rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1#if os(macOS)
2import Combine
3import Foundation
4import Observation
5import OSLog
6import Sparkle
7
8/// Why an update did or did not arrive, in Console under the app's bundle ID.
9private let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "RastrilloMacUpdates", category: "updates")
10
11/// One app-owned updater. Unconfigured development bundles never contact a feed.
12@MainActor @Observable public final class NativeUpdater {
13 public let policy: NativeUpdatePolicy
14 public private(set) var isConfigured = false
15 public private(set) var canCheckForUpdates = false
16 private var automaticChecks = false
17 private var automaticDownloads = false
18 private var rules = QuietUpdateRules()
19 @ObservationIgnored private var controller: SPUStandardUpdaterController?
20 @ObservationIgnored private var quietDelegate: QuietUpdateDelegate?
21 @ObservationIgnored private var observations = Set<AnyCancellable>()
22 /// Sparkle's "install now" for a staged update, held while the update waits for quit.
23 @ObservationIgnored private var installNow: (() -> Void)?
24 /// Starts the check that waited for the app to stop being busy. A seam for tests.
25 @ObservationIgnored var runDeferredCheck: () -> Void = {}
26
27 public init(policy: NativeUpdatePolicy = .standard) {
28 self.policy = policy
29 guard Self.validConfiguration(Bundle.main.infoDictionary ?? [:]) else { return }
30 let delegate = policy == .quiet ? QuietUpdateDelegate() : nil
31 let controller = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: delegate, userDriverDelegate: delegate)
32 quietDelegate = delegate
33 delegate?.owner = self
34 self.controller = controller
35 isConfigured = true
36 runDeferredCheck = { [weak controller] in
37 // A person may have started a check since; Sparkle refuses a second session.
38 guard let updater = controller?.updater, !updater.sessionInProgress, updater.automaticallyChecksForUpdates else { return }
39 updater.checkForUpdatesInBackground()
40 }
41 controller.updater.publisher(for: \.canCheckForUpdates)
42 .receive(on: DispatchQueue.main)
43 .sink { [weak self] in self?.canCheckForUpdates = $0 }
44 .store(in: &observations)
45 controller.updater.publisher(for: \.automaticallyChecksForUpdates)
46 .receive(on: DispatchQueue.main)
47 .sink { [weak self] in self?.automaticChecks = $0 }
48 .store(in: &observations)
49 controller.updater.publisher(for: \.automaticallyDownloadsUpdates)
50 .receive(on: DispatchQueue.main)
51 .sink { [weak self] in self?.automaticDownloads = $0 }
52 .store(in: &observations)
53 // Started after the delegate is attached, so the first scheduled check is already quiet.
54 controller.startUpdater()
55 }
56
57 public var automaticallyChecksForUpdates: Bool {
58 get { automaticChecks }
59 set {
60 guard let controller, newValue != controller.updater.automaticallyChecksForUpdates else { return }
61 controller.updater.automaticallyChecksForUpdates = newValue
62 }
63 }
64
65 /// Checks, downloads and installs on quit without asking: the one switch a
66 /// person needs. Turning it off keeps scheduled checks, so a quiet app still
67 /// mentions a new version, but nothing downloads until they choose it. It stays
68 /// off where the bundle was not packaged to allow automatic updates.
69 public var automaticallyInstallsUpdates: Bool {
70 get { automaticChecks && automaticDownloads }
71 set {
72 guard let updater = controller?.updater, updater.allowsAutomaticUpdates else { return }
73 if newValue { updater.automaticallyChecksForUpdates = true }
74 updater.automaticallyDownloadsUpdates = newValue
75 }
76 }
77
78 /// Whether automatic installation can be offered at all.
79 public var canInstallUpdatesAutomatically: Bool { controller?.updater.allowsAutomaticUpdates ?? false }
80
81 /// The app is doing something an update must not interrupt. Quiet updaters hold
82 /// scheduled checks and pending-update actions until it clears.
83 public var isBusy: Bool {
84 get { rules.busy }
85 set {
86 guard newValue != rules.busy else { return }
87 if rules.setBusy(newValue) {
88 log.info("No longer busy: running the deferred update check")
89 runDeferredCheck()
90 }
91 }
92 }
93
94 /// What a quiet updater has found and not yet installed. Always `.none` for
95 /// `standard`, which shows Sparkle's alert instead.
96 public var pendingUpdate: NativePendingUpdate { rules.pending }
97
98 /// Whether `installPendingUpdate()` would do anything now.
99 public var canInstallPendingUpdate: Bool { rules.canInstall }
100
101 /// Relaunches into a staged update, or shows Sparkle's window for one that still
102 /// needs the person. Does nothing while busy.
103 public func installPendingUpdate() {
104 guard rules.canInstall else { return }
105 if case .ready = rules.pending, let installNow {
106 installNow()
107 } else {
108 // Sparkle already holds this update's session: checking brings it into focus.
109 controller?.checkForUpdates(nil)
110 }
111 }
112
113 public func checkForUpdates() {
114 guard canCheckForUpdates else { return }
115 controller?.checkForUpdates(nil)
116 }
117
118 // MARK: Quiet delegate events
119
120 func mayPerform(_ check: NativeUpdateCheck) -> Bool {
121 guard policy == .quiet, !rules.mayPerform(check) else { return true }
122 log.info("Busy: deferring the scheduled update check")
123 return false
124 }
125
126 func staged(version: String, installNow: @escaping () -> Void) {
127 self.installNow = installNow
128 rules.staged(version: version)
129 log.info("Update \(version, privacy: .public) is staged and installs on quit")
130 }
131
132 func found(version: String, downloaded: Bool) {
133 rules.found(version: version, downloaded: downloaded)
134 log.info("Update \(version, privacy: .public) found (downloaded: \(downloaded)); shown as a notice")
135 }
136
137 /// Sparkle's window has the update, or the session ended; the notice goes.
138 func sessionEnded() {
139 installNow = nil
140 rules.clear()
141 }
142
143 static func validConfiguration(_ info: [String: Any]) -> Bool {
144 guard let feed = info["SUFeedURL"] as? String,
145 let url = URLComponents(string: feed), url.scheme == "https",
146 let host = url.host, !host.isEmpty,
147 url.user == nil, url.password == nil, url.fragment == nil,
148 let publicKey = info["SUPublicEDKey"] as? String,
149 Data(base64Encoded: publicKey)?.count == 32 else { return false }
150 return true
151 }
152}
153
154/// Sparkle's two delegates for the quiet policy. Sparkle calls both on the main thread.
155final class QuietUpdateDelegate: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate {
156 @MainActor weak var owner: NativeUpdater?
157
158 static let deferredError = NSError(domain: "RastrilloMacUpdates", code: 1, userInfo: [NSLocalizedDescriptionKey: "Update check deferred while the app is busy."])
159
160 func updater(_ updater: SPUUpdater, mayPerform updateCheck: SPUUpdateCheck) throws {
161 let check: NativeUpdateCheck = switch updateCheck {
162 case .updates: .userInitiated
163 case .updatesInBackground: .background
164 default: .information
165 }
166 let allowed = MainActor.assumeIsolated { owner?.mayPerform(check) ?? true }
167 // Declining a background check is silent: Sparkle logs it and schedules the next.
168 if !allowed { throw Self.deferredError }
169 }
170
171 /// Holding the install block keeps the staged update, which still installs on
172 /// quit, and lets the app offer "Restart" instead of Sparkle's window.
173 func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock: @escaping () -> Void) -> Bool {
174 MainActor.assumeIsolated {
175 guard let owner else { return false }
176 owner.staged(version: item.displayVersionString, installNow: immediateInstallationBlock)
177 return true
178 }
179 }
180
181 func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: (any Error)?) {
182 // A held staged update only finishes its cycle by installing or failing; a
183 // failed one must not keep offering a restart that does nothing.
184 guard let error = error as NSError?,
185 error.domain != Self.deferredError.domain || error.code != Self.deferredError.code else { return }
186 MainActor.assumeIsolated { owner?.sessionEnded() }
187 }
188
189 var supportsGentleScheduledUpdateReminders: Bool { true }
190
191 /// Never a window for a scheduled update: the app shows a notice instead.
192 func standardUserDriverShouldHandleShowingScheduledUpdate(_ update: SUAppcastItem, andInImmediateFocus immediateFocus: Bool) -> Bool {
193 false
194 }
195
196 func standardUserDriverWillHandleShowingUpdate(_ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, state: SPUUserUpdateState) {
197 // User-initiated checks are shown by Sparkle and need no notice.
198 guard !handleShowingUpdate else { return }
199 MainActor.assumeIsolated {
200 owner?.found(version: update.displayVersionString, downloaded: state.stage != .notDownloaded)
201 }
202 }
203
204 func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) {
205 MainActor.assumeIsolated { owner?.sessionEnded() }
206 }
207
208 func standardUserDriverWillFinishUpdateSession() {
209 MainActor.assumeIsolated { owner?.sessionEnded() }
210 }
211}
212#endif
213