import AppKit import CodexBarCore 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 private let preferencesSelection: PreferencesSelection private let account: AccountInfo init() { let env = ProcessInfo.processInfo.environment let level = CodexBarLog.parseLevel(env["CODEXBAR_LOG_LEVEL"]) ?? .info CodexBarLog.bootstrapIfNeeded(.init( destination: .oslog(subsystem: "com.steipete.codexbar"), level: level, json: false)) let preferencesSelection = PreferencesSelection() let settings = SettingsStore() let fetcher = UsageFetcher() let account = fetcher.loadAccountInfo() let store = UsageStore(fetcher: fetcher, settings: settings) self.preferencesSelection = preferencesSelection _settings = State(wrappedValue: settings) _store = State(wrappedValue: store) self.account = account self.appDelegate.configure( store: store, settings: settings, account: account, selection: preferencesSelection) } @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) } .defaultSize(width: PreferencesTab.windowWidth, 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 @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?) } // 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 @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 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 } } } } 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 { let updaterController: UpdaterProviding = makeUpdaterController() private var statusController: StatusItemControlling? private var store: UsageStore? private var settings: SettingsStore? private var account: AccountInfo? private var preferencesSelection: PreferencesSelection? func configure(store: UsageStore, settings: SettingsStore, account: AccountInfo, selection: PreferencesSelection) { self.store = store self.settings = settings self.account = account self.preferencesSelection = selection } func applicationDidFinishLaunching(_ notification: Notification) { AppNotifications.shared.requestAuthorizationOnStartup() self.ensureStatusController() } private func ensureStatusController() { if self.statusController != nil { return } if let store, let settings, let account, let selection = self.preferencesSelection { self.statusController = StatusItemController.factory( store, settings, account, self.updaterController, selection) return } // Defensive fallback: this should not be hit in normal app lifecycle. let fallbackSettings = SettingsStore() let fetcher = UsageFetcher() let fallbackAccount = fetcher.loadAccountInfo() let fallbackStore = UsageStore(fetcher: fetcher, settings: fallbackSettings) self.statusController = StatusItemController.factory( fallbackStore, fallbackSettings, fallbackAccount, self.updaterController, PreferencesSelection()) } }