| 1 | #if os(macOS) |
| 2 | import Combine |
| 3 | import Foundation |
| 4 | import Observation |
| 5 | import OSLog |
| 6 | import Sparkle |
| 7 | |
| 8 | /// Why an update did or did not arrive, in Console under the app's bundle ID. |
| 9 | private 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. |
| 155 | final 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 | |