Skip to content
Closed
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
157 changes: 146 additions & 11 deletions Sources/OpenUsage/App/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import Observation
@MainActor
@Observable
final class AppContainer {
/// The AppDelegate replaces its immutable registry, layout, runtime, and status-item graph when
/// a login changes; posting only after a verified graph diff keeps account cards stable otherwise.
static let accountGraphDidChangeNotification = Notification.Name(
"OpenUsage.accountGraphDidChange"
)

let registry: WidgetRegistry
let layout: LayoutStore
let dataStore: WidgetDataStore
Expand Down Expand Up @@ -41,6 +47,9 @@ final class AppContainer {
/// provider were ever removed from the registry. Injected into the view tree via
/// `\.codexResetClaim`.
let codexResetClaim: CodexResetClaimService?
/// The authoritative account registry shared by discovery, runtime assembly, and live renames.
let accounts: ProviderAccountsStore

/// The provider runtimes, kept so on-demand credential detection (the Customize "Reset All" reseed)
/// can re-probe `hasLocalCredentials()` the same way first-run seeding does.
private let providers: [ProviderRuntime]
Expand All @@ -56,6 +65,8 @@ final class AppContainer {
/// Persists a fresh `ShellEnvironmentSnapshot` once the login-shell capture completes, so the next
/// launch can read shell-exported facts (provider home overrides) even when its own capture is slow.
private let shellEnvironmentSnapshotTask: Task<Void, Never>
/// Cheap default-home identity checks catch live swaps promptly; full discovery is throttled.
private let accountGraphWatchTask: Task<Void, Never>

/// `isFreshInstall` must be captured by the caller BEFORE `SettingsMigrator.migrate()` runs (the
/// migrator's schema stamp makes the defaults domain non-empty). See `AppDelegate`.
Expand All @@ -67,11 +78,18 @@ final class AppContainer {
// Once the capture lands, persist its identity-relevant facts so the NEXT launch has them
// even if that launch's own capture is slow (see `ShellEnvironmentSnapshot`).
self.shellEnvironmentSnapshotTask = ShellEnvironmentSnapshotStore(defaults: .standard).startRefreshTask()
// The launch account pass: which account is signed in at each family's default home. Feeds
// the snapshot cache's account stamp and reconciles the account registry.
let accountAssembly = ProviderAccountAssembly.make(waitsForLoginShell: true)
// Reconcile one shared registry before constructing any account-aware runtime. Every Claude
// runtime then follows its record's current verified source, regardless of its card id.
let accounts = ProviderAccountsStore()
let accountAssembly = ProviderAccountAssembly.make(
accountsStore: accounts,
waitsForLoginShell: true
)
self.accounts = accounts

let providers = ProviderCatalog.make()
let providers = ProviderCatalog.make(
claude: accountAssembly.claudeRuntimePlan
)
let registry = WidgetRegistry.from(providers)
let apiKeyProviders = providers.compactMap { $0 as? any APIKeyManaging }
let enablement = ProviderEnablementStore()
Expand All @@ -86,7 +104,8 @@ final class AppContainer {
isProviderEnabled: { [enablement] in enablement.isEnabled($0) },
orderedDescriptors: { [layout] in layout.visiblePlaced.compactMap { layout.descriptor(for: $0) } },
notificationSettings: { notificationSettings },
providerIdentityKeys: accountAssembly.identityKeysByCard
providerIdentityKeys: accountAssembly.identityKeysByCard,
resolveDisplayName: { [accounts] in accounts.resolvedDisplayName(cardID: $0) }
)
let iCloudSync = ICloudUsageSyncStore(dataStore: dataStore)
// Re-enabling a provider should fetch it promptly, so clear any leftover failure backoff before
Expand All @@ -99,19 +118,19 @@ final class AppContainer {
// Fresh installs start minimal: seed the enabled-provider list (Claude/Codex/Cursor right away,
// then the detected set once the local credential probe finishes). No-op on every later launch.
let onboarding = OnboardingStore()
self.seedTask = FirstRunSeeder.seedIfNeeded(
let firstRunSeedTask = FirstRunSeeder.seedIfNeeded(
isFreshInstall: isFreshInstall,
providers: providers,
enablement: enablement,
onboarding: onboarding
)
self.seedTask = firstRunSeedTask
// Providers added by an update get the same credential detection on their first launch — enabled
// only when the user actually has the tool. Runs every launch; a no-op unless the registry has a
// provider this install has never seen (fresh installs were just baselined by FirstRunSeeder).
self.newProviderTask = NewProviderSeeder.reconcileIfNeeded(
providers: providers,
enablement: enablement
)
self.newProviderTask = firstRunSeedTask == nil
? NewProviderSeeder.reconcileIfNeeded(providers: providers, enablement: enablement)
: nil
self.providers = providers
self.onboarding = onboarding
self.registry = registry
Expand Down Expand Up @@ -196,16 +215,21 @@ final class AppContainer {
self.telemetry = telemetry
self.transparency = PopoverTransparencyStore()
self.privacy = MenuBarPrivacyStore()
self.localAPI = LocalUsageServer(state: { [layout, enablement, dataStore] in
self.localAPI = LocalUsageServer(state: { [layout, enablement, dataStore, accounts] in
LocalUsageAPI.State(
enabledOrderedIDs: layout.orderedProviderIDs().filter { enablement.isEnabled($0) },
knownIDs: Set(registry.providers.map(\.id)),
snapshots: dataStore.snapshots,
limitDescriptors: registry.limitDescriptorsByProvider,
errors: dataStore.providerErrors
)
.resolvingDisplayNames(accounts.resolvedDisplayNamesByCardID)
})
self.refreshTask = Self.startPeriodicRefresh(dataStore: dataStore, telemetry: telemetry)
self.accountGraphWatchTask = Self.startAccountGraphWatch(
accounts: accounts,
initialAssembly: accountAssembly
)
localAPI.start()
// Become the notification-center delegate so banners show while frontmost — a menu-bar accessory
// effectively always is. Notification authorization is requested the first time a trigger is
Expand All @@ -218,6 +242,32 @@ final class AppContainer {
seedTask?.cancel()
newProviderTask?.cancel()
shellEnvironmentSnapshotTask.cancel()
accountGraphWatchTask.cancel()
}

/// Stop every long-lived service before the AppDelegate installs the replacement graph.
func shutdownForAccountGraphReload() async {
accountGraphWatchTask.cancel()
refreshTask.cancel()
seedTask?.cancel()
newProviderTask?.cancel()
shellEnvironmentSnapshotTask.cancel()
iCloudSync.shutdownForAccountGraphReload()
await localAPI.stop()
}

func displayName(for provider: Provider) -> String {
accounts.resolvedDisplayName(cardID: provider.id) ?? provider.displayName
}

func displayName(for providerID: String) -> String {
accounts.resolvedDisplayName(cardID: providerID)
?? registry.provider(id: providerID)?.displayName
?? providerID
}

func canRename(_ providerID: String) -> Bool {
accounts.runtimeRecord(for: providerID) != nil
}

/// Re-runs first-launch credential detection on demand — the enablement half of the Customize
Expand Down Expand Up @@ -263,6 +313,91 @@ final class AppContainer {
AppLog.info(.config, "All settings reset to defaults")
}

/// Watch one tiny default-home identity file every five seconds, and only walk config dirs and
/// Cowork sandboxes after a detected swap or once a minute. The potentially hundreds of identity
/// files are collected off the main actor; account-store reconciliation alone returns to it.
private static func startAccountGraphWatch(
accounts: ProviderAccountsStore,
initialAssembly: ProviderAccountAssembly
) -> Task<Void, Never> {
let initialDefaultIdentity = DefaultAccountObserver().observeClaude()
return Task { @MainActor in
var previousDefaultIdentity = initialDefaultIdentity
var checksSinceFullDiscovery = 0
while !Task.isCancelled {
do {
try await Task.sleep(for: .seconds(5))
} catch {
return
}
guard !Task.isCancelled else { return }
let currentDefaultIdentity = DefaultAccountObserver().observeClaude()
checksSinceFullDiscovery += 1
let defaultChanged = currentDefaultIdentity != previousDefaultIdentity
guard defaultChanged || checksSinceFullDiscovery >= 12 else { continue }

previousDefaultIdentity = currentDefaultIdentity
checksSinceFullDiscovery = 0
let preparedDiscovery = await prepareAccountDiscovery()
guard !Task.isCancelled else { return }
guard DefaultAccountObserver().observeClaude() == currentDefaultIdentity else {
AppLog.info(.config, "accounts: default login changed during discovery; retrying with a fresh graph scan")
continue
}
let currentAssembly = ProviderAccountAssembly.make(
accountsStore: accounts,
waitsForLoginShell: true,
preparedDiscovery: preparedDiscovery
)
Comment thread
robinebers marked this conversation as resolved.
guard !Task.isCancelled else { return }
guard currentAssembly.claudeCards != initialAssembly.claudeCards
|| currentAssembly.identityKeysByCard != initialAssembly.identityKeysByCard
|| currentAssembly.allowsUnboundClaudeFallback != initialAssembly.allowsUnboundClaudeFallback
else { continue }

AppLog.info(.config, "accounts: verified account sources changed; rebuilding the runtime graph")
NotificationCenter.default.post(
name: Self.accountGraphDidChangeNotification,
object: nil
)
return
}
}
}

/// Collect blocking filesystem/keychain-attribute discovery on a detached utility executor.
/// Cancelling the graph watcher also cancels its detached scan; no stale result may reconcile.
static func prepareAccountDiscovery(
configScan: @escaping @Sendable () -> ClaudeConfigDirDiscovery.Result = {
ClaudeConfigDirDiscovery().run()
},
coworkScan: @escaping @Sendable () -> ClaudeCoworkDiscovery.Result = {
ClaudeCoworkDiscovery().run()
}
) async -> PreparedProviderAccountDiscovery {
let task = Task.detached(priority: .utility) {
guard !Task.isCancelled else {
return PreparedProviderAccountDiscovery(
config: ClaudeConfigDirDiscovery.Result(),
cowork: ClaudeCoworkDiscovery.Result(truncated: true)
)
}
let config = configScan()
guard !Task.isCancelled else {
return PreparedProviderAccountDiscovery(
config: config,
cowork: ClaudeCoworkDiscovery.Result(truncated: true)
)
}
return PreparedProviderAccountDiscovery(config: config, cowork: coworkScan())
}
return await withTaskCancellationHandler {
await task.value
} onCancel: {
task.cancel()
}
}

/// Drives live updates: refresh on launch, then again every refresh interval. Each pass honors the
/// cache, so it only hits the network once a snapshot has actually expired. `@Observable` propagates
/// the resulting snapshot changes to the menu-bar label and any open widgets, so the UI refreshes on
Expand Down
11 changes: 9 additions & 2 deletions Sources/OpenUsage/App/FirstRunSeeder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,21 @@ enum FirstRunSeeder {
logPrefix: String,
probeVerb: String = "probing"
) -> Task<Void, Never> {
let fallback = fallbackProviderIDs.intersection(Set(providers.map(\.provider.id)))
let providerIDs = Set(providers.map(\.provider.id))
let fallback = fallbackProviderIDs.intersection(providerIDs)
enablement.seedEnabledProviders(fallback)
enablement.markProviderDetectionPending(providerIDs)
AppLog.info(.config, "\(logPrefix): seeded providers \(fallback.sorted()); \(probeVerb) local credentials")
return Task {
let detected = await detectLocalProviders(providers)
guard !Task.isCancelled else { return }
defer { enablement.finishProviderDetection(providerIDs) }
AppLog.info(.config, "\(logPrefix): detected credentials for \(detected.sorted())")
guard enablement.enabledIDs == fallback, !detected.isEmpty else { return }
enablement.seedEnabledProviders(detected)
let honoringUserChoices = detected.intersection(enablement.pendingDetectionIDs)
.union(fallback.subtracting(enablement.pendingDetectionIDs))
guard !honoringUserChoices.isEmpty else { return }
enablement.seedEnabledProviders(honoringUserChoices)
}
}

Expand Down
21 changes: 16 additions & 5 deletions Sources/OpenUsage/App/NewProviderSeeder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,28 @@ enum NewProviderSeeder {
}

let newIDs = enablement.registerKnownProviders(currentIDs)
guard !newIDs.isEmpty else { return nil }
AppLog.info(.config, "new providers since last run: \(newIDs.sorted()); probing local credentials")
let pendingIDs = enablement.pendingDetectionIDs.intersection(currentIDs)
let detectionIDs = newIDs.union(pendingIDs)
guard !detectionIDs.isEmpty else { return nil }
enablement.markProviderDetectionPending(detectionIDs)
AppLog.info(
.config,
"new or interrupted provider checks: \(detectionIDs.sorted()); probing local credentials"
)

return Task {
// Same concurrent local-only probe as first-run detection and the Reset All reseed.
let newProviders = providers.filter { newIDs.contains($0.provider.id) }
let newProviders = providers.filter { detectionIDs.contains($0.provider.id) }
let detected = await FirstRunSeeder.detectLocalProviders(newProviders)
guard !Task.isCancelled else { return }
defer { enablement.finishProviderDetection(detectionIDs) }
for id in detected.sorted() {
guard !Task.isCancelled else { return }
// The probe takes a moment; if the user already turned the provider on themselves,
// leave their toggle alone (setEnabled would be a no-op anyway).
guard !enablement.isEnabled(id) else { continue }
// or explicitly toggled it back off, their choice cancels its pending detection.
guard enablement.pendingDetectionIDs.contains(id),
!enablement.isEnabled(id)
else { continue }
AppLog.info(.config, "new provider \(id): credentials detected, enabling")
enablement.setEnabled(true, for: id)
}
Expand Down
40 changes: 40 additions & 0 deletions Sources/OpenUsage/App/OpenUsageApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
private var container: AppContainer?
private var statusItemController: StatusItemController?
private var singleInstanceLock: SingleInstanceLock.Token?
private var accountGraphObserver: NSObjectProtocol?
private var isReloadingAccountGraph = false
private let updater = UpdaterController()

public override init() {
Expand Down Expand Up @@ -71,10 +73,48 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
let container = AppContainer(isFreshInstall: isFreshInstall)
self.container = container
statusItemController = StatusItemController(container: container, updater: updater)
observeAccountGraphChanges()
// Starts background update checks (release build only; dormant under preview/`swift run`).
updater.start()
}

private func observeAccountGraphChanges() {
guard accountGraphObserver == nil else { return }
accountGraphObserver = NotificationCenter.default.addObserver(
forName: AppContainer.accountGraphDidChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
_ = Task<Void, Never> { [weak self] in
await self?.reloadAccountGraph()
}
}
}
}

private func reloadAccountGraph() async {
guard !isReloadingAccountGraph, let previous = container else { return }
isReloadingAccountGraph = true
defer { isReloadingAccountGraph = false }

let wasVisible = statusItemController?.isPopoverVisible == true
let previousScreen = previous.layout.screen
statusItemController?.shutdown()
statusItemController = nil
await previous.shutdownForAccountGraphReload()

let replacement = AppContainer()
Comment thread
robinebers marked this conversation as resolved.
replacement.layout.screen = previousScreen
container = replacement
let replacementController = StatusItemController(container: replacement, updater: updater)
statusItemController = replacementController
if wasVisible {
replacementController.showPopover()
}
AppLog.info(.config, "account graph rebuilt after a Claude login changed")
}

/// Flush queued telemetry on quit. The SDK's lifecycle autocapture is off (we emit our own daily
/// rollups), so it won't auto-flush on termination — this explicit flush keeps low-frequency events
/// from being stranded across a clean quit.
Expand Down
25 changes: 24 additions & 1 deletion Sources/OpenUsage/App/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ final class StatusItemController: NSObject {
self?.panel.appearance = AppearanceSetting.current.nsAppearance
}
}
// Registered once here; the controller lives for the app's whole life.
// Each account graph owns one controller; shutdown removes this handler before its
// replacement registers another, without clearing the user's configured shortcut.
KeyboardShortcuts.onKeyUp(for: .togglePopover) { [weak self] in
AppLog.info(.statusItem, "Global shortcut fired; toggling popover")
self?.togglePopover()
Expand Down Expand Up @@ -301,6 +302,28 @@ final class StatusItemController: NSObject {
showPanel()
}

var isPopoverVisible: Bool { panel.isVisible }

/// Account ownership can change while the app remains open. Remove the old status item and
/// observers before installing the graph's replacement, otherwise both controllers stay visible.
func shutdown() {
// onKeyUp appends listeners globally, so leaving this installed would make every account
// graph reload add another popover toggle. Removing a handler preserves the saved shortcut.
KeyboardShortcuts.removeHandler(for: .togglePopover)
if panel.isVisible {
hidePanel()
} else {
outsideClickMonitor.stop()
panel.orderOut(nil)
}
if let appearanceObserver {
NotificationCenter.default.removeObserver(appearanceObserver)
self.appearanceObserver = nil
}
statusItem.button?.target = nil
NSStatusBar.system.removeStatusItem(statusItem)
}
Comment thread
robinebers marked this conversation as resolved.

private func showPanel() {
guard let button = statusItem.button, let buttonWindow = button.window else {
AppLog.error(.statusItem, "Cannot show panel: status item has no button")
Expand Down
Loading