From cad0ee00449c82d3f9d876494bc70b51fc174efd Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 23 May 2026 08:20:25 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Gate Sparkle auto-install on Rsnap idle state","authority":"manual"} --- docs/spec/settings.md | 5 ++ .../RsnapNativeHostKit/NativeHostApp.swift | 35 ++++++++++++- .../NativeHostSoftwareUpdater.swift | 51 ++++++++++++++++++- .../PermissionRecoveryGuideWindow.swift | 2 + .../RsnapNativeHostKitProbe/main.swift | 28 ++++++++++ scripts/smoke/sparkle-update-local.sh | 11 ++-- 6 files changed, 124 insertions(+), 8 deletions(-) diff --git a/docs/spec/settings.md b/docs/spec/settings.md index 757bc019..d932bcbf 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 f6489105..dad435dc 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 e43ab336..8dbeb27b 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 fdf79d9b..27f5f598 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 1b51ab2b..97e7ba47 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 7e6e6b0a..ce4a09fa 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