diff --git a/docs/spec/settings.md b/docs/spec/settings.md index 757bc01..d932bcb 100644 --- a/docs/spec/settings.md +++ b/docs/spec/settings.md @@ -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 diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift index f648910..dad435d 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift @@ -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) @@ -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( @@ -253,6 +259,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg @objc private func openSettings(_ sender: Any?) { + settingsWindowIsVisible = true settingsWindowController.present() } @@ -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 { @@ -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", @@ -316,7 +330,9 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg else { return } + self.settingsWindowIsVisible = false NSApp.setActivationPolicy(.accessory) + self.retryDeferredSoftwareUpdateInstall() } } @@ -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 diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift index e43ab33..8dbeb27 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift @@ -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") @@ -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) @@ -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" @@ -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 @@ -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 + } +} diff --git a/native/macos-host/Sources/RsnapNativeHostKit/PermissionRecoveryGuideWindow.swift b/native/macos-host/Sources/RsnapNativeHostKit/PermissionRecoveryGuideWindow.swift index fdf79d9..27f5f59 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/PermissionRecoveryGuideWindow.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/PermissionRecoveryGuideWindow.swift @@ -26,6 +26,7 @@ final class PermissionRecoveryGuideWindowController: NSWindowController { private var guideDirection: GuideDirection = .left private let materialView = NSVisualEffectView() private var hostingController: NSHostingController? + var onClose: (() -> Void)? init() { let panel = NSPanel( @@ -67,6 +68,7 @@ final class PermissionRecoveryGuideWindowController: NSWindowController { positionWorkItem = nil statusPollWorkItem = nil super.close() + onClose?() } private func updateRootView() { diff --git a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift index 1b51ab2..97e7ba4 100644 --- a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift +++ b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift @@ -14,6 +14,7 @@ enum RsnapNativeHostKitProbe { assertScrollCaptureObservedInputAcceptsSourceWindowGutter() assertSoftwareUpdateModeResolution() assertManualUpdateCheckRemainsAvailable() + assertImmediateInstallGateWaitsForCaptureIdle() let minimapExportSize = CGSize(width: 100, height: 200) guard let rightMinimap = scrollCaptureMinimapPlan( @@ -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) diff --git a/scripts/smoke/sparkle-update-local.sh b/scripts/smoke/sparkle-update-local.sh index 7e6e6b0..ce4a09f 100755 --- a/scripts/smoke/sparkle-update-local.sh +++ b/scripts/smoke/sparkle-update-local.sh @@ -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 @@ -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