From f22d1ed39b5508433d4783ee6fd515ef12353cc0 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 7 May 2026 20:22:31 +0800 Subject: [PATCH] {"schema":"maestro/commit/1","summary":"Add Sparkle automatic updates","authority":"manual"} --- .github/workflows/release.yml | 18 +- Cargo.lock | 8 +- Cargo.toml | 8 +- Makefile.toml | 8 + README.md | 11 +- .../smoke-perf-validation-surface.md | 2 +- docs/runbook/performance-validation.md | 4 +- docs/runbook/scroll-capture-benchmarks.md | 2 +- docs/runbook/validate-release.md | 43 +++- docs/spec/capture-session.md | 2 +- docs/spec/settings.md | 22 ++ native/macos-host/Package.resolved | 11 +- native/macos-host/Package.swift | 6 +- .../RsnapNativeHostKit/NativeHostApp.swift | 9 + .../RsnapNativeHostKit/NativeHostPanels.swift | 10 +- .../NativeHostSettingsView.swift | 118 ++++++++- .../NativeHostSoftwareUpdater.swift | 198 ++++++++++++++ scripts/build_and_run.sh | 72 +++++- scripts/release/sparkle-appcast.sh | 156 ++++++++++++ scripts/smoke/sparkle-update-local.sh | 241 ++++++++++++++++++ 20 files changed, 915 insertions(+), 34 deletions(-) create mode 100644 native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift create mode 100755 scripts/release/sparkle-appcast.sh create mode 100755 scripts/smoke/sparkle-update-local.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80fc11c5..14a82f54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: full MACOS_RELEASE_ASSET: rsnap-aarch64-apple-darwin.zip + MACOS_APPCAST_ASSET: appcast.xml on: push: @@ -58,13 +59,17 @@ jobs: APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} APPLE_NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_ISSUER }} APPLE_NOTARY_KEY_P8: ${{ secrets.APPLE_NOTARY_KEY_P8 }} + SPARKLE_PUBLIC_ED_KEY: ${{ secrets.SPARKLE_PUBLIC_ED_KEY }} + SPARKLE_PRIVATE_ED_KEY: ${{ secrets.SPARKLE_PRIVATE_ED_KEY }} run: | set -euo pipefail for required_secret in \ APPLE_CERTIFICATE_P12_BASE64 \ APPLE_CERTIFICATE_PASSWORD \ - APPLE_SIGNING_IDENTITY; do + APPLE_SIGNING_IDENTITY \ + SPARKLE_PUBLIC_ED_KEY \ + SPARKLE_PRIVATE_ED_KEY; do if [[ -z "${!required_secret:-}" ]]; then echo "Missing required secret: ${required_secret}" >&2 exit 1 @@ -113,6 +118,7 @@ jobs: RSNAP_NATIVE_HOST_RUST_PROFILE=final-release \ RSNAP_NATIVE_HOST_SWIFT_CONFIGURATION=release \ RSNAP_NATIVE_HOST_SIGN_IDENTITY="${APPLE_SIGNING_IDENTITY}" \ + RSNAP_SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY}" \ ./scripts/build_and_run.sh stage APP_PATH="target/rsnap-native-host/Rsnap.app" @@ -154,11 +160,19 @@ jobs: "${APP_PATH}" \ "${MACOS_RELEASE_ASSET}" + scripts/release/sparkle-appcast.sh \ + --archive "${MACOS_RELEASE_ASSET}" \ + --appcast "${MACOS_APPCAST_ASSET}" \ + --version "${VERSION}" \ + --tag "${GITHUB_REF_NAME}" + - name: Upload artifact uses: actions/upload-artifact@v7 with: name: rsnap-aarch64-apple-darwin - path: ${{ env.MACOS_RELEASE_ASSET }} + path: | + ${{ env.MACOS_RELEASE_ASSET }} + ${{ env.MACOS_APPCAST_ASSET }} if-no-files-found: error retention-days: 1 diff --git a/Cargo.lock b/Cargo.lock index 7f8efa5c..96642030 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3363,7 +3363,7 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rsnap" -version = "0.1.2" +version = "0.1.3" dependencies = [ "color-eyre", "directories", @@ -3376,7 +3376,7 @@ dependencies = [ [[package]] name = "rsnap-capture-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "image", "serde", @@ -3384,7 +3384,7 @@ dependencies = [ [[package]] name = "rsnap-host-ffi" -version = "0.1.2" +version = "0.1.3" dependencies = [ "rsnap-capture-core", "rsnap-overlay", @@ -3392,7 +3392,7 @@ dependencies = [ [[package]] name = "rsnap-overlay" -version = "0.1.2" +version = "0.1.3" dependencies = [ "block2 0.6.2", "color-eyre", diff --git a/Cargo.toml b/Cargo.toml index 31286c00..4be0934f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ homepage = "https://hack.ink/rsnap" license = "GPL-3.0" readme = "README.md" repository = "https://github.com/hack-ink/rsnap" -version = "0.1.2" +version = "0.1.3" [workspace.dependencies] arboard = { version = "3.6" } @@ -52,9 +52,9 @@ wgpu = { version = "29.0" } winit = { version = "0.30", features = ["rwh_06"] } xcap = { version = "0.9" } -rsnap-capture-core = { version = "0.1.2", path = "packages/rsnap-capture-core" } -rsnap-host-ffi = { version = "0.1.2", path = "packages/rsnap-host-ffi" } -rsnap-overlay = { version = "0.1.2", path = "packages/rsnap-overlay" } +rsnap-capture-core = { version = "0.1.3", path = "packages/rsnap-capture-core" } +rsnap-host-ffi = { version = "0.1.3", path = "packages/rsnap-host-ffi" } +rsnap-overlay = { version = "0.1.3", path = "packages/rsnap-overlay" } [profile.final-release] inherits = "release" diff --git a/Makefile.toml b/Makefile.toml index eeed3a02..c23f1808 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -261,9 +261,17 @@ codesign -dv --verbose=4 "$APP_PATH" 2>&1 | grep -q '^TeamIdentifier=' test -x "$APP_PATH/Contents/MacOS/RsnapNativeHost" test -f "$APP_PATH/Contents/Info.plist" test -f "$APP_PATH/Contents/Resources/AppIcon.icns" +test -d "$APP_PATH/Contents/Frameworks/Sparkle.framework" +otool -L "$APP_PATH/Contents/MacOS/RsnapNativeHost" | grep -q '@rpath/Sparkle.framework' +otool -l "$APP_PATH/Contents/MacOS/RsnapNativeHost" | grep -q '@executable_path/../Frameworks' plutil -extract CFBundleName raw "$APP_PATH/Contents/Info.plist" | grep -qx 'Rsnap' plutil -extract CFBundleDisplayName raw "$APP_PATH/Contents/Info.plist" | grep -qx 'Rsnap' plutil -extract CFBundleIdentifier raw "$APP_PATH/Contents/Info.plist" | grep -qx 'ink.hack.rsnap' +plutil -extract SUFeedURL raw "$APP_PATH/Contents/Info.plist" \ + | grep -qx 'https://github.com/hack-ink/rsnap/releases/latest/download/appcast.xml' +plutil -extract SUEnableAutomaticChecks raw "$APP_PATH/Contents/Info.plist" | grep -qx 'true' +plutil -extract SUAutomaticallyUpdate raw "$APP_PATH/Contents/Info.plist" | grep -qx 'true' +plutil -extract SUScheduledCheckInterval raw "$APP_PATH/Contents/Info.plist" | grep -qx '86400' if plutil -extract LSUIElement raw "$APP_PATH/Contents/Info.plist" >/dev/null 2>&1; then echo "LSUIElement must stay unset so Settings is a normal foreground window" >&2 exit 1 diff --git a/README.md b/README.md index 4198012d..6bd725df 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Prototype / in active development. - Menubar and Dock are not included in live window-outline targeting. - Windows support is planned (minimum Windows 10), but not implemented yet. - The scroll-capture engine, deterministic replay, and benchmark surfaces remain in the repository, - but the v0.1.2 native-host release does not expose scroll capture in the toolbar. + but the v0.1.3 native-host release does not expose scroll capture in the toolbar. ## Usage @@ -85,6 +85,10 @@ Download the latest macOS zip: Unzip it and move `Rsnap.app` to `/Applications`. +Release builds include Sparkle-based updates. Use `Settings...` -> `About` -> `Check` for the +standard macOS update flow; the About Auto Update mode defaults to `Install` for signed release builds +with the Sparkle appcast configured. + #### Build from Source ```sh @@ -119,7 +123,7 @@ Rsnap currently relies on **Screen Recording** permission to capture other apps/ - ScreenCaptureKit live sampling on macOS requires macOS 12.3+ and Screen Recording permission. - Normal region/window/monitor capture does not require Accessibility or Input Monitoring. - The retained scroll-capture path uses Screen Recording-backed screenshots plus forwarded wheel - input, but the v0.1.2 native-host release does not expose scroll capture in the toolbar. + input, but the v0.1.3 native-host release does not expose scroll capture in the toolbar. - macOS may describe Screen Recording as `Screen & System Audio Recording` or as direct screen/audio access when Rsnap bypasses the system picker. - Settings -> Permissions shows Screen Recording as the only required permission. - Normal native capture depends on Screen Recording; if access is missing, Rsnap opens the Screen Recording page in System Settings and shows a floating drag-to-grant guide. @@ -152,7 +156,7 @@ Rsnap currently relies on **Screen Recording** permission to capture other apps/ ### Current scroll-capture status -Scroll capture is temporarily hidden in the v0.1.2 native-host release. The retained Rust +Scroll capture is temporarily hidden in the v0.1.3 native-host release. The retained Rust scroll-capture session, deterministic replay, and benchmark surfaces remain for validation and future re-enablement, but users should not expect a `Scroll Capture` toolbar item in this release. @@ -189,6 +193,7 @@ scripts/smoke/analyze-scroll-capture-trace.sh scripts/smoke/native-hud-follow-macos.sh scripts/smoke/self-check-macos.sh scripts/smoke/macos.sh +scripts/smoke/sparkle-update-local.sh scripts/perf/local.sh scripts/perf/self-check-macos.sh scripts/perf/macos.sh diff --git a/docs/reference/smoke-perf-validation-surface.md b/docs/reference/smoke-perf-validation-surface.md index d1850929..97de24b6 100644 --- a/docs/reference/smoke-perf-validation-surface.md +++ b/docs/reference/smoke-perf-validation-surface.md @@ -17,7 +17,7 @@ Depends on: `docs/runbook/performance-validation.md`; `docs/spec/performance.md` Covers: The current layer map for smoke/perf entrypoints, deterministic replay/bench surfaces, overlay runtime integration tests, and scroll-capture session semantics tests. -Release exposure note: v0.1.2 hides user-facing scroll capture in the native host. The +Release exposure note: v0.1.3 hides user-facing scroll capture in the native host. The scroll-capture entries in this reference describe retained internal validation assets, not a visible toolbar feature in that release. diff --git a/docs/runbook/performance-validation.md b/docs/runbook/performance-validation.md index 908e4c5b..8aa7037b 100644 --- a/docs/runbook/performance-validation.md +++ b/docs/runbook/performance-validation.md @@ -17,9 +17,9 @@ Depends on: `docs/spec/performance.md` Outputs: A clear command choice for the regression class you are testing, plus a repeatable local baseline workflow for the committed Criterion benchmark targets. -Current release status: v0.1.2 hides user-facing scroll capture in the native host. The replay and +Current release status: v0.1.3 hides user-facing scroll capture in the native host. The replay and benchmark commands in this runbook still own retained internal scroll-capture engine validation and -future re-enablement work, but they are not evidence that the v0.1.2 toolbar exposes scroll +future re-enablement work, but they are not evidence that the v0.1.3 toolbar exposes scroll capture. ## Command selection diff --git a/docs/runbook/scroll-capture-benchmarks.md b/docs/runbook/scroll-capture-benchmarks.md index 3ef4a740..13d2adad 100644 --- a/docs/runbook/scroll-capture-benchmarks.md +++ b/docs/runbook/scroll-capture-benchmarks.md @@ -14,7 +14,7 @@ Depends on: `docs/spec/performance.md` Outputs: A repeatable local benchmark run, an optional saved Criterion baseline, and a clear understanding of what the synthetic fixture is intended to cover. -Current release status: v0.1.2 hides user-facing scroll capture in the native host. This runbook +Current release status: v0.1.3 hides user-facing scroll capture in the native host. This runbook still applies to the retained internal scroll-capture engine, replay, and future re-enablement work. diff --git a/docs/runbook/validate-release.md b/docs/runbook/validate-release.md index 68ce402a..c903c59a 100644 --- a/docs/runbook/validate-release.md +++ b/docs/runbook/validate-release.md @@ -26,7 +26,10 @@ manual first-run/user-flow validation. - No existing local or remote tag already uses `v`. 2. Confirm release credentials: - Apple signing certificate secrets are available to the Release workflow. - - Apple notary credentials are optional for v0.1.2; when absent, the Release workflow still + - Sparkle update signing secrets are available to the Release workflow: + `SPARKLE_PUBLIC_ED_KEY` for `SUPublicEDKey` and `SPARKLE_PRIVATE_ED_KEY` for signing the + published update archive. + - Apple notary credentials are optional for v0.1.3; when absent, the Release workflow still publishes a signed but unnotarized macOS zip. 3. Confirm local gates: - `cargo make checks` @@ -55,10 +58,24 @@ Validate these user-visible flows: fullscreen fallback. - Frozen toolbar tools: pointer, pen, arrow, text, mosaic, spotlight, undo, redo, auto-center, Recognize Text, copy, and save. -- Scroll capture is hidden in the v0.1.2 native-host release: the toolbar must not show a scroll +- Scroll capture is hidden in the v0.1.3 native-host release: the toolbar must not show a scroll capture item, and pressing `s` must not enter scroll capture. - Light and dark appearance; Classic Glass and Liquid Glass where the OS and current build support Liquid Glass. +- Settings -> About update rows: `Auto Update` and `Release Version` must use Title Case for row + titles. The Auto Update mode control must show `Off`, `Notify`, and `Install`; secondary text must use + sentence case, must not look like download progress, and the release-configured build must not + report that the Sparkle appcast is missing. +- Sparkle local update smoke: + +```sh +scripts/smoke/sparkle-update-local.sh +``` + + The script builds a disposable old app, a higher-version update archive, a local signed appcast, + and a local HTTP server. The final Sparkle `Install and Relaunch` confirmation remains manual; + after confirming it, return to the script and press Enter so it can verify the old bundle's + `CFBundleVersion` changed to the update version. - Output directory, filename prefix, sequence/timestamp naming, clipboard copy, and save failure handling where practical. @@ -77,9 +94,9 @@ user-entered annotation text. 2. Watch the Release workflow for the exact tag. 3. Treat a build, signing, or packaging failure as a release blocker. 4. Treat notarization failure as a release blocker only when notary credentials are configured. -5. The Release workflow publishes the signed macOS zip to the GitHub release. It notarizes and - staples the app only when notary credentials are configured. It does not publish crates.io - packages or non-macOS desktop archives for v0.1.2. +5. The Release workflow publishes the signed macOS zip and `appcast.xml` to the GitHub release. + It notarizes and staples the app only when notary credentials are configured. It does not + publish crates.io packages or non-macOS desktop archives for v0.1.3. ## Published Artifact Check @@ -91,6 +108,10 @@ After the Release workflow succeeds: - The app bundle is `Rsnap.app`. - `CFBundleName` and `CFBundleDisplayName` are `Rsnap`. - `CFBundleIdentifier` is `ink.hack.rsnap`. + - `SUFeedURL` is + `https://github.com/hack-ink/rsnap/releases/latest/download/appcast.xml`. + - `SUPublicEDKey` is present. + - `Sparkle.framework` is present in `Contents/Frameworks`. 3. Verify the signature: ```sh @@ -107,5 +128,13 @@ For a signed but unnotarized build, Gatekeeper may still block a quarantined dow quarantine override documented in `README.md` only for a bundle built locally or downloaded from this repository's GitHub Releases page. -5. Launch the downloaded app and repeat a minimal capture, toolbar, OCR, copy, and save check. -6. Confirm release notes and the macOS zip were published. +5. Confirm the appcast asset was published: + +```sh +curl -fsSL https://github.com/hack-ink/rsnap/releases/latest/download/appcast.xml \ + | grep -q 'sparkle:edSignature' +``` + +6. Launch the downloaded app and repeat a minimal capture, toolbar, OCR, copy, save, and About + update check. +7. Confirm release notes, the macOS zip, and the appcast were published. diff --git a/docs/spec/capture-session.md b/docs/spec/capture-session.md index 7976c546..8cd66fda 100644 --- a/docs/spec/capture-session.md +++ b/docs/spec/capture-session.md @@ -162,7 +162,7 @@ product level rather than binding itself to a particular window toolkit or shell ## Scroll capture -- The v0.1.2 native-host release does not expose scroll capture. The frozen toolbar MUST NOT show a +- The v0.1.3 native-host release does not expose scroll capture. The frozen toolbar MUST NOT show a scroll-capture item while the native-host scroll-capture gate is disabled, and plain `s` MUST NOT enter scroll capture in that state. - When scroll capture is re-enabled, it is available only from a dragged-region freeze on macOS. diff --git a/docs/spec/settings.md b/docs/spec/settings.md index 7d803216..aa371364 100644 --- a/docs/spec/settings.md +++ b/docs/spec/settings.md @@ -83,6 +83,7 @@ Defines: - HUD tint: `0.4990234375`. - HUD tint hue: `0.6074879184861536`. - Loupe sample size: small. +- Sparkle update mode: install in release builds. ## Permission Settings @@ -110,6 +111,27 @@ Defines: `https://x.com/YvetteCipher`. - The creator link may encourage following for ongoing Rsnap updates and may state that follows help support future work through X creator rewards. +- Release builds must use Sparkle's standard updater UI and appcast format for macOS self-updates. + GitHub Releases remains the distribution surface, but the Sparkle appcast at + `https://github.com/hack-ink/rsnap/releases/latest/download/appcast.xml` is the update-version + authority for in-app update checks. +- The appcast must compare against the running app bundle's `CFBundleVersion`. The user-visible + version should remain `CFBundleShortVersionString`. +- The About section must expose a Check for Updates action backed by Sparkle's standard check + flow. When an installable update is available, Sparkle owns the native update window, download + progress, install authorization if needed, and final install-and-relaunch action. +- The About section must expose one Auto Update mode control rather than separate Automatic Checks + and Automatic Updates rows. The visible modes are Off, Notify, and Install. +- Off must disable Sparkle automatic checks and automatic downloads. Notify must enable Sparkle + automatic checks without automatic downloads. Install must enable automatic checks and Sparkle's + `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. +- 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 + that the signed appcast is not configured. +- The About section must not display last checked as a separate row. - The About section must not expose capture defaults or a Restore Defaults action. ## Default-Size Usability diff --git a/native/macos-host/Package.resolved b/native/macos-host/Package.resolved index 76b83bd7..736e79cf 100644 --- a/native/macos-host/Package.resolved +++ b/native/macos-host/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "c0b742de72dd1e5131410a36e724f0790338ceb514c3db6b94cd5da9967e75e7", + "originHash" : "9c05e994e9f87c939c8cda211d145e263758e49e626a3bf1ca423d96a5e990be", "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", + "version" : "2.9.1" + } + }, { "identity" : "swiftlintplugins", "kind" : "remoteSourceControl", diff --git a/native/macos-host/Package.swift b/native/macos-host/Package.swift index 151bd113..e5815389 100644 --- a/native/macos-host/Package.swift +++ b/native/macos-host/Package.swift @@ -21,6 +21,7 @@ let package = Package( .executable(name: "RsnapNativeHost", targets: ["RsnapNativeHost"]), ], dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.1"), .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", exact: "0.63.2"), ], targets: [ @@ -50,7 +51,10 @@ let package = Package( ), .target( name: "RsnapNativeHostKit", - dependencies: ["RsnapHostBridge"], + dependencies: [ + "RsnapHostBridge", + .product(name: "Sparkle", package: "Sparkle"), + ], resources: [ .process("Resources"), ], diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift index ad760a9f..8c28139c 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift @@ -310,6 +310,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg private var selfCaptureRegistrationWindow: NSWindow? private var didBootstrap = false private var didPresentLaunchPermissionOnboarding = false + private let softwareUpdater = NativeHostSoftwareUpdater() @objc public dynamic var window: NSWindow? private lazy var sessionController: CaptureSessionController = { let controller = CaptureSessionController(settingsStore: settingsStore) @@ -326,6 +327,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg private lazy var permissionRecoveryWindowController = PermissionRecoveryGuideWindowController() private lazy var settingsWindowController = SettingsWindowController( settingsStore: settingsStore, + softwareUpdater: softwareUpdater, onClose: { [weak self] in self?.settingsWindowDidClose() }) @@ -357,6 +359,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg refreshStatusMenuState() sessionController.prepareLiveFrameStreamSampler(reason: "launch") scheduleLaunchPermissionOnboardingIfNeeded() + scheduleLaunchUpdateCheckIfEnabled() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in self?.sessionController.refreshShareableContentCacheIfPermitted(source: "launch") } @@ -444,6 +447,12 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg } } + private func scheduleLaunchUpdateCheckIfEnabled() { + Task { @MainActor [weak self] in + self?.softwareUpdater.checkForUpdatesInBackgroundOnLaunchIfEnabled() + } + } + @discardableResult private func presentPermissionRecoveryIfNeeded( source: String, diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostPanels.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostPanels.swift index 4b7cde71..8fe54d3e 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostPanels.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostPanels.swift @@ -55,8 +55,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private let viewModel: NativeHostSettingsViewModel private let onClose: () -> Void - init(settingsStore: NativeHostSettingsStore, onClose: @escaping () -> Void = {}) { - self.viewModel = NativeHostSettingsViewModel(settingsStore: settingsStore) + init( + settingsStore: NativeHostSettingsStore, + softwareUpdater: NativeHostSoftwareUpdater, + onClose: @escaping () -> Void = {} + ) { + self.viewModel = NativeHostSettingsViewModel( + settingsStore: settingsStore, + softwareUpdater: softwareUpdater) self.onClose = onClose let contentRect = NSRect( diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift index 866f22f6..4c483b40 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift @@ -13,17 +13,25 @@ enum NativeHostSettingsWindowMetrics { final class NativeHostSettingsViewModel: ObservableObject { @Published private(set) var settings: NativeHostSettings @Published private(set) var launchAtLoginState = LaunchAtLoginState.current() + @Published private(set) var softwareUpdateSettings: NativeHostSoftwareUpdater.Snapshot private let settingsStore: NativeHostSettingsStore + private let softwareUpdater: NativeHostSoftwareUpdater - init(settingsStore: NativeHostSettingsStore) { + init( + settingsStore: NativeHostSettingsStore, + softwareUpdater: NativeHostSoftwareUpdater + ) { self.settingsStore = settingsStore + self.softwareUpdater = softwareUpdater self.settings = settingsStore.settings + self.softwareUpdateSettings = softwareUpdater.snapshot() } func refresh() { settings = settingsStore.settings launchAtLoginState = LaunchAtLoginState.current() + softwareUpdateSettings = softwareUpdater.snapshot() } func update(_ mutate: (inout NativeHostSettings) -> Void) { @@ -45,6 +53,16 @@ final class NativeHostSettingsViewModel: ObservableObject { } } + func setSoftwareUpdateMode(_ mode: NativeHostSoftwareUpdater.Mode) { + softwareUpdater.setMode(mode) + refresh() + } + + func checkForUpdates() { + softwareUpdater.checkForUpdates(nil) + refresh() + } + func chooseOutputDirectory() { let panel = NSOpenPanel() panel.canChooseDirectories = true @@ -334,7 +352,7 @@ private struct SettingsDashboard: View { case .permissions: PermissionsSettingsPanel(model: model) case .about: - AboutSettingsPanel() + AboutSettingsPanel(model: model) } } } @@ -834,6 +852,60 @@ private struct LoupeSampleSizePicker: View { } } +private struct SoftwareUpdateModePicker: View { + let snapshot: NativeHostSoftwareUpdater.Snapshot + let onSelect: (NativeHostSoftwareUpdater.Mode) -> Void + + var body: some View { + HStack(spacing: 8) { + ForEach(NativeHostSoftwareUpdater.Mode.allCases, id: \.rawValue) { mode in + let enabled = isEnabled(mode) + ModernSegmentButton( + title: mode.title, + isSelected: snapshot.mode == mode, + isEnabled: enabled + ) { + onSelect(mode) + } + .help(helpText(for: mode, isEnabled: enabled)) + } + } + .padding(.horizontal, 1) + .frame(width: SettingsControlLayout.controlColumnWidth) + .segmentedGlassBackground() + } + + private func isEnabled(_ mode: NativeHostSoftwareUpdater.Mode) -> Bool { + guard snapshot.isConfigured else { + return false + } + if mode == .install { + return snapshot.allowsAutomaticUpdates + } + return true + } + + private func helpText( + for mode: NativeHostSoftwareUpdater.Mode, + isEnabled: Bool + ) -> String { + if !snapshot.isConfigured { + return "Sparkle appcast not configured." + } + if mode == .install, !isEnabled { + return "Automatic install is unavailable." + } + switch mode { + case .off: + return "Turn off automatic update checks." + case .check: + return "Check automatically and notify when an update is available." + case .install: + return "Download updates automatically and install after confirmation." + } + } +} + private struct OutputNamingPicker: View { let selection: OutputNamingPreference let onSelect: (OutputNamingPreference) -> Void @@ -1662,6 +1734,8 @@ private struct PermissionStateBadge: View { } private struct AboutSettingsPanel: View { + @ObservedObject var model: NativeHostSettingsViewModel + var body: some View { VStack(alignment: .leading, spacing: 8) { AboutIntroBlock() @@ -1674,6 +1748,46 @@ private struct AboutSettingsPanel: View { urlString: NativeHostAboutLinks.source ) } + + VStack(spacing: 0) { + SettingsControlTile( + symbolName: "arrow.triangle.2.circlepath", + title: "Auto Update", + subtitle: model.softwareUpdateSettings.modeSubtitle + ) { + SoftwareUpdateModePicker(snapshot: model.softwareUpdateSettings) { mode in + model.setSoftwareUpdateMode(mode) + } + } + + SettingsControlTile( + symbolName: "tag", + title: model.softwareUpdateSettings.releaseVersionTitle, + subtitle: model.softwareUpdateSettings.releaseVersionSubtitle + ) { + UpdateCheckButtonGroup(model: model) + } + } + } + } +} + +private struct UpdateCheckButtonGroup: View { + @ObservedObject var model: NativeHostSettingsViewModel + + var body: some View { + HStack(spacing: 6) { + Button { + model.checkForUpdates() + } label: { + Label("Check", systemImage: "arrow.clockwise") + .labelStyle(.titleAndIcon) + } + .rsnapGlassButton(prominent: false) + .controlSize(.small) + .disabled( + model.softwareUpdateSettings.isConfigured + && !model.softwareUpdateSettings.canCheckForUpdates) } } } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift new file mode 100644 index 00000000..08ee728f --- /dev/null +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift @@ -0,0 +1,198 @@ +import AppKit +import Foundation +import Sparkle + +@MainActor +final class NativeHostSoftwareUpdater { + enum Mode: String, CaseIterable { + case off + case check + case install + + var title: String { + switch self { + case .off: + return "Off" + case .check: + return "Notify" + case .install: + return "Install" + } + } + } + + struct Snapshot: Equatable { + let isConfigured: Bool + let canCheckForUpdates: Bool + let allowsAutomaticUpdates: Bool + let mode: Mode + let currentVersion: String + let lastCheckSummary: String + + var modeSubtitle: String { + if !isConfigured { + return "Sparkle appcast not configured." + } + switch mode { + case .off: + return "Automatic update checks are off." + case .check, .install: + return lastCheckSummary + } + } + + var releaseVersionTitle: String { + if isConfigured { + return "Release Version" + } + return "GitHub Release" + } + + var releaseVersionSubtitle: String { + if isConfigured { + return "Current \(currentVersion); Sparkle appcast." + } + return "Current \(currentVersion); opens latest release." + } + } + + static let releasePageURL = httpsURL( + host: "github.com", + path: "/hack-ink/rsnap/releases/latest") + + private let updaterController: SPUStandardUpdaterController? + + init() { + if Self.hasSparkleConfiguration { + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil) + NativeHostTelemetry.lifecycleEvent("native_host.sparkle_updater_started") + } else { + updaterController = nil + NativeHostTelemetry.lifecycleWarning( + "native_host.sparkle_updater_unconfigured", + detail: "reason=missing_feed_or_public_key") + } + } + + func snapshot() -> Snapshot { + guard let updater = updaterController?.updater else { + return Snapshot( + isConfigured: false, + canCheckForUpdates: true, + allowsAutomaticUpdates: false, + mode: .off, + currentVersion: Self.currentAppVersionLabel, + lastCheckSummary: "Never checked." + ) + } + return Snapshot( + isConfigured: true, + canCheckForUpdates: updater.canCheckForUpdates, + allowsAutomaticUpdates: updater.allowsAutomaticUpdates, + mode: Self.mode( + automaticallyChecksForUpdates: updater.automaticallyChecksForUpdates, + automaticallyDownloadsUpdates: updater.automaticallyDownloadsUpdates), + currentVersion: Self.currentAppVersionLabel, + lastCheckSummary: Self.lastCheckSummary(for: updater.lastUpdateCheckDate) + ) + } + + func setMode(_ mode: Mode) { + guard let updater = updaterController?.updater else { + return + } + switch mode { + case .off: + updater.automaticallyDownloadsUpdates = false + updater.automaticallyChecksForUpdates = false + case .check: + updater.automaticallyChecksForUpdates = true + updater.automaticallyDownloadsUpdates = false + case .install: + updater.automaticallyChecksForUpdates = true + if updater.allowsAutomaticUpdates { + updater.automaticallyDownloadsUpdates = true + } + } + NativeHostTelemetry.lifecycleEvent( + "native_host.sparkle_update_mode_changed", + detail: "mode=\(mode.rawValue)") + } + + func checkForUpdates(_ sender: Any?) { + guard let updaterController else { + NSWorkspace.shared.open(Self.releasePageURL) + return + } + NSApp.setActivationPolicy(.regular) + NSRunningApplication.current.activate(options: [.activateAllWindows]) + updaterController.checkForUpdates(sender) + } + + func checkForUpdatesInBackgroundOnLaunchIfEnabled() { + guard let updater = updaterController?.updater else { + return + } + guard updater.automaticallyChecksForUpdates else { + NativeHostTelemetry.lifecycleDebug( + "native_host.sparkle_update_check_skipped", + detail: "source=launch,reason=disabled") + return + } + updater.checkForUpdatesInBackground() + NativeHostTelemetry.lifecycleEvent( + "native_host.sparkle_update_check_scheduled", + detail: "source=launch") + } + + private static var hasSparkleConfiguration: Bool { + nonEmptyInfoValue(forKey: "SUFeedURL") != nil + && nonEmptyInfoValue(forKey: "SUPublicEDKey") != nil + } + + private static func mode( + automaticallyChecksForUpdates: Bool, + automaticallyDownloadsUpdates: Bool + ) -> Mode { + if automaticallyDownloadsUpdates { + return .install + } + if automaticallyChecksForUpdates { + return .check + } + return .off + } + + private static var currentAppVersionLabel: String { + nonEmptyInfoValue(forKey: "CFBundleShortVersionString") ?? "Development Build" + } + + private static func lastCheckSummary(for checkedAt: Date?) -> String { + guard let checkedAt else { + return "Never checked." + } + return "Last checked \(checkedAt.formatted(date: .abbreviated, time: .shortened))." + } + + private static func nonEmptyInfoValue(forKey key: String) -> String? { + guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func httpsURL(host: String, path: String) -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = host + components.path = path + guard let url = components.url else { + preconditionFailure("Invalid static Rsnap update URL: \(host)\(path)") + } + return url + } +} diff --git a/scripts/build_and_run.sh b/scripts/build_and_run.sh index 20d87798..5205398e 100755 --- a/scripts/build_and_run.sh +++ b/scripts/build_and_run.sh @@ -16,6 +16,7 @@ APP_BUNDLE="$STAGE_DIR/$APP_NAME.app" APP_CONTENTS="$APP_BUNDLE/Contents" APP_MACOS="$APP_CONTENTS/MacOS" APP_RESOURCES="$APP_CONTENTS/Resources" +APP_FRAMEWORKS="$APP_CONTENTS/Frameworks" APP_BINARY="$APP_MACOS/$EXECUTABLE_NAME" APP_SOURCE_BINARY_CACHE="$STAGE_DIR/.${EXECUTABLE_NAME}.source-bin" STAGE_FINGERPRINT_FILE="$STAGE_DIR/.stage-fingerprint" @@ -24,6 +25,8 @@ APP_ICON_SOURCE="$ROOT_DIR/assets/app-icon/generated/app-icon.icns" APP_ICON_NAME="AppIcon.icns" STATUS_ICON_SOURCE="$ROOT_DIR/assets/tray-icon/generated/tray-icon-template.png" STATUS_ICON_NAME="StatusBarIcon.png" +SPARKLE_APPCAST_URL="${RSNAP_SPARKLE_APPCAST_URL:-https://github.com/hack-ink/rsnap/releases/latest/download/appcast.xml}" +SPARKLE_PUBLIC_ED_KEY="${RSNAP_SPARKLE_PUBLIC_ED_KEY:-}" BUILD_ROOT="" BUILD_BINARY="" SWIFT_BUILD_FLAGS=() @@ -55,8 +58,11 @@ if [[ "$SWIFT_CONFIGURATION" == "release" ]]; then SWIFT_BUILD_FLAGS=(-c release) fi -APP_VERSION="$(sed -n '/^\[workspace.package\]/,/^\[/s/^version *= *"\(.*\)"/\1/p' "$ROOT_DIR/Cargo.toml" | head -n 1)" -APP_VERSION="${APP_VERSION:-0.1.2}" +APP_VERSION="${RSNAP_NATIVE_HOST_APP_VERSION:-}" +if [[ -z "$APP_VERSION" ]]; then + APP_VERSION="$(sed -n '/^\[workspace.package\]/,/^\[/s/^version *= *"\(.*\)"/\1/p' "$ROOT_DIR/Cargo.toml" | head -n 1)" +fi +APP_VERSION="${APP_VERSION:-0.1.3}" require_liquid_glass_capable_swift_for_release() { [[ "$SWIFT_CONFIGURATION" == "release" ]] || return 0 @@ -168,6 +174,40 @@ sync_bundle_dir() { return 0 } +sync_framework_dir() { + local source_dir="$1" + local destination_dir="$2" + if [[ ! -d "$source_dir" ]]; then + return 1 + fi + + mkdir -p "$(dirname "$destination_dir")" + rm -rf "$destination_dir" + ditto "$source_dir" "$destination_dir" + return 0 +} + +stage_sparkle_framework() { + local source_framework="" + if [[ -d "$PACKAGE_DIR/.build/artifacts" ]]; then + source_framework="$( + find "$PACKAGE_DIR/.build/artifacts" \ + -type d \ + -name 'Sparkle.framework' \ + -print \ + -quit + )" + fi + if [[ -z "$source_framework" ]]; then + echo "error: Sparkle.framework was not found in native/macos-host/.build/artifacts." >&2 + echo "error: run swift package resolve/build for the Sparkle SwiftPM artifact first." >&2 + exit 1 + fi + if sync_framework_dir "$source_framework" "$APP_FRAMEWORKS/Sparkle.framework"; then + STAGED_APP_DIRTY=1 + fi +} + write_if_changed() { local destination="$1" local contents="$2" @@ -207,7 +247,7 @@ canonicalize_app_bundle_name() { stage_app_bundle() { canonicalize_app_bundle_name - mkdir -p "$APP_MACOS" "$APP_RESOURCES" + mkdir -p "$APP_MACOS" "$APP_RESOURCES" "$APP_FRAMEWORKS" if [[ ! -x "$APP_BINARY" ]] || copy_if_changed "$BUILD_BINARY" "$APP_SOURCE_BINARY_CACHE"; then mkdir -p "$(dirname "$APP_SOURCE_BINARY_CACHE")" cp "$BUILD_BINARY" "$APP_BINARY" @@ -215,6 +255,12 @@ stage_app_bundle() { chmod +x "$APP_BINARY" fi + if otool -L "$APP_BINARY" | grep -q 'Sparkle.framework' \ + && ! otool -l "$APP_BINARY" | grep -q '@executable_path/../Frameworks'; then + install_name_tool -add_rpath '@executable_path/../Frameworks' "$APP_BINARY" + STAGED_APP_DIRTY=1 + fi + if [[ -f "$APP_ICON_SOURCE" ]]; then if copy_if_changed "$APP_ICON_SOURCE" "$APP_RESOURCES/$APP_ICON_NAME"; then STAGED_APP_DIRTY=1 @@ -234,6 +280,8 @@ stage_app_bundle() { fi done < <(find "$BUILD_ROOT" -maxdepth 1 -name '*.bundle' -type d | sort) + stage_sparkle_framework + local info_plist_contents info_plist_contents="$(cat < @@ -260,9 +308,27 @@ stage_app_bundle() { NSPrincipalClass NSApplication + SUFeedURL + $SPARKLE_APPCAST_URL + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 + SUAutomaticallyUpdate + + SUAllowsAutomaticUpdates + PLIST )" + if [[ -n "$SPARKLE_PUBLIC_ED_KEY" ]]; then + info_plist_contents+="$(cat <SUPublicEDKey + $SPARKLE_PUBLIC_ED_KEY +PLIST +)" + fi + if [[ -f "$APP_RESOURCES/$APP_ICON_NAME" ]]; then info_plist_contents+="$(cat <CFBundleIconFile diff --git a/scripts/release/sparkle-appcast.sh b/scripts/release/sparkle-appcast.sh new file mode 100755 index 00000000..0a2a4cd7 --- /dev/null +++ b/scripts/release/sparkle-appcast.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +usage: scripts/release/sparkle-appcast.sh --archive ZIP --appcast XML --version VERSION --tag TAG + +Creates a Sparkle appcast for the signed Rsnap macOS release archive. + +Required environment: + SPARKLE_PRIVATE_ED_KEY private EdDSA key used by Sparkle sign_update + +Optional environment: + SPARKLE_SIGN_UPDATE explicit path to Sparkle's sign_update tool + SPARKLE_ARCHIVE_URL explicit appcast download URL for the release archive + SPARKLE_RELEASE_NOTES_URL + explicit appcast release notes URL +USAGE +} + +archive="" +appcast="" +version="" +tag="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --archive) + archive="${2:-}" + shift 2 + ;; + --appcast) + appcast="${2:-}" + shift 2 + ;; + --version) + version="${2:-}" + shift 2 + ;; + --tag) + tag="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +for required_value in archive appcast version tag; do + if [[ -z "${!required_value}" ]]; then + echo "error: missing --${required_value}" >&2 + usage + exit 2 + fi +done + +if [[ ! -f "$archive" ]]; then + echo "error: release archive not found: $archive" >&2 + exit 1 +fi + +if [[ -z "${SPARKLE_PRIVATE_ED_KEY:-}" ]]; then + echo "error: SPARKLE_PRIVATE_ED_KEY is required to sign the Sparkle update archive" >&2 + exit 1 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +sign_update="${SPARKLE_SIGN_UPDATE:-}" +if [[ -z "$sign_update" ]]; then + sign_update="$( + find "$repo_root/native/macos-host/.build/artifacts" \ + -type f \ + -path '*/bin/sign_update' \ + -print \ + -quit + )" +fi + +if [[ -z "$sign_update" || ! -x "$sign_update" ]]; then + echo "error: Sparkle sign_update tool was not found or is not executable" >&2 + echo "error: run swift package resolve/build for native/macos-host first" >&2 + exit 1 +fi + +signature_fragment="$( + printf '%s\n' "$SPARKLE_PRIVATE_ED_KEY" \ + | "$sign_update" --ed-key-file - "$archive" +)" +if [[ "$signature_fragment" != *"sparkle:edSignature="* || "$signature_fragment" != *"length="* ]]; then + echo "error: unexpected Sparkle signature fragment: $signature_fragment" >&2 + exit 1 +fi + +VERSION="$version" \ + TAG="$tag" \ + ARCHIVE="$(basename "$archive")" \ + APPCAST="$appcast" \ + SPARKLE_ARCHIVE_URL="${SPARKLE_ARCHIVE_URL:-}" \ + SPARKLE_RELEASE_NOTES_URL="${SPARKLE_RELEASE_NOTES_URL:-}" \ + SPARKLE_SIGNATURE_FRAGMENT="$signature_fragment" \ + python3 - <<'PY' +import email.utils +import os +from pathlib import Path +from textwrap import dedent +from xml.sax.saxutils import escape + +version = os.environ["VERSION"] +tag = os.environ["TAG"] +archive = os.environ["ARCHIVE"] +appcast = os.environ["APPCAST"] +signature_fragment = os.environ["SPARKLE_SIGNATURE_FRAGMENT"].strip() +archive_url = os.environ["SPARKLE_ARCHIVE_URL"].strip() +release_notes_url = os.environ["SPARKLE_RELEASE_NOTES_URL"].strip() + +download_url = archive_url or f"https://github.com/hack-ink/rsnap/releases/download/{tag}/{archive}" +release_url = release_notes_url or f"https://github.com/hack-ink/rsnap/releases/tag/{tag}" +pub_date = email.utils.formatdate(usegmt=True) + +xml = dedent( + f"""\ + + + + Rsnap Updates + https://github.com/hack-ink/rsnap/releases + Rsnap macOS app updates. + en + + Version {escape(version)} + {escape(release_url)} + {escape(version)} + {escape(version)} + 14.0 + arm64 + {escape(release_url)} + {escape(pub_date)} + + + + + """ +) +Path(appcast).write_text(xml, encoding="utf-8") +PY diff --git a/scripts/smoke/sparkle-update-local.sh b/scripts/smoke/sparkle-update-local.sh new file mode 100755 index 00000000..7e6e6b0a --- /dev/null +++ b/scripts/smoke/sparkle-update-local.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +COMMON_ROOT="$(cd "$(git -C "$ROOT_DIR" rev-parse --git-common-dir)/.." && pwd)" +WORK_ROOT="${RSNAP_SPARKLE_SMOKE_WORK_ROOT:-$COMMON_ROOT/target/rsnap-sparkle-update-smoke}" +ARCHIVE_NAME="rsnap-aarch64-apple-darwin.zip" +OLD_VERSION="${RSNAP_SPARKLE_SMOKE_OLD_VERSION:-0.1.2}" +NEW_VERSION="${RSNAP_SPARKLE_SMOKE_NEW_VERSION:-99.0.0}" +HOST="${RSNAP_SPARKLE_SMOKE_HOST:-127.0.0.1}" +PORT="${RSNAP_SPARKLE_SMOKE_PORT:-}" +PREPARE_ONLY=0 +SELF_CHECK=0 + +usage() { + cat <<'USAGE' +Usage: scripts/smoke/sparkle-update-local.sh [--prepare-only] [--self-check] + +Builds a local Sparkle update fixture: + 1. generate temporary Sparkle test keys + 2. stage an old Rsnap.app pointing at a local appcast + 3. stage a higher-version Rsnap.app and zip it + 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. + +Options: + --prepare-only build fixtures and print paths without launching the app or server + --self-check run a fast appcast-generation smoke without building apps +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --prepare-only) + PREPARE_ONLY=1 + shift + ;; + --self-check) + SELF_CHECK=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +generate_test_keys() { + swift - <<'SWIFT' +import CryptoKit +import Foundation + +let privateKey = Curve25519.Signing.PrivateKey() +print(privateKey.rawRepresentation.base64EncodedString()) +print(privateKey.publicKey.rawRepresentation.base64EncodedString()) +SWIFT +} + +choose_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +run_self_check() { + local tmpdir archive appcast fake_sign_update + tmpdir="$(mktemp -d)" + archive="$tmpdir/$ARCHIVE_NAME" + appcast="$tmpdir/appcast.xml" + fake_sign_update="$tmpdir/sign_update" + printf 'zip-bytes' >"$archive" + cat >"$fake_sign_update" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +file="" +while [[ $# -gt 0 ]]; do + case "$1" in + --ed-key-file) + read -r _private_key + shift 2 + ;; + *) + file="$1" + shift + ;; + esac +done +size="$(wc -c <"$file" | tr -d ' ')" +printf 'sparkle:edSignature="fake-signature" length="%s"\n' "$size" +SH + chmod +x "$fake_sign_update" + SPARKLE_PRIVATE_ED_KEY="fake-private-key" \ + SPARKLE_SIGN_UPDATE="$fake_sign_update" \ + SPARKLE_ARCHIVE_URL="http://127.0.0.1:9/$ARCHIVE_NAME" \ + SPARKLE_RELEASE_NOTES_URL="http://127.0.0.1:9/release-notes.html" \ + "$ROOT_DIR/scripts/release/sparkle-appcast.sh" \ + --archive "$archive" \ + --appcast "$appcast" \ + --version "99.0.0" \ + --tag "v99.0.0" + python3 - "$appcast" <<'PY' +import sys +import xml.etree.ElementTree as ET + +path = sys.argv[1] +text = open(path, encoding="utf-8").read() +assert "http://127.0.0.1:9/rsnap-aarch64-apple-darwin.zip" in text +assert 'sparkle:edSignature="fake-signature"' in text +assert 'length="9"' in text +assert ET.parse(path).getroot().tag == "rss" +print("sparkle update local self-check ok") +PY +} + +if [[ "$SELF_CHECK" == "1" ]]; then + run_self_check + exit 0 +fi + +if [[ -z "$PORT" ]]; then + PORT="$(choose_port)" +fi + +SERVER_DIR="$WORK_ROOT/server" +OLD_STAGE_DIR="$WORK_ROOT/old" +NEW_STAGE_DIR="$WORK_ROOT/new" +LOG_PATH="$WORK_ROOT/http.log" +APPCAST_URL="http://$HOST:$PORT/appcast.xml" +ARCHIVE_URL="http://$HOST:$PORT/$ARCHIVE_NAME" +RELEASE_NOTES_URL="http://$HOST:$PORT/release-notes.html" + +sparkle_key_output="$(generate_test_keys)" +SPARKLE_PRIVATE_ED_KEY="$(printf '%s\n' "$sparkle_key_output" | sed -n '1p')" +SPARKLE_PUBLIC_ED_KEY="$(printf '%s\n' "$sparkle_key_output" | sed -n '2p')" + +rm -rf "$WORK_ROOT" +mkdir -p "$SERVER_DIR" "$OLD_STAGE_DIR" "$NEW_STAGE_DIR" + +cat >"$SERVER_DIR/release-notes.html" < +

Rsnap $NEW_VERSION Smoke Update

+HTML + +echo "Building new Rsnap.app $NEW_VERSION..." +RSNAP_NATIVE_HOST_STAGE_DIR="$NEW_STAGE_DIR" \ + RSNAP_NATIVE_HOST_APP_VERSION="$NEW_VERSION" \ + RSNAP_SPARKLE_APPCAST_URL="$APPCAST_URL" \ + RSNAP_SPARKLE_PUBLIC_ED_KEY="$SPARKLE_PUBLIC_ED_KEY" \ + RSNAP_NATIVE_HOST_FORCE_REBUILD=1 \ + "$ROOT_DIR/scripts/build_and_run.sh" stage + +ditto -c -k --sequesterRsrc --keepParent \ + "$NEW_STAGE_DIR/Rsnap.app" \ + "$SERVER_DIR/$ARCHIVE_NAME" + +SPARKLE_PRIVATE_ED_KEY="$SPARKLE_PRIVATE_ED_KEY" \ + SPARKLE_ARCHIVE_URL="$ARCHIVE_URL" \ + SPARKLE_RELEASE_NOTES_URL="$RELEASE_NOTES_URL" \ + "$ROOT_DIR/scripts/release/sparkle-appcast.sh" \ + --archive "$SERVER_DIR/$ARCHIVE_NAME" \ + --appcast "$SERVER_DIR/appcast.xml" \ + --version "$NEW_VERSION" \ + --tag "v$NEW_VERSION" + +echo "Building old Rsnap.app $OLD_VERSION..." +RSNAP_NATIVE_HOST_STAGE_DIR="$OLD_STAGE_DIR" \ + RSNAP_NATIVE_HOST_APP_VERSION="$OLD_VERSION" \ + RSNAP_SPARKLE_APPCAST_URL="$APPCAST_URL" \ + RSNAP_SPARKLE_PUBLIC_ED_KEY="$SPARKLE_PUBLIC_ED_KEY" \ + RSNAP_NATIVE_HOST_FORCE_REBUILD=1 \ + "$ROOT_DIR/scripts/build_and_run.sh" stage + +old_app="$OLD_STAGE_DIR/Rsnap.app" +actual_old_version="$(plutil -extract CFBundleVersion raw "$old_app/Contents/Info.plist")" +if [[ "$actual_old_version" != "$OLD_VERSION" ]]; then + echo "error: old app version mismatch: expected $OLD_VERSION, got $actual_old_version" >&2 + exit 1 +fi + +if [[ "$PREPARE_ONLY" == "1" ]]; then + cat <"$LOG_PATH" 2>&1 & +server_pid="$!" +cleanup() { + kill "$server_pid" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +sleep 0.5 +curl -fsSL "$APPCAST_URL" >/dev/null + +cat < About. + 2. Click Check. + 3. In Sparkle's updater window, confirm the update and click Install and Relaunch. + 4. 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..." + +actual_version="$(plutil -extract CFBundleVersion raw "$old_app/Contents/Info.plist")" +if [[ "$actual_version" != "$NEW_VERSION" ]]; then + echo "error: app did not update in place: expected $NEW_VERSION, got $actual_version" >&2 + exit 1 +fi + +echo "Sparkle update smoke passed: $OLD_VERSION -> $NEW_VERSION"