Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/spec/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ Defines:
`automaticallyDownloadsUpdates` setting when automatic updates are available.
- Sparkle must use a 24-hour scheduled check interval, and each fresh app launch should request one
immediate background check after the updater starts when the selected mode is Notify or Install.
- Install mode must keep Sparkle's automatic download/install behavior enabled and must invoke
Sparkle's immediate install-and-relaunch handler after an automatic update is prepared. If a
capture, quick screenshot, Settings window, or permission recovery guide is active, Rsnap must
defer that handler until Rsnap returns to its idle menubar state. Rsnap must not add a separate
custom update prompt for this automatic path.
- The Auto Update secondary text must use sentence case, must not read like download or install
progress, and should display Sparkle's last successful check time while Notify or Install is
selected. When Sparkle is not configured in a development build, the secondary text may state
Expand Down
35 changes: 34 additions & 1 deletion native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,15 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg
private var selfCaptureRegistrationWindow: NSWindow?
private var didBootstrap = false
private var didPresentLaunchPermissionOnboarding = false
private var settingsWindowIsVisible = false
private var permissionRecoveryGuideIsVisible = false
private lazy var softwareUpdater = NativeHostSoftwareUpdater()
@objc public dynamic var window: NSWindow?
private lazy var sessionController: CaptureSessionController = {
let controller = CaptureSessionController(settingsStore: settingsStore)
controller.captureStateDidChange = { [weak self] in
self?.refreshStatusMenuState()
self?.retryDeferredSoftwareUpdateInstall()
}
controller.sceneDidChange = { [weak self] scene in
self?.refreshHotKeyBindings(for: scene.mode)
Expand Down Expand Up @@ -147,10 +150,13 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg
)
Self.applyApplicationIcon()
configureStatusItem()
_ = softwareUpdater
softwareUpdater.canPerformImmediateInstall = { [weak self] in
self?.canPerformImmediateSoftwareUpdateInstall ?? true
}
configureGlobalHotKeys()
quickScreenshotController.onStateChanged = { [weak self] in
self?.refreshStatusMenuState()
self?.retryDeferredSoftwareUpdateInstall()
}
showSelfCaptureRegistrationWindow()
NotificationCenter.default.addObserver(
Expand Down Expand Up @@ -253,6 +259,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg

@objc
private func openSettings(_ sender: Any?) {
settingsWindowIsVisible = true
settingsWindowController.present()
}

Expand Down Expand Up @@ -292,7 +299,9 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg
oncePerLaunch: Bool = false
) -> Bool {
guard NativePermissions.screenRecordingGranted == false else {
permissionRecoveryGuideIsVisible = false
permissionRecoveryWindowController.close()
retryDeferredSoftwareUpdateInstall()
return false
}
if oncePerLaunch {
Expand All @@ -301,6 +310,11 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg
}
didPresentLaunchPermissionOnboarding = true
}
permissionRecoveryWindowController.onClose = { [weak self] in
self?.permissionRecoveryGuideIsVisible = false
self?.retryDeferredSoftwareUpdateInstall()
}
permissionRecoveryGuideIsVisible = true
permissionRecoveryWindowController.present()
NativeHostTelemetry.lifecycleEvent(
"native_host.permission_recovery_presented",
Expand All @@ -316,7 +330,9 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg
else {
return
}
self.settingsWindowIsVisible = false
NSApp.setActivationPolicy(.accessory)
self.retryDeferredSoftwareUpdateInstall()
}
}

Expand Down Expand Up @@ -415,6 +431,23 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg
quickScreenshotMenuItem?.isEnabled = !isCaptureActive
}

private var canPerformImmediateSoftwareUpdateInstall: Bool {
SoftwareUpdateImmediateInstallGate.canInstall(
captureActive: sessionController.isCaptureActive,
quickScreenshotActive: quickScreenshotController.isActive,
userFacingWindowVisible: isUserFacingWindowVisible)
}

private func retryDeferredSoftwareUpdateInstall() {
DispatchQueue.main.async { [weak self] in
self?.softwareUpdater.retryDeferredImmediateInstall()
}
}

private var isUserFacingWindowVisible: Bool {
settingsWindowIsVisible || permissionRecoveryGuideIsVisible
}

