Merge branch 'feat/quiet-updates' into main
Merge branch 'feat/quiet-updates' into main
11 files changed,
+533
−17
Sources/RastrilloMacUpdates/NativeUpdateCommands.swift+14 −1Sources/RastrilloMacUpdates/NativeUpdateNotice.swift+42 −0Sources/RastrilloMacUpdates/NativeUpdatePolicy.swift+79 −0Sources/RastrilloMacUpdates/NativeUpdater.swift+161 −2Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings+5 −0Tests/RastrilloMacUpdatesTests/QuietUpdateTests.swift+123 −0docs/mac-updates.md+36 −3tools/mac_updates.py+26 −4tools/publish_mac_update.py+2 −2tools/release_mac.py+3 −2tools/tests/test_mac_updates.py+42 −3
CI failed — run details
diff --git a/Sources/RastrilloMacUpdates/NativeUpdateCommands.swift b/Sources/RastrilloMacUpdates/NativeUpdateCommands.swift| index 6fa1f10..dc42be9 100644 |
| --- a/Sources/RastrilloMacUpdates/NativeUpdateCommands.swift |
| +++ b/Sources/RastrilloMacUpdates/NativeUpdateCommands.swift |
| @@ -7,10 +7,23 @@ public struct NativeUpdateCommands: Commands { |
| public init(updater: NativeUpdater) { self.updater = updater } |
| - private static let resources = Bundle.main.url(forResource: "RastrilloNative_RastrilloMacUpdates", withExtension: "bundle").flatMap(Bundle.init(url:)) ?? Bundle.module |
| + static let resources = Bundle.main.url(forResource: "RastrilloNative_RastrilloMacUpdates", withExtension: "bundle").flatMap(Bundle.init(url:)) ?? Bundle.module |
| + |
| + static var appName: String { |
| + let info = Bundle.main.localizedInfoDictionary ?? Bundle.main.infoDictionary ?? [:] |
| + return (info["CFBundleDisplayName"] as? String) ?? (info["CFBundleName"] as? String) ?? ProcessInfo.processInfo.processName |
| + } |
| public var body: some Commands { |
| CommandGroup(after: .appInfo) { |
| + // A quiet updater's staged update, where a person looks for it. Disabled |
| + // while busy: the restart would end whatever the app is busy with. |
| + if case .ready = updater.pendingUpdate { |
| + Button(action: updater.installPendingUpdate) { |
| + Text("Update Ready — Restart \(Self.appName)", bundle: Self.resources) |
| + } |
| + .disabled(!updater.canInstallPendingUpdate) |
| + } |
| Button(action: updater.checkForUpdates) { Text("Check for Updates…", bundle: Self.resources) } |
| .disabled(!updater.canCheckForUpdates) |
| Toggle(isOn: $updater.automaticallyChecksForUpdates) { Text("Automatically Check for Updates", bundle: Self.resources) } |
diff --git a/Sources/RastrilloMacUpdates/NativeUpdateNotice.swift b/Sources/RastrilloMacUpdates/NativeUpdateNotice.swift| new file mode 100644 |
| index 0000000..659707d |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/NativeUpdateNotice.swift |
| @@ -0,0 +1,42 @@ |
| +#if os(macOS) |
| +import SwiftUI |
| + |
| +/// One quiet line about a quiet updater's pending update, for an app's home screen. |
| +/// Empty when nothing is pending or while the app is busy, so it can sit in a layout |
| +/// permanently. Place it where a person rests, never over work in progress. |
| +public struct NativeUpdateNotice: View { |
| + private let updater: NativeUpdater |
| + |
| + public init(updater: NativeUpdater) { self.updater = updater } |
| + |
| + public var body: some View { |
| + if updater.canInstallPendingUpdate { |
| + HStack(alignment: .firstTextBaseline, spacing: 8) { |
| + Label { message } icon: { Image(systemName: "arrow.down.circle") } |
| + .foregroundStyle(.secondary) |
| + Button(action: updater.installPendingUpdate) { action } |
| + .buttonStyle(.borderless) |
| + } |
| + .font(.callout) |
| + .accessibilityElement(children: .contain) |
| + } |
| + } |
| + |
| + private var app: String { NativeUpdateCommands.appName } |
| + |
| + private var message: Text { |
| + switch updater.pendingUpdate { |
| + case .ready(let version): Text("\(app) \(version) is ready.", bundle: NativeUpdateCommands.resources) |
| + case .available(let version): Text("\(app) \(version) is available.", bundle: NativeUpdateCommands.resources) |
| + case .none: Text(verbatim: "") |
| + } |
| + } |
| + |
| + private var action: Text { |
| + if case .ready = updater.pendingUpdate { |
| + return Text("Restart to Update", bundle: NativeUpdateCommands.resources) |
| + } |
| + return Text("Update…", bundle: NativeUpdateCommands.resources) |
| + } |
| +} |
| +#endif |
diff --git a/Sources/RastrilloMacUpdates/NativeUpdatePolicy.swift b/Sources/RastrilloMacUpdates/NativeUpdatePolicy.swift| new file mode 100644 |
| index 0000000..79f9b4a |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/NativeUpdatePolicy.swift |
| @@ -0,0 +1,79 @@ |
| +#if os(macOS) |
| +import Foundation |
| + |
| +/// How an app's updater behaves between the checks a person asks for. |
| +/// |
| +/// `standard` is Sparkle's own flow: it asks before checking automatically and |
| +/// shows its update alert when a scheduled check finds something. `quiet` never |
| +/// opens a window for a scheduled check. It downloads in the background, installs |
| +/// when the app quits, and publishes `NativeUpdater.pendingUpdate` so the app can |
| +/// show a small notice. While the app reports itself busy (`NativeUpdater.isBusy`, |
| +/// a call, say) scheduled checks wait until it is idle again, so a download never |
| +/// competes with the busy work. A check the person asks for always behaves normally. |
| +/// |
| +/// Quiet also needs the bundle packaged with `"install": "automatic"` in its |
| +/// `mac-updates.json`: that is what turns on automatic checks and downloads |
| +/// without Sparkle's first-launch question. See docs/mac-updates.md. |
| +public enum NativeUpdatePolicy: Sendable, Equatable { |
| + case standard |
| + case quiet |
| +} |
| + |
| +/// What a quiet updater has found on a schedule and not yet installed. |
| +public enum NativePendingUpdate: Sendable, Equatable { |
| + case none |
| + /// Found but not downloaded, because the person turned automatic updates off |
| + /// or the update needs their attention first. Check for Updates shows it. |
| + case available(version: String) |
| + /// Downloaded and staged. It installs when the app quits, or at once through |
| + /// `NativeUpdater.installPendingUpdate()`. |
| + case ready(version: String) |
| +} |
| + |
| +/// Which kind of check Sparkle is about to start, without importing Sparkle. |
| +enum NativeUpdateCheck: Sendable { |
| + case userInitiated, background, information |
| +} |
| + |
| +/// The quiet policy's decisions, kept apart from Sparkle so that they can be tested |
| +/// without a feed, a signed bundle or a network. |
| +struct QuietUpdateRules: Sendable, Equatable { |
| + private(set) var pending: NativePendingUpdate = .none |
| + private(set) var busy = false |
| + /// A scheduled check was declined while busy. Sparkle records the declined check |
| + /// as done and waits a full interval, so without this the update would wait a |
| + /// day for a call that lasted ten minutes. |
| + private(set) var deferredCheck = false |
| + |
| + /// Only scheduled checks wait. A person who chooses Check for Updates mid-call |
| + /// asked for it. |
| + mutating func mayPerform(_ check: NativeUpdateCheck) -> Bool { |
| + guard check == .background, busy else { return true } |
| + deferredCheck = true |
| + return false |
| + } |
| + |
| + /// Returns true when a check deferred while busy should run now. |
| + mutating func setBusy(_ newValue: Bool) -> Bool { |
| + busy = newValue |
| + guard !newValue, deferredCheck else { return false } |
| + deferredCheck = false |
| + return true |
| + } |
| + |
| + mutating func staged(version: String) { pending = .ready(version: version) } |
| + |
| + /// A scheduled update Sparkle would have shown in a window. One already |
| + /// downloaded is ready; otherwise the notice offers it. |
| + mutating func found(version: String, downloaded: Bool) { |
| + pending = downloaded ? .ready(version: version) : .available(version: version) |
| + } |
| + |
| + /// Sparkle's own window has the update now, or the session is over. |
| + mutating func clear() { pending = .none } |
| + |
| + /// Acting on a pending update either relaunches the app or opens Sparkle's |
| + /// window, so neither happens while busy. |
| + var canInstall: Bool { pending != .none && !busy } |
| +} |
| +#endif |
diff --git a/Sources/RastrilloMacUpdates/NativeUpdater.swift b/Sources/RastrilloMacUpdates/NativeUpdater.swift| index 868f8f6..7c29c9c 100644 |
| --- a/Sources/RastrilloMacUpdates/NativeUpdater.swift |
| +++ b/Sources/RastrilloMacUpdates/NativeUpdater.swift |
| @@ -2,21 +2,42 @@ |
| import Combine |
| import Foundation |
| import Observation |
| +import OSLog |
| import Sparkle |
| +/// Why an update did or did not arrive, in Console under the app's bundle ID. |
| +private let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "RastrilloMacUpdates", category: "updates") |
| + |
| /// One app-owned updater. Unconfigured development bundles never contact a feed. |
| @MainActor @Observable public final class NativeUpdater { |
| + public let policy: NativeUpdatePolicy |
| public private(set) var isConfigured = false |
| public private(set) var canCheckForUpdates = false |
| private var automaticChecks = false |
| + private var automaticDownloads = false |
| + private var rules = QuietUpdateRules() |
| @ObservationIgnored private var controller: SPUStandardUpdaterController? |
| + @ObservationIgnored private var quietDelegate: QuietUpdateDelegate? |
| @ObservationIgnored private var observations = Set<AnyCancellable>() |
| + /// Sparkle's "install now" for a staged update, held while the update waits for quit. |
| + @ObservationIgnored private var installNow: (() -> Void)? |
| + /// Starts the check that waited for the app to stop being busy. A seam for tests. |
| + @ObservationIgnored var runDeferredCheck: () -> Void = {} |
| - public init() { |
| + public init(policy: NativeUpdatePolicy = .standard) { |
| + self.policy = policy |
| guard Self.validConfiguration(Bundle.main.infoDictionary ?? [:]) else { return } |
| - let controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) |
| + let delegate = policy == .quiet ? QuietUpdateDelegate() : nil |
| + let controller = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: delegate, userDriverDelegate: delegate) |
| + quietDelegate = delegate |
| + delegate?.owner = self |
| self.controller = controller |
| isConfigured = true |
| + runDeferredCheck = { [weak controller] in |
| + // A person may have started a check since; Sparkle refuses a second session. |
| + guard let updater = controller?.updater, !updater.sessionInProgress, updater.automaticallyChecksForUpdates else { return } |
| + updater.checkForUpdatesInBackground() |
| + } |
| controller.updater.publisher(for: \.canCheckForUpdates) |
| .receive(on: DispatchQueue.main) |
| .sink { [weak self] in self?.canCheckForUpdates = $0 } |
| @@ -25,6 +46,12 @@ import Sparkle |
| .receive(on: DispatchQueue.main) |
| .sink { [weak self] in self?.automaticChecks = $0 } |
| .store(in: &observations) |
| + controller.updater.publisher(for: \.automaticallyDownloadsUpdates) |
| + .receive(on: DispatchQueue.main) |
| + .sink { [weak self] in self?.automaticDownloads = $0 } |
| + .store(in: &observations) |
| + // Started after the delegate is attached, so the first scheduled check is already quiet. |
| + controller.startUpdater() |
| } |
| public var automaticallyChecksForUpdates: Bool { |
| @@ -35,11 +62,84 @@ import Sparkle |
| } |
| } |
| + /// Checks, downloads and installs on quit without asking: the one switch a |
| + /// person needs. Turning it off keeps scheduled checks, so a quiet app still |
| + /// mentions a new version, but nothing downloads until they choose it. It stays |
| + /// off where the bundle was not packaged to allow automatic updates. |
| + public var automaticallyInstallsUpdates: Bool { |
| + get { automaticChecks && automaticDownloads } |
| + set { |
| + guard let updater = controller?.updater, updater.allowsAutomaticUpdates else { return } |
| + if newValue { updater.automaticallyChecksForUpdates = true } |
| + updater.automaticallyDownloadsUpdates = newValue |
| + } |
| + } |
| + |
| + /// Whether automatic installation can be offered at all. |
| + public var canInstallUpdatesAutomatically: Bool { controller?.updater.allowsAutomaticUpdates ?? false } |
| + |
| + /// The app is doing something an update must not interrupt. Quiet updaters hold |
| + /// scheduled checks and pending-update actions until it clears. |
| + public var isBusy: Bool { |
| + get { rules.busy } |
| + set { |
| + guard newValue != rules.busy else { return } |
| + if rules.setBusy(newValue) { |
| + log.info("No longer busy: running the deferred update check") |
| + runDeferredCheck() |
| + } |
| + } |
| + } |
| + |
| + /// What a quiet updater has found and not yet installed. Always `.none` for |
| + /// `standard`, which shows Sparkle's alert instead. |
| + public var pendingUpdate: NativePendingUpdate { rules.pending } |
| + |
| + /// Whether `installPendingUpdate()` would do anything now. |
| + public var canInstallPendingUpdate: Bool { rules.canInstall } |
| + |
| + /// Relaunches into a staged update, or shows Sparkle's window for one that still |
| + /// needs the person. Does nothing while busy. |
| + public func installPendingUpdate() { |
| + guard rules.canInstall else { return } |
| + if case .ready = rules.pending, let installNow { |
| + installNow() |
| + } else { |
| + // Sparkle already holds this update's session: checking brings it into focus. |
| + controller?.checkForUpdates(nil) |
| + } |
| + } |
| + |
| public func checkForUpdates() { |
| guard canCheckForUpdates else { return } |
| controller?.checkForUpdates(nil) |
| } |
| + // MARK: Quiet delegate events |
| + |
| + func mayPerform(_ check: NativeUpdateCheck) -> Bool { |
| + guard policy == .quiet, !rules.mayPerform(check) else { return true } |
| + log.info("Busy: deferring the scheduled update check") |
| + return false |
| + } |
| + |
| + func staged(version: String, installNow: @escaping () -> Void) { |
| + self.installNow = installNow |
| + rules.staged(version: version) |
| + log.info("Update \(version, privacy: .public) is staged and installs on quit") |
| + } |
| + |
| + func found(version: String, downloaded: Bool) { |
| + rules.found(version: version, downloaded: downloaded) |
| + log.info("Update \(version, privacy: .public) found (downloaded: \(downloaded)); shown as a notice") |
| + } |
| + |
| + /// Sparkle's window has the update, or the session ended; the notice goes. |
| + func sessionEnded() { |
| + installNow = nil |
| + rules.clear() |
| + } |
| + |
| static func validConfiguration(_ info: [String: Any]) -> Bool { |
| guard let feed = info["SUFeedURL"] as? String, |
| let url = URLComponents(string: feed), url.scheme == "https", |
| @@ -50,4 +150,63 @@ import Sparkle |
| return true |
| } |
| } |
| + |
| +/// Sparkle's two delegates for the quiet policy. Sparkle calls both on the main thread. |
| +final class QuietUpdateDelegate: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate { |
| + @MainActor weak var owner: NativeUpdater? |
| + |
| + static let deferredError = NSError(domain: "RastrilloMacUpdates", code: 1, userInfo: [NSLocalizedDescriptionKey: "Update check deferred while the app is busy."]) |
| + |
| + func updater(_ updater: SPUUpdater, mayPerform updateCheck: SPUUpdateCheck) throws { |
| + let check: NativeUpdateCheck = switch updateCheck { |
| + case .updates: .userInitiated |
| + case .updatesInBackground: .background |
| + default: .information |
| + } |
| + let allowed = MainActor.assumeIsolated { owner?.mayPerform(check) ?? true } |
| + // Declining a background check is silent: Sparkle logs it and schedules the next. |
| + if !allowed { throw Self.deferredError } |
| + } |
| + |
| + /// Holding the install block keeps the staged update, which still installs on |
| + /// quit, and lets the app offer "Restart" instead of Sparkle's window. |
| + func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock: @escaping () -> Void) -> Bool { |
| + MainActor.assumeIsolated { |
| + guard let owner else { return false } |
| + owner.staged(version: item.displayVersionString, installNow: immediateInstallationBlock) |
| + return true |
| + } |
| + } |
| + |
| + func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: (any Error)?) { |
| + // A held staged update only finishes its cycle by installing or failing; a |
| + // failed one must not keep offering a restart that does nothing. |
| + guard let error = error as NSError?, |
| + error.domain != Self.deferredError.domain || error.code != Self.deferredError.code else { return } |
| + MainActor.assumeIsolated { owner?.sessionEnded() } |
| + } |
| + |
| + var supportsGentleScheduledUpdateReminders: Bool { true } |
| + |
| + /// Never a window for a scheduled update: the app shows a notice instead. |
| + func standardUserDriverShouldHandleShowingScheduledUpdate(_ update: SUAppcastItem, andInImmediateFocus immediateFocus: Bool) -> Bool { |
| + false |
| + } |
| + |
| + func standardUserDriverWillHandleShowingUpdate(_ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, state: SPUUserUpdateState) { |
| + // User-initiated checks are shown by Sparkle and need no notice. |
| + guard !handleShowingUpdate else { return } |
| + MainActor.assumeIsolated { |
| + owner?.found(version: update.displayVersionString, downloaded: state.stage != .notDownloaded) |
| + } |
| + } |
| + |
| + func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { |
| + MainActor.assumeIsolated { owner?.sessionEnded() } |
| + } |
| + |
| + func standardUserDriverWillFinishUpdateSession() { |
| + MainActor.assumeIsolated { owner?.sessionEnded() } |
| + } |
| +} |
| #endif |
diff --git a/Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings| index b7bd360..0cf7cdd 100644 |
| --- a/Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings |
| +++ b/Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings |
| @@ -1,2 +1,7 @@ |
| "Check for Updates\u2026" = "Check for Updates…"; |
| "Automatically Check for Updates" = "Automatically Check for Updates"; |
| +"Update Ready \u2014 Restart %@" = "Update Ready — Restart %@"; |
| +"%@ %@ is ready." = "%1$@ %2$@ is ready."; |
| +"%@ %@ is available." = "%1$@ %2$@ is available."; |
| +"Restart to Update" = "Restart to Update"; |
| +"Update\u2026" = "Update…"; |
diff --git a/Tests/RastrilloMacUpdatesTests/QuietUpdateTests.swift b/Tests/RastrilloMacUpdatesTests/QuietUpdateTests.swift| new file mode 100644 |
| index 0000000..aa9e57e |
| --- /dev/null |
| +++ b/Tests/RastrilloMacUpdatesTests/QuietUpdateTests.swift |
| @@ -0,0 +1,123 @@ |
| +#if os(macOS) |
| +import XCTest |
| +@testable import RastrilloMacUpdates |
| + |
| +final class QuietUpdateRulesTests: XCTestCase { |
| + func testBusyDefersOnlyScheduledChecksAndRunsThemWhenIdle() { |
| + var rules = QuietUpdateRules() |
| + XCTAssertTrue(rules.mayPerform(.background)) |
| + XCTAssertFalse(rules.setBusy(true)) |
| + XCTAssertFalse(rules.mayPerform(.background)) |
| + // Asked for by the person: never held back. |
| + XCTAssertTrue(rules.mayPerform(.userInitiated)) |
| + XCTAssertTrue(rules.mayPerform(.information)) |
| + XCTAssertTrue(rules.setBusy(false), "the declined check runs when the call ends") |
| + XCTAssertFalse(rules.setBusy(true)) |
| + XCTAssertFalse(rules.setBusy(false), "and only once") |
| + } |
| + |
| + func testNoDeferredCheckWhenNothingWasDeclined() { |
| + var rules = QuietUpdateRules() |
| + _ = rules.setBusy(true) |
| + XCTAssertTrue(rules.mayPerform(.userInitiated)) |
| + XCTAssertFalse(rules.setBusy(false)) |
| + } |
| + |
| + func testStagedUpdateIsReadyButNotActionableWhileBusy() { |
| + var rules = QuietUpdateRules() |
| + XCTAssertFalse(rules.canInstall) |
| + rules.staged(version: "0.3.5") |
| + XCTAssertEqual(rules.pending, .ready(version: "0.3.5")) |
| + XCTAssertTrue(rules.canInstall) |
| + _ = rules.setBusy(true) |
| + XCTAssertEqual(rules.pending, .ready(version: "0.3.5"), "busy hides the action, not the fact") |
| + XCTAssertFalse(rules.canInstall) |
| + _ = rules.setBusy(false) |
| + XCTAssertTrue(rules.canInstall) |
| + rules.clear() |
| + XCTAssertEqual(rules.pending, .none) |
| + XCTAssertFalse(rules.canInstall) |
| + } |
| + |
| + func testGentleReminderDistinguishesDownloadedFromAvailable() { |
| + var rules = QuietUpdateRules() |
| + rules.found(version: "0.3.5", downloaded: false) |
| + XCTAssertEqual(rules.pending, .available(version: "0.3.5")) |
| + rules.found(version: "0.3.5", downloaded: true) |
| + XCTAssertEqual(rules.pending, .ready(version: "0.3.5")) |
| + } |
| +} |
| + |
| +@MainActor final class QuietUpdaterTests: XCTestCase { |
| + /// Sparkle finds its optional delegate methods by selector. A Swift signature |
| + /// that drifts from the header compiles without a word and turns quiet updates |
| + /// back into Sparkle's alerts. |
| + func testDelegateAnswersSparklesSelectors() { |
| + let delegate = QuietUpdateDelegate() |
| + for selector in [ |
| + "updater:mayPerformUpdateCheck:error:", |
| + "updater:willInstallUpdateOnQuit:immediateInstallationBlock:", |
| + "updater:didFinishUpdateCycleForUpdateCheck:error:", |
| + "supportsGentleScheduledUpdateReminders", |
| + "standardUserDriverShouldHandleShowingScheduledUpdate:andInImmediateFocus:", |
| + "standardUserDriverWillHandleShowingUpdate:forUpdate:state:", |
| + "standardUserDriverDidReceiveUserAttentionForUpdate:", |
| + "standardUserDriverWillFinishUpdateSession", |
| + ] { |
| + XCTAssertTrue(delegate.responds(to: NSSelectorFromString(selector)), selector) |
| + } |
| + XCTAssertTrue(delegate.supportsGentleScheduledUpdateReminders) |
| + } |
| + |
| + func testQuietUpdaterDefersAndReplaysScheduledCheck() { |
| + let updater = NativeUpdater(policy: .quiet) |
| + var replayed = 0 |
| + updater.runDeferredCheck = { replayed += 1 } |
| + updater.isBusy = true |
| + XCTAssertFalse(updater.mayPerform(.background)) |
| + XCTAssertTrue(updater.mayPerform(.userInitiated)) |
| + XCTAssertEqual(replayed, 0) |
| + updater.isBusy = false |
| + XCTAssertEqual(replayed, 1) |
| + } |
| + |
| + func testStandardUpdaterNeverDefers() { |
| + let updater = NativeUpdater() |
| + XCTAssertEqual(updater.policy, .standard) |
| + updater.isBusy = true |
| + XCTAssertTrue(updater.mayPerform(.background)) |
| + } |
| + |
| + func testRestartWaitsForIdleAndUsesSparklesInstallBlock() { |
| + let updater = NativeUpdater(policy: .quiet) |
| + var installs = 0 |
| + updater.isBusy = true |
| + updater.staged(version: "0.3.5") { installs += 1 } |
| + XCTAssertEqual(updater.pendingUpdate, .ready(version: "0.3.5")) |
| + XCTAssertFalse(updater.canInstallPendingUpdate) |
| + updater.installPendingUpdate() |
| + XCTAssertEqual(installs, 0, "never relaunch during a call") |
| + updater.isBusy = false |
| + XCTAssertTrue(updater.canInstallPendingUpdate) |
| + updater.installPendingUpdate() |
| + XCTAssertEqual(installs, 1) |
| + } |
| + |
| + func testEndedSessionDropsNoticeAndInstallBlock() { |
| + let updater = NativeUpdater(policy: .quiet) |
| + var installs = 0 |
| + updater.staged(version: "0.3.5") { installs += 1 } |
| + updater.sessionEnded() |
| + XCTAssertEqual(updater.pendingUpdate, .none) |
| + updater.installPendingUpdate() |
| + XCTAssertEqual(installs, 0) |
| + } |
| + |
| + func testUnconfiguredBundleCannotTurnOnAutomaticInstalls() { |
| + let updater = NativeUpdater(policy: .quiet) |
| + XCTAssertFalse(updater.canInstallUpdatesAutomatically) |
| + updater.automaticallyInstallsUpdates = true |
| + XCTAssertFalse(updater.automaticallyInstallsUpdates) |
| + } |
| +} |
| +#endif |
diff --git a/docs/mac-updates.md b/docs/mac-updates.md| index 7875194..7cf5269 100644 |
| --- a/docs/mac-updates.md |
| +++ b/docs/mac-updates.md |
| @@ -2,9 +2,42 @@ |
| Meet, Calendar, Memoria, Docs and Sheets share `RastrilloMacUpdates`, a macOS-only |
| Sparkle 2.9.6 integration. Each app owns one updater and puts Check for Updates |
| -and the automatic-check preference in its application menu. Sparkle asks before |
| -enabling automatic checks. Installation uses the standard confirmation/relaunch |
| -flow and the app's normal quit handling. iOS uses Apple's distribution mechanisms. |
| +and the automatic-check preference in its application menu. By default Sparkle asks |
| +before enabling automatic checks, and installation uses the standard |
| +confirmation/relaunch flow and the app's normal quit handling. iOS uses Apple's |
| +distribution mechanisms. |
| + |
| +## Quiet updates (opt-in) |
| + |
| +An app that must never be interrupted (Calls, during a call) opts into quiet |
| +updates in two places, and needs both: |
| + |
| +- `NativeUpdater(policy: .quiet)` in code. A scheduled check never opens a window. |
| + A staged update waits for quit, and `pendingUpdate` becomes `.ready`. The app |
| + menu gains "Update Ready — Restart <App>", and `NativeUpdateNotice` is a one-line |
| + notice for the app's home screen. If the person has turned automatic downloads |
| + off, a found update becomes `.available` and the notice offers it. Set |
| + `isBusy` while the app must not be disturbed. Scheduled checks are then declined |
| + and rerun once it clears, and the restart action is disabled. A declined check |
| + also means no download during a call. Check for Updates always shows Sparkle's |
| + standard UI, because the person asked for it. It is disabled while a staged |
| + update waits: the Restart item replaces it. |
| +- `"install": "automatic"` in `mac-updates.json`. Packaging then writes |
| + `SUAllowsAutomaticUpdates`, `SUEnableAutomaticChecks`, `SUAutomaticallyUpdate` |
| + and a daily `SUScheduledCheckInterval` into Info.plist. There is no first-launch |
| + permission question. These are defaults, so a person's own choice (the menu |
| + toggle, or `automaticallyInstallsUpdates` in the app's settings) is stored in |
| + their preferences and wins. The default, `"prompt"`, is the behaviour above, |
| + and release and publication reject an archive whose Info.plist disagrees with |
| + its configuration's mode. |
| + |
| +The installed build decides how an update arrives. A person running a build |
| +from before quiet updates sees Sparkle's usual prompts once more, for the update |
| +that brings quiet updates. |
| + |
| +The delegate methods are optional Objective-C selectors. A drifted Swift |
| +signature still compiles but is never called, and the app is back to Sparkle's |
| +alerts. `QuietUpdaterTests.testDelegateAnswersSparklesSelectors` pins them. |
| Unconfigured development builds disable update controls. A release must pass its |
| app-specific `native/mac-updates.json` to packaging. These files contain only the |
diff --git a/tools/mac_updates.py b/tools/mac_updates.py| index d6e5160..c7593f2 100644 |
| --- a/tools/mac_updates.py |
| +++ b/tools/mac_updates.py |
| @@ -20,9 +20,30 @@ def read_configuration(path): |
| raise ValueError('public_ed_key must encode an Ed25519 public key') |
| if not re.fullmatch(r'[A-Za-z0-9.-]+', config['bundle_id']): |
| raise ValueError('invalid bundle_id') |
| + config.setdefault('install', 'prompt') |
| + if config['install'] not in INSTALL_MODES: |
| + raise ValueError('install must be "prompt" or "automatic"') |
| return config |
| +# "prompt" is Sparkle's standard flow: it asks before checking automatically and |
| +# every install is confirmed. "automatic" checks daily, downloads in the background |
| +# and installs on quit without asking first; pair it with NativeUpdater(policy: |
| +# .quiet) so that nothing it finds opens a window. These are Info.plist defaults: |
| +# a person's own choice, stored in their preferences, still wins. |
| +INSTALL_MODES = { |
| + 'prompt': dict(SUAllowsAutomaticUpdates=False), |
| + 'automatic': dict(SUAllowsAutomaticUpdates=True, SUEnableAutomaticChecks=True, |
| + SUAutomaticallyUpdate=True, SUScheduledCheckInterval=86400), |
| +} |
| +SPARKLE_KEYS = ['SUFeedURL', 'SUPublicEDKey', 'SUEnableAutomaticChecks', 'SUAutomaticallyUpdate', |
| + 'SUAllowsAutomaticUpdates', 'SUVerifyUpdateBeforeExtraction', 'SUScheduledCheckInterval'] |
| + |
| + |
| +def install_keys(config): |
| + return INSTALL_MODES[config['install']] |
| + |
| + |
| def configure(app, configuration=None, version=None, build=None): |
| info_path = app / 'Contents/Info.plist' |
| info = plistlib.loads(info_path.read_bytes()) |
| @@ -34,16 +55,17 @@ def configure(app, configuration=None, version=None, build=None): |
| if not re.fullmatch(r'[1-9][0-9]*', build): |
| raise ValueError('build must be a positive increasing integer') |
| info['CFBundleVersion'] = build |
| - for key in ['SUFeedURL', 'SUPublicEDKey', 'SUEnableAutomaticChecks', 'SUAutomaticallyUpdate', 'SUAllowsAutomaticUpdates', 'SUVerifyUpdateBeforeExtraction']: |
| + for key in SPARKLE_KEYS: |
| info.pop(key, None) |
| if configuration: |
| config = read_configuration(configuration) |
| if config['bundle_id'] != info['CFBundleIdentifier']: |
| raise ValueError('update configuration belongs to another app') |
| info.update(SUFeedURL=config['feed_url'], SUPublicEDKey=config['public_ed_key'], |
| - SUVerifyUpdateBeforeExtraction=True, SUAllowsAutomaticUpdates=False) |
| - # Do not set SUEnableAutomaticChecks: Sparkle asks and preserves the user's choice. |
| - # Installs require the normal explicit relaunch prompt; editor quit guards remain active. |
| + SUVerifyUpdateBeforeExtraction=True, **install_keys(config)) |
| + # In "prompt" mode SUEnableAutomaticChecks stays unset: Sparkle asks and preserves |
| + # the user's choice, and installs need the normal explicit relaunch prompt. |
| + # Either way the app's quit handling (editor guards, leaving a call) still runs. |
| info_path.write_bytes(plistlib.dumps(info)) |
diff --git a/tools/publish_mac_update.py b/tools/publish_mac_update.py| index a260ef0..059ef6f 100644 |
| --- a/tools/publish_mac_update.py |
| +++ b/tools/publish_mac_update.py |
| @@ -19,7 +19,7 @@ from urllib.parse import quote, urlsplit |
| from urllib.request import urlopen |
| import xml.etree.ElementTree as ET |
| -from mac_updates import read_configuration |
| +from mac_updates import install_keys, read_configuration |
| from release_mac import MAX_ARCHIVE_BYTES |
| SPARKLE = '{http://www.andymatuschak.org/xml-namespaces/sparkle}' |
| @@ -86,7 +86,7 @@ def publish(release, config_path, repo, tools, do_publish=False): |
| info = plistlib.loads(zipped.read(plists[0])) |
| expected = {'CFBundleIdentifier': config['bundle_id'], 'SUFeedURL': config['feed_url'], |
| 'SUPublicEDKey': config['public_ed_key'], 'CFBundleVersion': manifest['build'], |
| - 'CFBundleShortVersionString': manifest['version']} |
| + 'CFBundleShortVersionString': manifest['version']} | install_keys(config) |
| if any(info.get(key) != value for key, value in expected.items()): |
| raise ValueError('signed archive metadata does not match the release and update configuration') |
| public_key = subprocess.check_output([str(tools / 'generate_keys'), '--account', config['keychain_account'], '-p'], text=True).strip() |
diff --git a/tools/release_mac.py b/tools/release_mac.py| index d205ecc..7ea1af1 100644 |
| --- a/tools/release_mac.py |
| +++ b/tools/release_mac.py |
| @@ -8,7 +8,7 @@ import shutil |
| import subprocess |
| import tempfile |
| -from mac_updates import read_configuration, sign_bundle |
| +from mac_updates import install_keys, read_configuration, sign_bundle |
| MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 |
| @@ -24,7 +24,8 @@ def prepare(app, config_path, tools, identity, team, notary_profile, output): |
| info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) |
| if (info.get('CFBundleIdentifier') != config['bundle_id'] or |
| info.get('SUFeedURL') != config['feed_url'] or |
| - info.get('SUPublicEDKey') != config['public_ed_key']): |
| + info.get('SUPublicEDKey') != config['public_ed_key'] or |
| + any(info.get(key) != value for key, value in install_keys(config).items())): |
| raise ValueError('build the app with this update configuration first') |
| build = info['CFBundleVersion'] |
| if not build.isdecimal() or int(build) < 1: |
diff --git a/tools/tests/test_mac_updates.py b/tools/tests/test_mac_updates.py| index 5bfac33..b7b03c0 100644 |
| --- a/tools/tests/test_mac_updates.py |
| +++ b/tools/tests/test_mac_updates.py |
| @@ -44,6 +44,28 @@ class MacUpdatesTests(unittest.TestCase): |
| configure(self.app) |
| self.assertNotIn('SUFeedURL', plistlib.loads(self.info.read_bytes())) |
| + def test_automatic_install_mode_sets_defaults_a_person_can_override(self): |
| + self.config.write_text(json.dumps(self.values | {'install': 'automatic'})) |
| + configure(self.app, self.config, '1.2.3', '2') |
| + info = plistlib.loads(self.info.read_bytes()) |
| + # Info.plist values are defaults: Sparkle reads a stored preference first. |
| + self.assertTrue(info['SUAllowsAutomaticUpdates']) |
| + self.assertTrue(info['SUEnableAutomaticChecks']) |
| + self.assertTrue(info['SUAutomaticallyUpdate']) |
| + self.assertEqual(info['SUScheduledCheckInterval'], 86400) |
| + # Rebuilding in the default mode leaves none of them behind. |
| + self.config.write_text(json.dumps(self.values)) |
| + configure(self.app, self.config) |
| + info = plistlib.loads(self.info.read_bytes()) |
| + self.assertFalse(info['SUAllowsAutomaticUpdates']) |
| + for key in ['SUEnableAutomaticChecks', 'SUAutomaticallyUpdate', 'SUScheduledCheckInterval']: |
| + self.assertNotIn(key, info) |
| + |
| + def test_unknown_install_mode_is_rejected(self): |
| + self.config.write_text(json.dumps(self.values | {'install': 'silent'})) |
| + with self.assertRaises(ValueError): |
| + configure(self.app, self.config) |
| + |
| def test_signing_keeps_device_grants_on_host_only(self): |
| current = self.app / 'Contents/Frameworks/Sparkle.framework/Versions/Current' |
| for path in ['XPCServices/Downloader.xpc', 'XPCServices/Installer.xpc', 'Autoupdate', 'Updater.app']: |
| @@ -102,9 +124,7 @@ class PublicationBoundaryTests(unittest.TestCase): |
| self.values = dict(bundle_id='net.amadan.oficina.meet', feed_url='https://raw.amadan.net/oficina/native-releases/main/meet/appcast.xml', public_ed_key=base64.b64encode(bytes(32)).decode(), keychain_account='test-only') |
| self.config.write_text(json.dumps(self.values)) |
| self.archive = self.release / 'meet-3.zip' |
| - info = dict(CFBundleIdentifier=self.values['bundle_id'], SUFeedURL=self.values['feed_url'], SUPublicEDKey=self.values['public_ed_key'], CFBundleVersion='3', CFBundleShortVersionString='1.0') |
| - with zipfile.ZipFile(self.archive, 'w') as zipped: |
| - zipped.writestr('Meet.app/Contents/Info.plist', plistlib.dumps(info)) |
| + self.write_archive(SUAllowsAutomaticUpdates=False) |
| self.manifest = dict(bundle_id=self.values['bundle_id'], feed_url=self.values['feed_url'], build='3', version='1.0', archive=self.archive.name, length=self.archive.stat().st_size, sha256=hashlib.sha256(self.archive.read_bytes()).hexdigest(), signature='test-signature') |
| self.write_manifest() |
| self.events = [] |
| @@ -116,6 +136,11 @@ class PublicationBoundaryTests(unittest.TestCase): |
| patch('publish_mac_update.subprocess.run', side_effect=self.tool).start() |
| patch('publish_mac_update.verify_download', side_effect=self.download).start() |
| + def write_archive(self, **install): |
| + info = dict(CFBundleIdentifier=self.values['bundle_id'], SUFeedURL=self.values['feed_url'], SUPublicEDKey=self.values['public_ed_key'], CFBundleVersion='3', CFBundleShortVersionString='1.0', **install) |
| + with zipfile.ZipFile(self.archive, 'w') as zipped: |
| + zipped.writestr('Meet.app/Contents/Info.plist', plistlib.dumps(info)) |
| + |
| def write_manifest(self): |
| (self.release / 'release.json').write_text(json.dumps(self.manifest)) |
| @@ -157,6 +182,20 @@ class PublicationBoundaryTests(unittest.TestCase): |
| self.assertEqual(self.events, []) |
| self.assertFalse((self.repo / 'meet/releases').exists()) |
| + def test_archive_built_for_another_install_mode_is_rejected(self): |
| + # A quiet app's feed must not ship a build that asks, nor the reverse. |
| + self.config.write_text(json.dumps(self.values | {'install': 'automatic'})) |
| + with self.assertRaisesRegex(ValueError, 'metadata does not match'): |
| + self.publish() |
| + self.assertEqual(self.events, []) |
| + |
| + def test_automatic_install_mode_archive_publishes(self): |
| + self.config.write_text(json.dumps(self.values | {'install': 'automatic'})) |
| + self.write_archive(SUAllowsAutomaticUpdates=True, SUEnableAutomaticChecks=True, SUAutomaticallyUpdate=True, SUScheduledCheckInterval=86400) |
| + self.manifest.update(length=self.archive.stat().st_size, sha256=hashlib.sha256(self.archive.read_bytes()).hexdigest()) |
| + self.write_manifest() |
| + self.assertEqual(self.publish(enabled=False)['build'], '3') |
| + |
| def test_changed_archive_is_rejected_before_publication(self): |
| with self.archive.open('ab') as archive: |
| archive.write(b'changed after signing') |