rastrillo / native Public

Add opt-in quiet Mac updates: no prompts, install on quit, busy-aware

NativeUpdater(policy: .quiet) never opens a window for a scheduled check.
Sparkle's automatic driver stages the update; the updater holds Sparkle's
immediate-install block and publishes pendingUpdate (.ready or .available)
for an "Update Ready — Restart <App>" menu item and NativeUpdateNotice, a
one-line home-screen notice. isBusy declines scheduled checks (so nothing
downloads mid-call) and replays one when it clears, and disables the
restart. Manual Check for Updates keeps Sparkle's standard UI.

Packaging gains "install": "automatic" in mac-updates.json, which writes
SUAllowsAutomaticUpdates, SUEnableAutomaticChecks, SUAutomaticallyUpdate
and a daily interval as Info.plist defaults: no first-launch question,
and a person's stored choice still wins. Release and publication reject
an archive whose install mode disagrees with its configuration. The
default stays "prompt"; apps that do not opt in are unchanged.

A test pins the delegate's Objective-C selectors, since a drifted Swift
signature compiles silently and restores Sparkle's alerts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 4d969d16002fdbeace5be1624067bb3956941edb parent 14ee567
11 files changed, +533 −17
  • Sources/RastrilloMacUpdates/NativeUpdateCommands.swift +14 −1
  • Sources/RastrilloMacUpdates/NativeUpdateNotice.swift +42 −0
  • Sources/RastrilloMacUpdates/NativeUpdatePolicy.swift +79 −0
  • Sources/RastrilloMacUpdates/NativeUpdater.swift +161 −2
  • Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings +5 −0
  • Tests/RastrilloMacUpdatesTests/QuietUpdateTests.swift +123 −0
  • docs/mac-updates.md +36 −3
  • tools/mac_updates.py +26 −4
  • tools/publish_mac_update.py +2 −2
  • tools/release_mac.py +3 −2
  • tools/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')