385 lines · 14.6 KB · Swift
RawDownload
import AppKitimport CodexBarCoreimport KeyboardShortcutsimport Observationimport QuartzCoreimport Securityimport SwiftUI @mainstruct 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()         let preferencesSelection = PreferencesSelection()        let settings = SettingsStore()        let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator()        managedCodexAccountCoordinator.onManagedAccountsDidChange = {            _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()        }        _ = 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)        }        .defaultSize(width: PreferencesTab.general.preferredWidth, height: PreferencesTab.general.preferredHeight)        .windowResizability(.contentSize)    }     private func openSettings(tab: PreferencesTab) {        self.preferencesSelection.tab = tab        NSApp.activate(ignoringOtherApps: true)        _ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)    }} // MARK: - Updater abstraction @MainActorprotocol 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?)} /// 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?) {}} @MainActor@Observablefinal class UpdateStatus {    static let disabled = UpdateStatus()    var isUpdateReady: Bool     init(isUpdateReady: Bool = false) {        self.isUpdateReady = isUpdateReady    }} #if canImport(Sparkle) && ENABLE_SPARKLEimport Sparkle @MainActorfinal class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate {    private lazy var controller = SPUStandardUpdaterController(        startingUpdater: false,        updaterDelegate: self,        userDriverDelegate: nil)    let updateStatus = UpdateStatus()    let unavailableReason: String? = nil     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)    }     nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {        Task { @MainActor in            self.updateStatus.isUpdateReady = true        }    }     nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {        Task { @MainActor in            self.updateStatus.isUpdateReady = false        }    }     nonisolated func userDidCancelDownload(_ updater: SPUUpdater) {        Task { @MainActor in            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.updateStatus.isUpdateReady = false            case .dismiss:                self.updateStatus.isUpdateReady = downloaded            @unknown default:                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} @MainActorprivate 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)}#elseprivate func makeUpdaterController() -> UpdaterProviding {    DisabledUpdaterController()}#endif @MainActorfinal 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 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?     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.ensureStatusController()        KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in            Task { @MainActor [weak self] in                self?.statusController?.openMenuFromShortcut()            }        }    }     func applicationWillTerminate(_ notification: Notification) {        TTYCommandRunner.terminateActiveProcessesForAppShutdown()    }     /// 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 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)    }}