private func updateCaptureMenuShortcut() {
guard let captureMenuItem else {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,17 @@ final class NativeHostSoftwareUpdater: NSObject {
host: "github.com",
path: "/hack-ink/rsnap/releases/latest")

var canPerformImmediateInstall: (() -> Bool)?

private var updaterController: SPUStandardUpdaterController?
private var pendingImmediateInstall: (() -> Void)?

override init() {
super.init()
if Self.hasSparkleConfiguration {
let controller = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
updaterDelegate: self,
userDriverDelegate: self)
updaterController = controller
NativeHostTelemetry.lifecycleEvent("native_host.sparkle_updater_started")
Expand Down Expand Up @@ -125,6 +128,10 @@ final class NativeHostSoftwareUpdater: NSObject {
detail: "mode=\(mode.rawValue)")
}

func retryDeferredImmediateInstall() {
_ = performPendingImmediateInstallIfPossible(source: "state_changed")
}

func checkForUpdates(_ sender: Any?) {
guard let updaterController else {
NSWorkspace.shared.open(Self.releasePageURL)
Expand Down Expand Up @@ -183,6 +190,24 @@ final class NativeHostSoftwareUpdater: NSObject {
NSRunningApplication.current.activate(options: [.activateAllWindows])
}

private func performPendingImmediateInstallIfPossible(source: String) -> Bool {
guard let install = pendingImmediateInstall else {
return false
}
guard canPerformImmediateInstall?() ?? true else {
NativeHostTelemetry.lifecycleEvent(
"native_host.sparkle_immediate_install_deferred",
detail: "source=\(source),reason=app_busy")
return false
}
pendingImmediateInstall = nil
NativeHostTelemetry.lifecycleEvent(
"native_host.sparkle_immediate_install_started",
detail: "source=\(source)")
install()
return true
}

private static func httpsURL(host: String, path: String) -> URL {
var components = URLComponents()
components.scheme = "https"
Expand All @@ -195,6 +220,18 @@ final class NativeHostSoftwareUpdater: NSObject {
}
}

extension NativeHostSoftwareUpdater: SPUUpdaterDelegate {
func updater(
_: SPUUpdater,
willInstallUpdateOnQuit _: SUAppcastItem,
immediateInstallationBlock immediateInstallHandler: @escaping () -> Void
) -> Bool {
pendingImmediateInstall = immediateInstallHandler
_ = performPendingImmediateInstallIfPossible(source: "sparkle_ready")
return true
}
}

extension NativeHostSoftwareUpdater: @preconcurrency SPUStandardUserDriverDelegate {
var supportsGentleScheduledUpdateReminders: Bool {
true
Expand Down Expand Up @@ -256,3 +293,15 @@ package enum SoftwareUpdateManualCheckAvailability {
return true
}
}

package enum SoftwareUpdateImmediateInstallGate {
package static func canInstall(
captureActive: Bool,
quickScreenshotActive: Bool,
userFacingWindowVisible: Bool
) -> Bool {
captureActive == false
&& quickScreenshotActive == false
&& userFacingWindowVisible == false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ final class PermissionRecoveryGuideWindowController: NSWindowController {
private var guideDirection: GuideDirection = .left
private let materialView = NSVisualEffectView()
private var hostingController: NSHostingController<PermissionRecoveryGuideView>?
var onClose: (() -> Void)?

init() {
let panel = NSPanel(
Expand Down Expand Up @@ -67,6 +68,7 @@ final class PermissionRecoveryGuideWindowController: NSWindowController {
positionWorkItem = nil
statusPollWorkItem = nil
super.close()
onClose?()
}

private func updateRootView() {
Expand Down
28 changes: 28 additions & 0 deletions native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum RsnapNativeHostKitProbe {
assertScrollCaptureObservedInputAcceptsSourceWindowGutter()
assertSoftwareUpdateModeResolution()
assertManualUpdateCheckRemainsAvailable()
assertImmediateInstallGateWaitsForCaptureIdle()
let minimapExportSize = CGSize(width: 100, height: 200)
guard
let rightMinimap = scrollCaptureMinimapPlan(
Expand Down Expand Up @@ -98,6 +99,33 @@ enum RsnapNativeHostKitProbe {
}
}

private static func assertImmediateInstallGateWaitsForCaptureIdle() {
guard
SoftwareUpdateImmediateInstallGate.canInstall(
captureActive: false,
quickScreenshotActive: false,
userFacingWindowVisible: false),
SoftwareUpdateImmediateInstallGate.canInstall(
captureActive: true,
quickScreenshotActive: false,
userFacingWindowVisible: false) == false,
SoftwareUpdateImmediateInstallGate.canInstall(
captureActive: false,
quickScreenshotActive: true,
userFacingWindowVisible: false) == false,
SoftwareUpdateImmediateInstallGate.canInstall(
captureActive: true,
quickScreenshotActive: true,
userFacingWindowVisible: false) == false,
SoftwareUpdateImmediateInstallGate.canInstall(
captureActive: false,
quickScreenshotActive: false,
userFacingWindowVisible: true) == false
else {
fatalError("immediate update install should wait until Rsnap is idle")
}
}

private static func assertCaptureOverlayLocalPointKeepsScreenEdgesVisible() {
let windowFrame = CGRect(x: 0, y: 0, width: 1_440, height: 900)
let bounds = CGRect(x: 0, y: 0, width: 1_440, height: 900)
Expand Down
11 changes: 5 additions & 6 deletions scripts/smoke/sparkle-update-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ Builds a local Sparkle update fixture:
4. sign the zip and write appcast.xml
5. serve the appcast locally and launch the old app

The final Install and Relaunch confirmation is intentionally manual.
The final version readback is manually gated so the operator can observe the automatic
install-and-relaunch path.

Options:
--prepare-only build fixtures and print paths without launching the app or server
Expand Down Expand Up @@ -222,15 +223,13 @@ Appcast URL: $APPCAST_URL
HTTP log: $LOG_PATH

Next manual steps:
1. In Rsnap, open Settings -> About.
2. Click Check.
3. In Sparkle's updater window, confirm the update and click Install and Relaunch.
4. Return here and press Enter.
1. Wait for Rsnap to detect, install, and relaunch from the local appcast.
2. Return here and press Enter.
EOF

pkill -f "$old_app/Contents/MacOS/RsnapNativeHost" >/dev/null 2>&1 || true
/usr/bin/open -n "$old_app"
read -r -p "Press Enter after Sparkle finishes Install and Relaunch..."
read -r -p "Press Enter after Rsnap finishes the automatic install and relaunch..."

actual_version="$(plutil -extract CFBundleVersion raw "$old_app/Contents/Info.plist")"
if [[ "$actual_version" != "$NEW_VERSION" ]]; then
Expand Down