import Sparkle import Foundation import OSLog @MainActor final class SparkleUpdater: NSObject, ObservableObject { private lazy var controller: SPUStandardUpdaterController = { SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil) }() private let logger = Logger(subsystem: "net.24unix.iKeyMon", category: "Sparkle") private let verboseLogging: Bool @Published var logMessages: [String] = [] override init() { self.verboseLogging = ProcessInfo.processInfo.environment["SPARKLE_VERBOSE_LOGGING"] == "1" super.init() _ = controller log("Sparkle updater initialized (verbose=\(verboseLogging)).") } var automaticallyChecksForUpdates: Bool { get { controller.updater.automaticallyChecksForUpdates } set { controller.updater.automaticallyChecksForUpdates = newValue } } var automaticallyDownloadsUpdates: Bool { get { controller.updater.automaticallyDownloadsUpdates } set { controller.updater.automaticallyDownloadsUpdates = newValue } } func checkForUpdates() { log("Manual check for updates triggered.") controller.checkForUpdates(nil) } private func log(_ message: String) { logger.log("\(message, privacy: .public)") addLogMessage("[INFO] \(message)") if verboseLogging { print("[Sparkle] \(message)") } } private func logError(_ message: String) { logger.error("\(message, privacy: .public)") addLogMessage("[ERROR] \(message)") if verboseLogging { fputs("[Sparkle][error] \(message)\n", stderr) } } private func addLogMessage(_ message: String) { let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) let timestampedMessage = "[\(timestamp)] \(message)" logMessages.append(timestampedMessage) if logMessages.count > 100 { logMessages.removeFirst() } } private func describe(update item: SUAppcastItem) -> String { let short = item.displayVersionString let build = item.versionString return "\(short) (build \(build))" } } extension SparkleUpdater: SPUUpdaterDelegate { nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { Task { @MainActor in log("Loaded Sparkle appcast containing \(appcast.items.count) item(s).") } } nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { Task { @MainActor in log("Found valid update \(describe(update: item))") } } nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater) { Task { @MainActor in log("No updates available.") } } nonisolated func updater(_ updater: SPUUpdater, willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest) { Task { @MainActor in log("Downloading \(describe(update: item)) from \(request.url?.absoluteString ?? "unknown URL")") } } nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { Task { @MainActor in log("Finished downloading \(describe(update: item))") } } nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { Task { @MainActor in logError("Failed to download \(describe(update: item)): \(error.localizedDescription)") } } nonisolated func userDidCancelDownload(_ updater: SPUUpdater) { Task { @MainActor in log("User cancelled Sparkle download.") } } nonisolated func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { Task { @MainActor in log("Will install update \(describe(update: item))") } } nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { Task { @MainActor in let errorDescription = error as NSError let details = "Domain: \(errorDescription.domain), Code: \(errorDescription.code), Description: \(error.localizedDescription)" logError("Sparkle aborted: \(details)") if let underlying = errorDescription.userInfo[NSUnderlyingErrorKey] as? NSError { logError("Underlying error: Domain: \(underlying.domain), Code: \(underlying.code), Description: \(underlying.localizedDescription)") } } } }