From 54d2ccda99d75ce294b788012245211de93f409b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 23 Dec 2025 01:42:33 +0100 Subject: [PATCH] feat(mac): surface update-ready state --- .../macos/Sources/Clawdis/AboutSettings.swift | 2 + apps/macos/Sources/Clawdis/MenuBar.swift | 83 ++++++++++++++++--- .../Sources/Clawdis/MenuContentView.swift | 11 ++- 3 files changed, 83 insertions(+), 13 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AboutSettings.swift b/apps/macos/Sources/Clawdis/AboutSettings.swift index 66790f1be..c841910f8 100644 --- a/apps/macos/Sources/Clawdis/AboutSettings.swift +++ b/apps/macos/Sources/Clawdis/AboutSettings.swift @@ -92,10 +92,12 @@ struct AboutSettings: View { guard let updater, !self.didLoadUpdaterState else { return } // Keep Sparkle’s auto-check setting in sync with the persisted toggle. updater.automaticallyChecksForUpdates = self.autoCheckEnabled + updater.automaticallyDownloadsUpdates = self.autoCheckEnabled self.didLoadUpdaterState = true } .onChange(of: self.autoCheckEnabled) { _, newValue in self.updater?.automaticallyChecksForUpdates = newValue + self.updater?.automaticallyDownloadsUpdates = newValue } } diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index cd3347d23..7ca6b3901 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -2,6 +2,7 @@ import AppKit import Darwin import Foundation import MenuBarExtraAccess +import Observation import OSLog import Security import SwiftUI @@ -306,27 +307,94 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor protocol UpdaterProviding: AnyObject { var automaticallyChecksForUpdates: Bool { get set } + var automaticallyDownloadsUpdates: Bool { get set } var isAvailable: Bool { get } + var updateStatus: UpdateStatus { get } func checkForUpdates(_ sender: Any?) } // No-op updater used for debug/dev runs to suppress Sparkle dialogs. final class DisabledUpdaterController: UpdaterProviding { var automaticallyChecksForUpdates: Bool = false + var automaticallyDownloadsUpdates: Bool = false let isAvailable: Bool = false + let updateStatus = UpdateStatus() func checkForUpdates(_: Any?) {} } +@MainActor +@Observable +final class UpdateStatus { + static let disabled = UpdateStatus() + var isUpdateReady: Bool + + init(isUpdateReady: Bool = false) { + self.isUpdateReady = isUpdateReady + } +} + #if canImport(Sparkle) import Sparkle -extension SPUStandardUpdaterController: UpdaterProviding { +@MainActor +final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate { + private lazy var controller = SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: self, + userDriverDelegate: nil) + let updateStatus = UpdateStatus() + + init(savedAutoUpdate: Bool) { + super.init() + let updater = self.controller.updater + updater.automaticallyChecksForUpdates = savedAutoUpdate + updater.automaticallyDownloadsUpdates = savedAutoUpdate + self.controller.startUpdater() + } + var automaticallyChecksForUpdates: Bool { - get { self.updater.automaticallyChecksForUpdates } - set { self.updater.automaticallyChecksForUpdates = newValue } + get { self.controller.updater.automaticallyChecksForUpdates } + set { self.controller.updater.automaticallyChecksForUpdates = newValue } + } + + var automaticallyDownloadsUpdates: Bool { + get { self.controller.updater.automaticallyDownloadsUpdates } + set { self.controller.updater.automaticallyDownloadsUpdates = newValue } } var isAvailable: Bool { true } + + func checkForUpdates(_ sender: Any?) { + self.controller.checkForUpdates(sender) + } + + func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { + self.updateStatus.isUpdateReady = true + } + + func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { + self.updateStatus.isUpdateReady = false + } + + func userDidCancelDownload(_ updater: SPUUpdater) { + self.updateStatus.isUpdateReady = false + } + + func updater( + _ updater: SPUUpdater, + userDidMakeChoice choice: SPUUserUpdateChoice, + forUpdate updateItem: SUAppcastItem, + state: SPUUserUpdateState + ) { + switch choice { + case .install, .skip: + self.updateStatus.isUpdateReady = false + case .dismiss: + self.updateStatus.isUpdateReady = (state.stage == .downloaded) + @unknown default: + self.updateStatus.isUpdateReady = false + } + } } private func isDeveloperIDSigned(bundleURL: URL) -> Bool { @@ -359,14 +427,7 @@ private func makeUpdaterController() -> UpdaterProviding { let autoUpdateKey = "autoUpdateEnabled" // Default to true; honor the user's last choice otherwise. let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true - - let controller = SPUStandardUpdaterController( - startingUpdater: false, - updaterDelegate: nil, - userDriverDelegate: nil) - controller.updater.automaticallyChecksForUpdates = savedAutoUpdate - controller.startUpdater() - return controller + return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate) } #else private func makeUpdaterController() -> UpdaterProviding { diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index fd1ff613e..4b2aeb9e2 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -8,6 +8,7 @@ import SwiftUI struct MenuContent: View { @Bindable var state: AppState let updater: UpdaterProviding? + @Bindable private var updateStatus: UpdateStatus private let gatewayManager = GatewayProcessManager.shared private let healthStore = HealthStore.shared private let heartbeatStore = HeartbeatStore.shared @@ -18,6 +19,12 @@ struct MenuContent: View { @State private var loadingMics = false @State private var browserControlEnabled = true + init(state: AppState, updater: UpdaterProviding?) { + self._state = Bindable(wrappedValue: state) + self.updater = updater + self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled) + } + var body: some View { VStack(alignment: .leading, spacing: 8) { Toggle(isOn: self.activeBinding) { @@ -86,8 +93,8 @@ struct MenuContent: View { .keyboardShortcut(",", modifiers: [.command]) self.debugMenu Button("About Clawdis") { self.open(tab: .about) } - if let updater, updater.isAvailable { - Button("Check for Updates…") { updater.checkForUpdates(nil) } + if let updater, updater.isAvailable, self.updateStatus.isUpdateReady { + Button("Update ready, restart now?") { updater.checkForUpdates(nil) } } Button("Quit") { NSApplication.shared.terminate(nil) } }