项目文件夹

文件
2026-07-13 12:22:33 +08:00

597 行
23 KiB
Swift

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import AppKit
import CodexBarCore
import KeyboardShortcuts
import Observation
import QuartzCore
import Security
import SwiftUI
@main
struct CodexBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var settings: SettingsStore
@State private var store: UsageStore
@State private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator
@State private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator
private let preferencesSelection: PreferencesSelection
private let account: AccountInfo
init() {
let env = ProcessInfo.processInfo.environment
let storedLevel = CodexBarLog.parseLevel(UserDefaults.standard.string(forKey: "debugLogLevel")) ?? .verbose
let level = CodexBarLog.parseLevel(env["CODEXBAR_LOG_LEVEL"]) ?? storedLevel
CodexBarLog.bootstrapIfNeeded(.init(
destination: .oslog(subsystem: "com.steipete.codexbar"),
level: level,
json: false))
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
let gitCommit = Bundle.main.object(forInfoDictionaryKey: "CodexGitCommit") as? String ?? "unknown"
let buildTimestamp = Bundle.main.object(forInfoDictionaryKey: "CodexBuildTimestamp") as? String ?? "unknown"
CodexBarLog.logger(LogCategories.app).info(
"CodexBar starting",
metadata: [
"version": version,
"build": build,
"git": gitCommit,
"built": buildTimestamp,
])
KeychainAccessGate.isDisabled = UserDefaults.standard.bool(forKey: "debugDisableKeychainAccess")
KeychainPromptCoordinator.install()
if MainThreadHangWatchdog.isEnabledForCurrentProcess {
MainThreadHangWatchdog.shared.start()
}
let preferencesSelection = PreferencesSelection()
let settings = SettingsStore()
Self.applyLanguagePreference(from: settings)
configureUsageFormatterLocalizationProvider()
let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator()
managedCodexAccountCoordinator.onManagedAccountsDidChange = {
_ = settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange()
}
_ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()
let fetcher = UsageFetcher()
let browserDetection = BrowserDetection(cacheTTL: BrowserDetection.defaultCacheTTL)
let account = fetcher.loadAccountInfo()
let store = UsageStore(fetcher: fetcher, browserDetection: browserDetection, settings: settings)
let codexAccountPromotionCoordinator = CodexAccountPromotionCoordinator(
settingsStore: settings,
usageStore: store,
managedAccountCoordinator: managedCodexAccountCoordinator)
self.preferencesSelection = preferencesSelection
_settings = State(wrappedValue: settings)
_store = State(wrappedValue: store)
_managedCodexAccountCoordinator = State(wrappedValue: managedCodexAccountCoordinator)
_codexAccountPromotionCoordinator = State(wrappedValue: codexAccountPromotionCoordinator)
self.account = account
CodexBarLog.setLogLevel(settings.debugLogLevel)
self.appDelegate.configure(.init(
store: store,
settings: settings,
account: account,
selection: preferencesSelection,
managedCodexAccountCoordinator: managedCodexAccountCoordinator,
codexAccountPromotionCoordinator: codexAccountPromotionCoordinator))
}
@SceneBuilder
var body: some Scene {
// Hidden 1×1 window to keep SwiftUI's lifecycle alive so `Settings` scene
// shows the native toolbar tabs even though the UI is AppKit-based.
WindowGroup("CodexBarLifecycleKeepalive") {
HiddenWindowView()
}
.defaultSize(width: 20, height: 20)
.windowStyle(.hiddenTitleBar)
Settings {
PreferencesView(
settings: self.settings,
store: self.store,
updater: self.appDelegate.updaterController,
selection: self.preferencesSelection,
managedCodexAccountCoordinator: self.managedCodexAccountCoordinator,
codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator,
runProviderLoginFlow: { provider in
await self.appDelegate.runProviderLoginFlow(provider)
})
}
.defaultSize(width: SettingsPane.windowWidth, height: SettingsPane.windowHeight)
.windowResizability(.contentMinSize)
}
private func openSettings(pane: SettingsPane) {
self.preferencesSelection.pane = pane
NSApp.activate(ignoringOtherApps: true)
_ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
}
private static func applyLanguagePreference(from settings: SettingsStore) {
AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned(storedAppLanguage: settings.appLanguage)
resetCodexBarLocalizationCache()
}
}
// MARK: - Updater abstraction
@MainActor
protocol UpdaterProviding: AnyObject {
var automaticallyChecksForUpdates: Bool { get set }
var automaticallyDownloadsUpdates: Bool { get set }
var isAvailable: Bool { get }
var unavailableReason: String? { get }
var updateStatus: UpdateStatus { get }
func checkForUpdates(_ sender: Any?)
func installUpdate()
}
/// No-op updater used for debug builds and non-bundled runs to suppress Sparkle dialogs.
final class DisabledUpdaterController: UpdaterProviding {
var automaticallyChecksForUpdates: Bool = false
var automaticallyDownloadsUpdates: Bool = false
let isAvailable: Bool = false
let unavailableReason: String?
let updateStatus = UpdateStatus()
init(unavailableReason: String? = nil) {
self.unavailableReason = unavailableReason
}
func checkForUpdates(_ sender: Any?) {}
func installUpdate() {}
}
@MainActor
@Observable
final class UpdateStatus {
static let disabled = UpdateStatus()
var isUpdateReady: Bool
init(isUpdateReady: Bool = false) {
self.isUpdateReady = isUpdateReady
}
}
#if canImport(Sparkle) && ENABLE_SPARKLE
import Sparkle
@MainActor
final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate {
private final class ImmediateInstallHandler: @unchecked Sendable {
private let handler: () -> Void
init(_ handler: @escaping () -> Void) {
self.handler = handler
}
func install() {
self.handler()
}
}
private lazy var controller = SPUStandardUpdaterController(
startingUpdater: false,
updaterDelegate: self,
userDriverDelegate: nil)
let updateStatus = UpdateStatus()
let unavailableReason: String? = nil
private var immediateInstallHandler: ImmediateInstallHandler?
init(savedAutoUpdate: Bool) {
super.init()
let updater = self.controller.updater
updater.automaticallyChecksForUpdates = savedAutoUpdate
updater.automaticallyDownloadsUpdates = savedAutoUpdate
self.controller.startUpdater()
}
var automaticallyChecksForUpdates: Bool {
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 installUpdate() {
guard let immediateInstallHandler else {
self.controller.checkForUpdates(nil)
return
}
immediateInstallHandler.install()
}
nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
_ = updater
_ = item
}
nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
_ = updater
_ = item
_ = error
Task { @MainActor in
self.immediateInstallHandler = nil
self.updateStatus.isUpdateReady = false
}
}
nonisolated func userDidCancelDownload(_ updater: SPUUpdater) {
_ = updater
Task { @MainActor in
self.immediateInstallHandler = nil
self.updateStatus.isUpdateReady = false
}
}
nonisolated func updater(
_ updater: SPUUpdater,
willInstallUpdateOnQuit item: SUAppcastItem,
immediateInstallationBlock immediateInstallHandler: @escaping () -> Void)
-> Bool
{
_ = updater
_ = item
let installHandler = ImmediateInstallHandler(immediateInstallHandler)
Task { @MainActor in
self.immediateInstallHandler = installHandler
self.updateStatus.isUpdateReady = true
}
return true
}
nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
_ = updater
_ = error
Task { @MainActor in
self.immediateInstallHandler = nil
self.updateStatus.isUpdateReady = false
}
}
nonisolated func updater(
_ updater: SPUUpdater,
userDidMake choice: SPUUserUpdateChoice,
forUpdate updateItem: SUAppcastItem,
state: SPUUserUpdateState)
{
let downloaded = state.stage == .downloaded
Task { @MainActor in
switch choice {
case .install, .skip:
self.immediateInstallHandler = nil
self.updateStatus.isUpdateReady = false
case .dismiss:
self.updateStatus.isUpdateReady = downloaded
@unknown default:
self.immediateInstallHandler = nil
self.updateStatus.isUpdateReady = false
}
}
}
nonisolated func allowedChannels(for updater: SPUUpdater) -> Set<String> {
UpdateChannel.current.allowedSparkleChannels
}
}
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
var staticCode: SecStaticCode?
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess,
let code = staticCode else { return false }
var infoCF: CFDictionary?
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any],
let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
let leaf = certs.first else { return false }
if let summary = SecCertificateCopySubjectSummary(leaf) as String? {
return summary.hasPrefix("Developer ID Application:")
}
return false
}
@MainActor
private func makeUpdaterController() -> UpdaterProviding {
let bundleURL = Bundle.main.bundleURL
let isBundledApp = bundleURL.pathExtension == "app"
guard isBundledApp else {
return DisabledUpdaterController(unavailableReason: "Updates unavailable in this build.")
}
if InstallOrigin.isHomebrewCask(appBundleURL: bundleURL) {
return DisabledUpdaterController(
unavailableReason: "Updates managed by Homebrew. Run: brew upgrade --cask steipete/tap/codexbar")
}
guard isDeveloperIDSigned(bundleURL: bundleURL) else {
return DisabledUpdaterController(unavailableReason: "Updates unavailable in this build.")
}
let defaults = UserDefaults.standard
let autoUpdateKey = "autoUpdateEnabled"
// Default to true for first launch; fall back to saved preference thereafter.
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate)
}
#else
private func makeUpdaterController() -> UpdaterProviding {
DisabledUpdaterController()
}
#endif
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
struct Dependencies {
let store: UsageStore
let settings: SettingsStore
let account: AccountInfo
let selection: PreferencesSelection
let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator
let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator
}
let updaterController: UpdaterProviding = makeUpdaterController()
private let confettiOverlayController = ScreenConfettiOverlayController()
private let confettiLogger = CodexBarLog.logger(LogCategories.confetti)
private lazy var memoryPressureMonitor = MemoryPressureMonitor(trimAppCaches: { [weak self] in
self?.trimRebuildableCachesForMemoryPressure() ?? MemoryPressureCacheTrimSummary()
})
private var statusController: StatusItemControlling?
private var store: UsageStore?
private var settings: SettingsStore?
private var account: AccountInfo?
private var preferencesSelection: PreferencesSelection?
private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator?
private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator?
private var hasInstalledLimitResetObservers = false
#if DEBUG
private var debugMemoryPressureObserver: NSObjectProtocol?
#endif
var terminateActiveProcessesForAppShutdown: () -> Void = {
TTYCommandRunner.terminateActiveProcessesForAppShutdown()
}
func configure(_ dependencies: Dependencies) {
self.store = dependencies.store
self.settings = dependencies.settings
self.account = dependencies.account
self.preferencesSelection = dependencies.selection
self.managedCodexAccountCoordinator = dependencies.managedCodexAccountCoordinator
self.codexAccountPromotionCoordinator = dependencies.codexAccountPromotionCoordinator
}
func applicationWillFinishLaunching(_ notification: Notification) {
self.configureAppIconForMacOSVersion()
}
func applicationDidFinishLaunching(_ notification: Notification) {
AppNotifications.shared.requestAuthorizationOnStartup()
self.memoryPressureMonitor.start()
#if DEBUG
self.installDebugMemoryPressureObserverIfNeeded()
#endif
self.ensureStatusController()
KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in
// KeyboardShortcuts dispatches both normal and menu-tracking hotkeys on the main event loop.
MainActor.assumeIsolated {
self?.statusController?.openMenuFromShortcut()
}
}
if !self.hasInstalledLimitResetObservers {
NotificationCenter.default.addObserver(
self,
selector: #selector(self.handleSessionLimitResetNotification(_:)),
name: .codexbarSessionLimitReset,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.handleWeeklyLimitResetNotification(_:)),
name: .codexbarWeeklyLimitReset,
object: nil)
self.hasInstalledLimitResetObservers = true
}
}
func applicationWillTerminate(_ notification: Notification) {
self.memoryPressureMonitor.stop()
#if DEBUG
self.removeDebugMemoryPressureObserver()
#endif
self.statusController?.prepareForAppShutdown()
self.confettiOverlayController.dismiss()
self.dismissAppKitWindowsForShutdown()
self.terminateActiveProcessesForAppShutdown()
}
func runProviderLoginFlow(_ provider: UsageProvider) async {
self.ensureStatusController()
guard let statusController else { return }
await statusController.runLoginFlowFromSettings(provider: provider)
}
@objc private func handleSessionLimitResetNotification(_ notification: Notification) {
guard let event = notification.object as? SessionLimitResetEvent else { return }
guard self.settings?.confettiOnSessionLimitResetsEnabled == true else { return }
self.playLimitResetConfetti(
provider: event.provider,
accountIdentifier: event.accountIdentifier,
resetKind: "session")
}
@objc private func handleWeeklyLimitResetNotification(_ notification: Notification) {
guard let event = notification.object as? WeeklyLimitResetEvent else { return }
guard self.settings?.confettiOnWeeklyLimitResetsEnabled == true else { return }
self.playLimitResetConfetti(
provider: event.provider,
accountIdentifier: event.accountIdentifier,
resetKind: "weekly")
}
private func playLimitResetConfetti(
provider: UsageProvider,
accountIdentifier: String,
resetKind: String)
{
let origin = self.statusController?.celebrationOriginPoint(for: provider)
self.confettiLogger.info(
"Triggering confetti",
metadata: [
"provider": provider.rawValue,
"accountIdentifier": accountIdentifier,
"resetKind": resetKind,
"originKnown": origin == nil ? "0" : "1",
])
self.confettiOverlayController.play(originInScreen: origin)
}
/// Use the classic (non-Liquid Glass) app icon on macOS versions before 26.
private func configureAppIconForMacOSVersion() {
if #unavailable(macOS 26) {
self.applyClassicAppIcon()
}
}
private func applyClassicAppIcon() {
guard let classicIcon = Self.loadClassicIcon() else { return }
NSApp.applicationIconImage = classicIcon
}
private static func loadClassicIcon() -> NSImage? {
guard let url = self.classicIconURL(),
let image = NSImage(contentsOf: url)
else {
return nil
}
return image
}
private static func classicIconURL() -> URL? {
Bundle.main.url(forResource: "Icon-classic", withExtension: "icns")
}
private func dismissAppKitWindowsForShutdown() {
guard let app = NSApp else { return }
for window in app.windows {
window.orderOut(nil)
}
}
private func ensureStatusController() {
if self.statusController != nil { return }
if let store,
let settings,
let account,
let selection = self.preferencesSelection,
let managedCodexAccountCoordinator,
let codexAccountPromotionCoordinator
{
self.statusController = StatusItemController.factory(
store,
settings,
account,
self.updaterController,
selection,
managedCodexAccountCoordinator,
codexAccountPromotionCoordinator)
return
}
// Defensive fallback: this should not be hit in normal app lifecycle.
CodexBarLog.logger(LogCategories.app)
.error("StatusItemController fallback path used; settings/store mismatch likely.")
assertionFailure("StatusItemController fallback path used; check app lifecycle wiring.")
let fallbackSettings = SettingsStore()
let fetcher = UsageFetcher()
let browserDetection = BrowserDetection(cacheTTL: BrowserDetection.defaultCacheTTL)
let fallbackAccount = fetcher.loadAccountInfo()
let fallbackStore = UsageStore(fetcher: fetcher, browserDetection: browserDetection, settings: fallbackSettings)
let fallbackManagedCodexAccountCoordinator = ManagedCodexAccountCoordinator()
let fallbackCodexAccountPromotionCoordinator = CodexAccountPromotionCoordinator(
settingsStore: fallbackSettings,
usageStore: fallbackStore,
managedAccountCoordinator: fallbackManagedCodexAccountCoordinator)
self.statusController = StatusItemController.factory(
fallbackStore,
fallbackSettings,
fallbackAccount,
self.updaterController,
PreferencesSelection(),
fallbackManagedCodexAccountCoordinator,
fallbackCodexAccountPromotionCoordinator)
}
private func trimRebuildableCachesForMemoryPressure() -> MemoryPressureCacheTrimSummary {
var summary = MemoryPressureCacheTrimSummary()
let statusSummary = self.statusController?.trimRebuildableCachesForMemoryPressure()
?? MemoryPressureCacheTrimSummary()
let storeSummary = self.store?.trimRebuildableCachesForMemoryPressure()
?? MemoryPressureCacheTrimSummary()
summary.merge(statusSummary)
summary.merge(storeSummary)
return summary
}
#if DEBUG
private func installDebugMemoryPressureObserverIfNeeded() {
guard self.debugMemoryPressureObserver == nil else { return }
self.debugMemoryPressureObserver = DistributedNotificationCenter.default().addObserver(
forName: .codexbarDebugSimulateMemoryPressure,
object: nil,
queue: .main)
{ [weak self] notification in
let rawLevel = notification.userInfo?["level"] as? String
let shouldSeedCaches = notification.userInfo?["seedCaches"] as? String == "1"
MainActor.assumeIsolated {
self?.handleDebugMemoryPressureNotification(
rawLevel: rawLevel,
shouldSeedCaches: shouldSeedCaches)
}
}
}
private func removeDebugMemoryPressureObserver() {
guard let observer = self.debugMemoryPressureObserver else { return }
DistributedNotificationCenter.default().removeObserver(observer)
self.debugMemoryPressureObserver = nil
}
private func handleDebugMemoryPressureNotification(rawLevel: String?, shouldSeedCaches: Bool) {
let isCritical = rawLevel?.caseInsensitiveCompare("critical") == .orderedSame
if shouldSeedCaches {
OpenAIDashboardFetcher.seedCachedWebViewsForMemoryPressureProof()
self.statusController?.seedRebuildableCachesForMemoryPressureProof()
self.store?.seedRebuildableCachesForMemoryPressureProof()
}
CodexBarLog.logger(LogCategories.memoryPressure).info(
"Debug memory pressure notification received",
metadata: [
"level": isCritical ? "critical" : "warning",
"seedCaches": shouldSeedCaches ? "1" : "0",
])
self.memoryPressureMonitor.handleMemoryPressureForTesting(isWarning: !isCritical, isCritical: isCritical)
}
#endif
deinit {
NotificationCenter.default.removeObserver(self)
}
}