From 25723b7f07c5bc219c3dfd97c8b839c1b88fc1bc Mon Sep 17 00:00:00 2001 From: Micha Date: Tue, 30 Dec 2025 13:12:27 +0100 Subject: [PATCH] feat: add in-app Sparkle update logging - Add published logMessages array to SparkleUpdater to track all update events - Display logs in the Updates preferences tab with Show/Hide toggle - Each log entry is timestamped and shows both info and error messages - Logs persist during session with max 100 entries - Users can clear logs manually - Helps diagnose update failures directly in the app UI --- Sources/ViewModels/SparkleUpdater.swift | 68 ++++++++++++++++++------- Sources/Views/PreferencesView.swift | 53 +++++++++++++++++++ 2 files changed, 102 insertions(+), 19 deletions(-) diff --git a/Sources/ViewModels/SparkleUpdater.swift b/Sources/ViewModels/SparkleUpdater.swift index 35a41ab..fa49a17 100644 --- a/Sources/ViewModels/SparkleUpdater.swift +++ b/Sources/ViewModels/SparkleUpdater.swift @@ -9,6 +9,8 @@ final class SparkleUpdater: NSObject, ObservableObject { }() private let logger = Logger(subsystem: "net.24unix.iKeyMon", category: "Sparkle") private let verboseLogging: Bool + @Published var logMessages: [String] = [] + @Published var showLogs: Bool = false override init() { self.verboseLogging = ProcessInfo.processInfo.environment["SPARKLE_VERBOSE_LOGGING"] == "1" @@ -34,6 +36,7 @@ final class SparkleUpdater: NSObject, ObservableObject { private func log(_ message: String) { logger.log("\(message, privacy: .public)") + addLogMessage("[INFO] \(message)") if verboseLogging { print("[Sparkle] \(message)") } @@ -41,11 +44,21 @@ final class SparkleUpdater: NSObject, ObservableObject { 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 @@ -53,41 +66,58 @@ final class SparkleUpdater: NSObject, ObservableObject { } } -@MainActor extension SparkleUpdater: SPUUpdaterDelegate { - func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { - log("Loaded Sparkle appcast containing \(appcast.items.count) item(s).") + nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { + Task { @MainActor in + log("Loaded Sparkle appcast containing \(appcast.items.count) item(s).") + } } - func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { - log("Found valid update \(describe(update: item))") + nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + Task { @MainActor in + log("Found valid update \(describe(update: item))") + } } - func updaterDidNotFindUpdate(_ updater: SPUUpdater) { - log("No updates available.") + nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater) { + Task { @MainActor in + log("No updates available.") + } } - func updater(_ updater: SPUUpdater, willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest) { - log("Downloading \(describe(update: item)) from \(request.url?.absoluteString ?? "unknown URL")") + 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")") + } } - func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { - log("Finished downloading \(describe(update: item))") + nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { + Task { @MainActor in + log("Finished downloading \(describe(update: item))") + } } - func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { - logError("Failed to download \(describe(update: item)): \(error.localizedDescription)") + nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { + Task { @MainActor in + logError("Failed to download \(describe(update: item)): \(error.localizedDescription)") + } } - func userDidCancelDownload(_ updater: SPUUpdater) { - log("User cancelled Sparkle download.") + nonisolated func userDidCancelDownload(_ updater: SPUUpdater) { + Task { @MainActor in + log("User cancelled Sparkle download.") + } } - func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { - log("Will install update \(describe(update: item))") + nonisolated func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { + Task { @MainActor in + log("Will install update \(describe(update: item))") + } } - func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { - logError("Sparkle aborted: \(error.localizedDescription)") + nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + Task { @MainActor in + logError("Sparkle aborted: \(error.localizedDescription)") + } } } diff --git a/Sources/Views/PreferencesView.swift b/Sources/Views/PreferencesView.swift index a434be8..bb5fc3d 100644 --- a/Sources/Views/PreferencesView.swift +++ b/Sources/Views/PreferencesView.swift @@ -257,11 +257,64 @@ private struct UpdatesPreferencesView: View { .foregroundColor(.secondary) .padding(.top, 4) + Divider() + .padding(.vertical, 8) + + Button(action: { sparkleUpdater.showLogs.toggle() }) { + Label(sparkleUpdater.showLogs ? "Hide Logs" : "Show Logs", systemImage: "terminal.fill") + } + + if sparkleUpdater.showLogs { + logsView + } + Spacer() } .toggleStyle(.switch) .frame(maxWidth: .infinity, alignment: .leading) } + + private var logsView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Update Logs") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + + ScrollView { + VStack(alignment: .leading, spacing: 4) { + if sparkleUpdater.logMessages.isEmpty { + Text("No logs yet. Check for updates to see activity.") + .font(.caption) + .foregroundColor(.secondary) + .italic() + } else { + ForEach(sparkleUpdater.logMessages, id: \.self) { message in + Text(message) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(3) + .textSelection(.enabled) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .frame(height: 200) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .border(.separator, width: 1) + + Button(action: { + sparkleUpdater.logMessages.removeAll() + }) { + Label("Clear Logs", systemImage: "trash") + .font(.caption) + } + .disabled(sparkleUpdater.logMessages.isEmpty) + } + } } private struct NotificationsPreferencesView: View {