#if os(macOS) 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() /// 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(policy: NativeUpdatePolicy = .standard) { self.policy = policy guard Self.validConfiguration(Bundle.main.infoDictionary ?? [:]) else { return } 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 } .store(in: &observations) controller.updater.publisher(for: \.automaticallyChecksForUpdates) .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 { get { automaticChecks } set { guard let controller, newValue != controller.updater.automaticallyChecksForUpdates else { return } controller.updater.automaticallyChecksForUpdates = newValue } } /// 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", let host = url.host, !host.isEmpty, url.user == nil, url.password == nil, url.fragment == nil, let publicKey = info["SUPublicEDKey"] as? String, Data(base64Encoded: publicKey)?.count == 32 else { return false } 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