Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions Sources/AgentBar/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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()
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 14 additions & 2 deletions Sources/AgentBar/Localization/I18n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -305,6 +316,7 @@ enum I18n {
averageWeeklyTokenUsage: "周均 Token 数",
tokenUsageChartTitle: "近 30 天",
tokenUsageUnavailable: "Token 消耗不可用",
codexCLIUpgradeAlertTitle: "需要升级 Codex CLI"
codexCLIUpgradeAlertTitle: "需要升级 Codex CLI",
appUpdateReadyTitle: "AgentBar 更新已就绪"
)
}
172 changes: 172 additions & 0 deletions Sources/AgentBar/Services/AppUpdateNotificationService.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}