diff --git a/Sources/AgentBar/App/AppDelegate.swift b/Sources/AgentBar/App/AppDelegate.swift index 3c3dfc1..9750b22 100644 --- a/Sources/AgentBar/App/AppDelegate.swift +++ b/Sources/AgentBar/App/AppDelegate.swift @@ -4,10 +4,11 @@ import Sparkle import SwiftUI @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, SPUUpdaterDelegate { private static let statusItemWidth: CGFloat = 76 private let store = QuotaStore() + private let updateNotificationService = AppUpdateNotificationService() private var statusItem: NSStatusItem? private var menuBarView: MenuBarQuotaView? private var popover: NSPopover? @@ -20,6 +21,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) NSApp.applicationIconImage = AppIcon.image() + removeInstalledUpdateNotification() configureUpdater() configureStatusItem() configurePopover() @@ -28,6 +30,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { store.start() } + private func removeInstalledUpdateNotification() { + guard let buildVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String else { + return + } + + updateNotificationService.removeNotificationIfInstalled(buildVersion: buildVersion) + } + func applicationWillTerminate(_ notification: Notification) { store.stop() } @@ -61,13 +71,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let updaterController = SPUStandardUpdaterController( startingUpdater: true, - updaterDelegate: nil, + updaterDelegate: self, userDriverDelegate: nil ) self.updaterController = updaterController updaterController.updater.checkForUpdatesInBackground() } + func updater( + _ updater: SPUUpdater, + willInstallUpdateOnQuit item: SUAppcastItem, + immediateInstallationBlock _: @escaping () -> Void + ) -> Bool { + updateNotificationService.notifyUpdateReady( + displayVersion: item.displayVersionString, + buildVersion: item.versionString + ) + return false + } + private func configurePopover() { let popover = NSPopover() popover.behavior = .transient @@ -92,6 +114,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } private func bindStore() { + NotificationCenter.default.publisher(for: AppUpdateNotificationService.notificationActivated) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + NSApp.activate(ignoringOtherApps: true) + self?.updaterController?.checkForUpdates(nil) + } + .store(in: &cancellables) + store.$snapshot .receive(on: RunLoop.main) .sink { [weak self] _ in diff --git a/Sources/AgentBar/Localization/I18n.swift b/Sources/AgentBar/Localization/I18n.swift index a145922..2088fdf 100644 --- a/Sources/AgentBar/Localization/I18n.swift +++ b/Sources/AgentBar/Localization/I18n.swift @@ -48,6 +48,7 @@ enum I18n { let tokenUsageChartTitle: String let tokenUsageUnavailable: String let codexCLIUpgradeAlertTitle: String + let appUpdateReadyTitle: String func signedInAs(_ planType: String) -> String { switch I18n.language { @@ -112,6 +113,15 @@ enum I18n { } } + func appUpdateReadyMessage(version: String) -> String { + switch I18n.language { + case .simplifiedChinese: + return "AgentBar \(version) 已下载完成,将在退出应用后自动安装。" + case .english: + return "AgentBar \(version) has been downloaded and will be installed automatically when the app quits." + } + } + func parsingFailed(_ message: String) -> String { switch I18n.language { case .simplifiedChinese: @@ -261,7 +271,8 @@ enum I18n { averageWeeklyTokenUsage: "Weekly avg tokens", tokenUsageChartTitle: "Past 30 days", tokenUsageUnavailable: "Token usage unavailable", - codexCLIUpgradeAlertTitle: "Codex CLI update required" + codexCLIUpgradeAlertTitle: "Codex CLI update required", + appUpdateReadyTitle: "AgentBar update ready" ) private static let simplifiedChinese = Strings( @@ -305,6 +316,7 @@ enum I18n { averageWeeklyTokenUsage: "周均 Token 数", tokenUsageChartTitle: "近 30 天", tokenUsageUnavailable: "Token 消耗不可用", - codexCLIUpgradeAlertTitle: "需要升级 Codex CLI" + codexCLIUpgradeAlertTitle: "需要升级 Codex CLI", + appUpdateReadyTitle: "AgentBar 更新已就绪" ) } diff --git a/Sources/AgentBar/Services/AppUpdateNotificationService.swift b/Sources/AgentBar/Services/AppUpdateNotificationService.swift new file mode 100644 index 0000000..d50ec7b --- /dev/null +++ b/Sources/AgentBar/Services/AppUpdateNotificationService.swift @@ -0,0 +1,172 @@ +import Foundation +import UserNotifications + +final class AppUpdateNotificationService: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable { + static let notificationActivated = Notification.Name("AppUpdateNotificationActivated") + + private static let notificationIdentifier = "com.lonewolfyx.AgentBar.update-ready" + private static let notificationThreadIdentifier = "com.lonewolfyx.AgentBar.app-update" + private static let lastNotifiedBuildVersionKey = "AppUpdateLastNotifiedBuildVersion" + + private let notificationCenter: UNUserNotificationCenter + private let userDefaults: UserDefaults + private let stateLock = NSLock() + private var inFlightBuildVersion: String? + + override init() { + let notificationCenter = UNUserNotificationCenter.current() + self.notificationCenter = notificationCenter + userDefaults = .standard + super.init() + notificationCenter.delegate = self + } + + func removeNotificationIfInstalled(buildVersion: String) { + guard lastNotifiedBuildVersion() == buildVersion else { + return + } + + notificationCenter.removeDeliveredNotifications(withIdentifiers: [Self.notificationIdentifier]) + notificationCenter.removePendingNotificationRequests(withIdentifiers: [Self.notificationIdentifier]) + } + + func notifyUpdateReady(displayVersion: String, buildVersion: String) { + guard beginNotificationAttempt(buildVersion: buildVersion) else { + return + } + + notificationCenter.getNotificationSettings { [weak self] settings in + guard let self else { + return + } + + switch settings.authorizationStatus { + case .authorized, .provisional: + self.deliverUpdateReadyNotification( + displayVersion: displayVersion, + buildVersion: buildVersion + ) + case .notDetermined: + self.requestAuthorizationAndNotify( + displayVersion: displayVersion, + buildVersion: buildVersion + ) + case .denied: + self.finishNotificationAttempt(buildVersion: buildVersion, succeeded: false) + @unknown default: + self.finishNotificationAttempt(buildVersion: buildVersion, succeeded: false) + } + } + } + + private func requestAuthorizationAndNotify(displayVersion: String, buildVersion: String) { + notificationCenter.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in + guard let self else { + return + } + + guard granted else { + self.finishNotificationAttempt(buildVersion: buildVersion, succeeded: false) + return + } + + self.deliverUpdateReadyNotification( + displayVersion: displayVersion, + buildVersion: buildVersion + ) + } + } + + private func deliverUpdateReadyNotification(displayVersion: String, buildVersion: String) { + let content = UNMutableNotificationContent() + content.title = I18n.current.appUpdateReadyTitle + content.body = I18n.current.appUpdateReadyMessage(version: displayVersion) + content.sound = .default + content.threadIdentifier = Self.notificationThreadIdentifier + + let request = UNNotificationRequest( + identifier: Self.notificationIdentifier, + content: content, + trigger: nil + ) + notificationCenter.add(request) { [weak self] error in + self?.finishNotificationAttempt( + buildVersion: buildVersion, + succeeded: error == nil + ) + } + } + + private func beginNotificationAttempt(buildVersion: String) -> Bool { + stateLock.lock() + defer { + stateLock.unlock() + } + + guard + userDefaults.string(forKey: Self.lastNotifiedBuildVersionKey) != buildVersion, + inFlightBuildVersion == nil + else { + return false + } + + inFlightBuildVersion = buildVersion + return true + } + + private func finishNotificationAttempt(buildVersion: String, succeeded: Bool) { + stateLock.lock() + defer { + stateLock.unlock() + } + + if succeeded { + userDefaults.set(buildVersion, forKey: Self.lastNotifiedBuildVersionKey) + } + + if inFlightBuildVersion == buildVersion { + inFlightBuildVersion = nil + } + } + + private func lastNotifiedBuildVersion() -> String? { + stateLock.lock() + defer { + stateLock.unlock() + } + + return userDefaults.string(forKey: Self.lastNotifiedBuildVersionKey) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping @Sendable (UNNotificationPresentationOptions) -> Void + ) { + guard notification.request.identifier == Self.notificationIdentifier else { + completionHandler([]) + return + } + + completionHandler([.banner, .list, .sound]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping @Sendable () -> Void + ) { + defer { + completionHandler() + } + + guard + response.actionIdentifier == UNNotificationDefaultActionIdentifier, + response.notification.request.identifier == Self.notificationIdentifier + else { + return + } + + NotificationCenter.default.post(name: Self.notificationActivated, object: nil) + } +}