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
188 changes: 169 additions & 19 deletions Sources/CodexBar/CodexbarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,20 +106,22 @@ struct CodexBarApp: App {
@SceneBuilder
var body: some Scene {
Settings {
PreferencesView(
settings: self.settings,
store: self.store,
cloudSyncState: self.appDelegate.cloudSyncState,
updater: self.appDelegate.updaterController,
selection: self.preferencesSelection,
managedCodexAccountCoordinator: self.managedCodexAccountCoordinator,
codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator,
runProviderLoginFlow: { provider in
await self.appDelegate.runProviderLoginFlow(provider)
})
EmptyView()
}
.defaultSize(width: SettingsPane.windowWidth, height: SettingsPane.windowHeight)
.windowResizability(.contentMinSize)
.commands {
CommandGroup(replacing: .appSettings) {
Button(self.settingsMenuTitle) {
self.appDelegate.openSettings(pane: nil)
}
.keyboardShortcut(",", modifiers: .command)
}
}
}

private var settingsMenuTitle: String {
// Establish an Observation dependency so the command title follows in-app language changes.
_ = self.settings.appLanguage
return L("Settings...")
}

private static func applyLanguagePreference(from settings: SettingsStore) {
Expand Down Expand Up @@ -173,6 +175,8 @@ import Sparkle

@MainActor
final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate {
private static let presentationTimeout: Duration = .seconds(60)

private final class ImmediateInstallHandler: @unchecked Sendable {
private let handler: () -> Void

Expand All @@ -192,6 +196,7 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDele
let updateStatus = UpdateStatus()
let unavailableReason: String? = nil
private var immediateInstallHandler: ImmediateInstallHandler?
private var dockPresentationAttemptID: DockIconPresentationAttemptID?

init(savedAutoUpdate: Bool) {
super.init()
Expand All @@ -216,7 +221,8 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDele
}

func checkForUpdates(_ sender: Any?) {
DockIconController.shared.promote()
self.dockPresentationAttemptID = DockIconController.shared.promote(
presentationTimeout: Self.presentationTimeout)
self.controller.checkForUpdates(sender)
}

Expand Down Expand Up @@ -277,6 +283,25 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDele
}
}

nonisolated func updater(
_ updater: SPUUpdater,
didFinishUpdateCycleFor updateCheck: SPUUpdateCheck,
error: Error?)
{
_ = updater
CodexBarLog.logger(LogCategories.app).debug(
"Sparkle update cycle finished",
metadata: [
"check": String(describing: updateCheck),
"hadError": error == nil ? "0" : "1",
])
Task { @MainActor in
guard let attemptID = self.dockPresentationAttemptID else { return }
self.dockPresentationAttemptID = nil
DockIconController.shared.finishPresentationAttempt(attemptID)
}
}

nonisolated func updater(
_ updater: SPUUpdater,
userDidMake choice: SPUUserUpdateChoice,
Expand Down Expand Up @@ -351,6 +376,9 @@ private func makeUpdaterController() -> UpdaterProviding {

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private static let settingsMenuReadinessRetryCount = 2
private static let settingsMenuFallbackVerificationRetryCount = 2

struct Dependencies {
let store: UsageStore
let settings: SettingsStore
Expand All @@ -377,6 +405,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator?
private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator?
private var cloudSyncCoordinator: CloudSyncCoordinator?
private var settingsWindowController: SettingsWindowController?
private var hasInstalledLimitResetObservers = false
#if DEBUG
private var debugMemoryPressureObserver: NSObjectProtocol?
Expand All @@ -393,6 +422,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.managedCodexAccountCoordinator = dependencies.managedCodexAccountCoordinator
self.codexAccountPromotionCoordinator = dependencies.codexAccountPromotionCoordinator
self.cloudSyncCoordinator = CloudSyncCoordinator(settings: dependencies.settings, state: self.cloudSyncState)
self.settingsWindowController = SettingsWindowController(
settings: dependencies.settings,
store: dependencies.store,
cloudSyncState: self.cloudSyncState,
updater: self.updaterController,
selection: dependencies.selection,
managedCodexAccountCoordinator: dependencies.managedCodexAccountCoordinator,
codexAccountPromotionCoordinator: dependencies.codexAccountPromotionCoordinator,
runProviderLoginFlow: { [weak self] provider in
guard let self else { return }
await self.runProviderLoginFlow(provider)
})
}

func applicationWillFinishLaunching(_ notification: Notification) {
Expand All @@ -406,6 +447,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.installDebugMemoryPressureObserverIfNeeded()
#endif
self.ensureStatusController()
self.observeSettingsApplicationMenuLanguage()
self.scheduleSettingsApplicationMenuValidation(
missingItemRetriesRemaining: Self.settingsMenuReadinessRetryCount,
fallbackVerificationRetriesRemaining: Self.settingsMenuFallbackVerificationRetryCount)
self.cloudSyncCoordinator?.start()
Task { @MainActor [weak self] in
await Task.yield()
Expand Down Expand Up @@ -463,6 +508,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
await statusController.runLoginFlowFromSettings(provider: provider)
}

func openSettings(pane: SettingsPane?) {
// Escape NSMenu's synchronous tracking callback before activating and presenting a window.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard let settingsWindowController = self.settingsWindowController else {
self.dockIconController.settingsWindowPresentationFailed()
CodexBarLog.logger(LogCategories.app).error("Settings window controller was not configured")
return
}
settingsWindowController.open(pane: pane)
}
}

@objc private func showSettingsFromApplicationMenu(_: Any?) {
self.openSettings(pane: nil)
}

@objc private func handleSessionLimitResetNotification(_ notification: Notification) {
guard let event = notification.object as? SessionLimitResetEvent else { return }
guard self.settings?.confettiOnSessionLimitResetsEnabled == true else { return }
Expand Down Expand Up @@ -531,6 +593,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

private func observeSettingsApplicationMenuLanguage() {
guard let settings else { return }
withObservationTracking {
_ = settings.appLanguage
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.observeSettingsApplicationMenuLanguage()
self.scheduleSettingsApplicationMenuValidation(
missingItemRetriesRemaining: Self.settingsMenuReadinessRetryCount,
fallbackVerificationRetriesRemaining: Self.settingsMenuFallbackVerificationRetryCount)
}
}
}

private func scheduleSettingsApplicationMenuValidation(
missingItemRetriesRemaining: Int,
fallbackVerificationRetriesRemaining: Int)
{
DispatchQueue.main.async { [weak self] in
self?.ensureSingleSettingsApplicationMenuItem(
missingItemRetriesRemaining: missingItemRetriesRemaining,
fallbackVerificationRetriesRemaining: fallbackVerificationRetriesRemaining)
}
}

private func ensureSingleSettingsApplicationMenuItem(
missingItemRetriesRemaining: Int,
fallbackVerificationRetriesRemaining: Int)
{
guard let mainMenu = NSApp.mainMenu else {
if missingItemRetriesRemaining > 0 {
self.scheduleSettingsApplicationMenuValidation(
missingItemRetriesRemaining: missingItemRetriesRemaining - 1,
fallbackVerificationRetriesRemaining: fallbackVerificationRetriesRemaining)
} else {
CodexBarLog.logger(LogCategories.app).error("Application menu unavailable for Settings validation")
}
return
}
let result = SettingsApplicationMenu.ensureSingleItem(
in: mainMenu,
localizedTitle: L("Settings..."),
target: self,
action: #selector(self.showSettingsFromApplicationMenu(_:)),
allowMissingItemRepair: missingItemRetriesRemaining == 0)
switch result {
case let .unchanged(isFallback):
if isFallback, fallbackVerificationRetriesRemaining > 0 {
self.scheduleSettingsApplicationMenuValidation(
missingItemRetriesRemaining: Self.settingsMenuReadinessRetryCount,
fallbackVerificationRetriesRemaining: fallbackVerificationRetriesRemaining - 1)
}
case .retryNeeded:
self.scheduleSettingsApplicationMenuValidation(
missingItemRetriesRemaining: max(0, missingItemRetriesRemaining - 1),
fallbackVerificationRetriesRemaining: fallbackVerificationRetriesRemaining)
case let .repaired(previousCount, installedFallback):
CodexBarLog.logger(LogCategories.app).warning(
"Repaired application Settings menu",
metadata: [
"installedFallback": installedFallback ? "1" : "0",
"previousCount": "\(previousCount)",
])
if installedFallback, fallbackVerificationRetriesRemaining > 0 {
self.scheduleSettingsApplicationMenuValidation(
missingItemRetriesRemaining: Self.settingsMenuReadinessRetryCount,
fallbackVerificationRetriesRemaining: fallbackVerificationRetriesRemaining - 1)
}
case .missingApplicationMenu:
if missingItemRetriesRemaining > 0 {
self.scheduleSettingsApplicationMenuValidation(
missingItemRetriesRemaining: missingItemRetriesRemaining - 1,
fallbackVerificationRetriesRemaining: fallbackVerificationRetriesRemaining)
} else {
CodexBarLog.logger(LogCategories.app).error("Could not repair application Settings menu")
}
}
}

private func ensureStatusController() {
if self.statusController != nil {
return
Expand All @@ -543,17 +685,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let managedCodexAccountCoordinator,
let codexAccountPromotionCoordinator
{
self.statusController = StatusItemController.factory(
let statusController = StatusItemController.factory(
store,
settings,
account,
self.updaterController,
selection,
managedCodexAccountCoordinator,
codexAccountPromotionCoordinator)
if let statusController = self.statusController as? StatusItemController {
statusController.cloudSyncState = self.cloudSyncState
MenuSwitchFlickerProbe.startIfRequested(controller: statusController)
statusController.setSettingsOpenHandler { [weak self] pane in
self?.openSettings(pane: pane)
}
self.statusController = statusController
if let concreteStatusController = statusController as? StatusItemController {
concreteStatusController.cloudSyncState = self.cloudSyncState
MenuSwitchFlickerProbe.startIfRequested(controller: concreteStatusController)
}
return
}
Expand All @@ -572,14 +718,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
settingsStore: fallbackSettings,
usageStore: fallbackStore,
managedAccountCoordinator: fallbackManagedCodexAccountCoordinator)
self.statusController = StatusItemController.factory(
let statusController = StatusItemController.factory(
fallbackStore,
fallbackSettings,
fallbackAccount,
self.updaterController,
PreferencesSelection(),
fallbackManagedCodexAccountCoordinator,
fallbackCodexAccountPromotionCoordinator)
statusController.setSettingsOpenHandler { [weak self] pane in
self?.openSettings(pane: pane)
}
self.statusController = statusController
}

private func trimRebuildableCachesForMemoryPressure() -> MemoryPressureCacheTrimSummary {
Expand Down
Loading