From df0964474a32914afa6c100f481a0cc64393ed93 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 27 Sep 2026 21:53:05 -0700 Subject: [PATCH] fix(widgets): isolate last-good retention by provider --- CHANGELOG.md | 1 + .../CodexBar/UsageStore+WidgetSnapshot.swift | 37 ++++---- Sources/CodexBar/UsageStore.swift | 2 +- .../WidgetEmptyProjectionTests.swift | 84 ++++++++++++++++++- docs/widgets.md | 12 ++- 5 files changed, 109 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bedc2eca6..54457f8578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Widgets: retain each eligible provider's last-good reading and original age after failed refreshes, even when another provider is unavailable, disabled, or changes accounts (#3500). - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index c8abd0fc26..d3a750323f 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -38,9 +38,7 @@ extension UsageStore { }() let snapshot = self.makeWidgetSnapshot(previousSnapshot: previousSnapshot) self.lastQueuedWidgetSnapshot = snapshot - self.lastQueuedWidgetSnapshotIsPreservable = snapshot.entries.allSatisfy { - !self.widgetUsagePreservationBlockedProviders.contains($0.provider) - } + self.invalidatedQueuedWidgetProviders = self.widgetUsagePreservationBlockedProviders NotificationCenter.default.post( name: .codexbarUsageSnapshotsDidChange, object: UsageSnapshotsDidChangeEvent(snapshots: self.cloudSyncAccountSnapshots())) @@ -191,33 +189,28 @@ extension UsageStore { self.lastWidgetSourceSnapshots[provider.instanceID] = nil self.widgetUsagePreservationBlockedProviders.insert(provider.instanceID) // A successful fetch cannot make an older queued account valid again. - if self.lastQueuedWidgetSnapshot?.entries.contains(where: { $0.provider == provider.instanceID }) == true { - self.lastQueuedWidgetSnapshotIsPreservable = false - } + self.invalidatedQueuedWidgetProviders.insert(provider.instanceID) } private func makeWidgetSnapshot(previousSnapshot: WidgetSnapshot?) -> WidgetSnapshot { let now = Date() let enabledProviders = self.enabledProviders() - var entries = UsageProvider.allCases.compactMap { provider in - self.makeWidgetEntry( + let entries = UsageProvider.allCases.compactMap { provider -> WidgetSnapshot.ProviderEntry? in + if let entry = self.makeWidgetEntry( for: provider, now: now, previousEntry: previousSnapshot?.entries.first { $0.provider == provider.instanceID }) - } - // Only reuse this process's publication; disk entries do not establish the current account's ownership. - if entries.isEmpty, self.lastQueuedWidgetSnapshotIsPreservable, - let previousSnapshot = self.lastQueuedWidgetSnapshot, - previousSnapshot.enabledProviders.allSatisfy(enabledProviders.contains), - previousSnapshot.entries.allSatisfy({ entry in - // Provider-specific by design: Claude's owner-aware preservation above remains authoritative. - entry.provider != .claude && enabledProviders.contains(entry.provider) && - self.errors[entry.provider] != nil && - (entry.providerCost == nil || self.settings.showOptionalCreditsAndExtraUsage) && - !self.widgetUsagePreservationBlockedProviders.contains(entry.provider) - }) - { - entries = previousSnapshot.entries.map { self.preservedWidgetEntryForCurrentMetric($0) } + { return entry } + // Only this process's publication establishes ownership; Claude keeps its owner-aware path above. + guard provider != .claude, enabledProviders.contains(provider.instanceID), + self.errors[provider.instanceID] != nil, + !self.invalidatedQueuedWidgetProviders.contains(provider.instanceID), + !self.widgetUsagePreservationBlockedProviders.contains(provider.instanceID), + let entry = self.lastQueuedWidgetSnapshot?.entries + .first(where: { $0.provider == provider.instanceID }), + entry.providerCost == nil || self.settings.showOptionalCreditsAndExtraUsage + else { return nil } + return self.preservedWidgetEntryForCurrentMetric(entry) } return WidgetSnapshot( entries: entries, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6beeaeb89c..d022a7307c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -332,7 +332,7 @@ final class UsageStore { TimeInterval) async throws -> Void)? @ObservationIgnored var widgetSnapshotPersistTask: Task? @ObservationIgnored var lastQueuedWidgetSnapshot: WidgetSnapshot? - @ObservationIgnored var lastQueuedWidgetSnapshotIsPreservable = false + @ObservationIgnored var invalidatedQueuedWidgetProviders: Set = [] @ObservationIgnored var lastWidgetSourceSnapshots: [ProviderInstanceID: UsageSnapshot] = [:] @ObservationIgnored let widgetSnapshotURL: URL? @ObservationIgnored let widgetTimelineReloader: @MainActor () -> Void diff --git a/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift b/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift index e06848610e..9a43e20c31 100644 --- a/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift +++ b/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift @@ -1,11 +1,80 @@ +import AppKit import CodexBarCore import Foundation +import SwiftUI import Testing +import WidgetKit @testable import CodexBar +@testable import CodexBarWidget @Suite(.serialized, ProviderTransportRegressionFixtures()) @MainActor struct WidgetEmptyProjectionTests { + @Test(arguments: ["claude", "disabled", "retired", "partial"]) + func `one ineligible provider cannot erase another providers last good widget reading`( + scenario: String) async throws + { + let (store, settings) = self.makeStore(providers: [.minimax, .deepseek, .claude]) + var saved: WidgetSnapshot? + store._test_widgetSnapshotSaveOverride = { saved = $0 } + self.seed(store, measuredAt: Date().addingTimeInterval(-3600)) + if scenario == "claude" { self.seed(store, providers: [.claude]) } + store.persistWidgetSnapshot(reason: "synthetic-before-wake") + await store.widgetSnapshotPersistTask?.value + let before = try #require(saved?.entries.first { $0.provider == .deepseek }) + store.snapshots.removeAll() + store.errors = [ + .minimax: "Synthetic offline failure", + .deepseek: "Synthetic offline failure", + .claude: "Synthetic offline failure", + ] + switch scenario { + case "claude": store.widgetUsagePreservationBlockedProviders.insert(.claude) + case "disabled": + settings.setProviderEnabled(provider: .minimax, metadata: store.metadata(for: .minimax), enabled: false) + case "retired": + store.clearProviderRuntimeState(.minimax) + store.errors[.minimax] = "Synthetic offline failure" + case "partial": self.seed(store, providers: [.minimax]) + default: break + } + store.persistWidgetSnapshot(reason: "synthetic-after-wake") + await store.widgetSnapshotPersistTask?.value + try self.renderProof(#require(saved), scenario: scenario) + let after = try #require(saved?.entries.first { $0.provider == .deepseek }) + #expect(after.updatedAt == before.updatedAt) + #expect(after.primary == before.primary) + #expect(saved?.entries.contains { $0.provider == .claude } == false) + if scenario == "disabled" || scenario == "retired" { + #expect(saved?.entries.contains { $0.provider == .minimax } == false) + } + } + + private func renderProof(_ snapshot: WidgetSnapshot, scenario: String) throws { + guard let path = ProcessInfo.processInfo.environment["CODEXBAR_WIDGET_RETENTION_PROOF_DIR"] else { return } + let output = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + let entry = CodexBarSwitcherEntry( + date: snapshot.generatedAt, + provider: .deepseek, + availableProviders: [.minimax, .deepseek, .claude], + snapshot: snapshot) + let view = CodexBarSwitcherWidgetView(entry: entry) + .environment(\.widgetRenderingMode, .fullColor) + .environment(\.colorScheme, .light) + .padding(14) + .frame(width: 360, height: 170) + .background(.background) + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(x: 0, y: 0, width: 360, height: 170) + hosting.appearance = NSAppearance(named: .aqua) + hosting.layoutSubtreeIfNeeded() + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])) + .write(to: output.appendingPathComponent("\(scenario).png")) + } + @Test(arguments: [false, true]) func `all failed providers retain published entries and original ages`(queued: Bool) async throws { let (store, settings) = self.makeStore() @@ -63,7 +132,12 @@ struct WidgetEmptyProjectionTests { store.persistWidgetSnapshot(reason: "synthetic-invalidation") await store.widgetSnapshotPersistTask?.value if scenario == "cold-start" { saved = WidgetSnapshotStore.load(from: url) } - #expect(saved?.entries.count == (scenario == "partial" ? 1 : 0)) + let expected: Set = switch scenario { + case "disabled", "blocked", "retired": [.deepseek] + case "partial": [.minimax, .deepseek] + default: [] + } + #expect(Set(saved?.entries.map(\.provider) ?? []) == expected) } @Test(arguments: [false, true]) @@ -334,13 +408,17 @@ struct WidgetEmptyProjectionTests { return (store, settings) } - private func seed(_ store: UsageStore, providers: [UsageProvider] = [.minimax, .deepseek]) { + private func seed( + _ store: UsageStore, + providers: [UsageProvider] = [.minimax, .deepseek], + measuredAt: Date = Date(timeIntervalSince1970: 1_800_000_000)) + { for (index, provider) in providers.enumerated() { store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), secondary: nil, - updatedAt: Date(timeIntervalSince1970: 1_800_000_000 + Double(index))), + updatedAt: measuredAt.addingTimeInterval(Double(index))), provider: provider) } } diff --git a/docs/widgets.md b/docs/widgets.md index bbea482540..59fb448267 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -15,7 +15,7 @@ read_when: - WidgetKit owns the outer margins. All sizes share rendering and quota-selection rules, with overflow labels for omitted rows. Native relative-date text keeps snapshot ages and resets current between timeline reloads. Token-cost rows show their own saved age when more than ten minutes behind quota data. New usage still requires an app refresh and an accepted WidgetKit timeline. - The app writes snapshots after the main refresh pipeline and token-usage refreshes; narrow single-provider refresh paths may wait for the next snapshot write. - Claude-swap refreshes and cleared adapter state publish snapshots even when account widgets are off. When Claude-swap owns account presentation, provider widgets follow its active slot and measurement time. Missing quota can retain only that slot owner's saved reading, never ambient or another slot's quota. Local cost remains provider-wide. -- If every provider entry disappears during a failed refresh, the writer can retain its last queued entries while their providers remain enabled and preservation has not been invalidated. Measurement timestamps stay unchanged, so the widgets show the data's original age. Account invalidation keeps a queued publication retired until valid replacement usage is published. This fallback is limited to the current app session; it does not restore generic provider entries from disk across account changes or restarts. Claude keeps its existing ownership-checked preservation path. +- When a failed refresh has no usage for a provider, the writer can retain that provider's last queued entry while it remains enabled and preservation has not been invalidated. Another provider's missing, disabled, or invalidated entry does not discard eligible readings. Measurement timestamps stay unchanged, so widgets show the data's original age. Account invalidation retires only that provider's queued entry until valid replacement usage is published. This fallback is limited to the current app session; it does not restore generic provider entries from disk across account changes or restarts. Claude keeps its existing ownership-checked preservation path. - Scheduled provider refreshes trigger token/cost refreshes when their TTL permits, with a 15-minute local-history minimum (30 minutes in low-power mode). Manual disables the recurring timer; startup and pending Codex catch-up may still scan. These limits bound history work and WidgetKit reload requests without changing provider usage/status cadence. - Claude local cost/token history remains eligible for widget snapshots when its account does not expose numeric session or weekly quota data. @@ -140,6 +140,16 @@ extension and `chronod` logs. The reporter recovered by quitting only the `Codex extension process and allowing macOS to relaunch it. This is a manual diagnostic workaround, not an automatic recovery policy; restarting the main app may leave that process alive. +After an update, distinguish the installed extension from the executable already mapped by +its running process. In #2838 the reporter found an old extension mapped from a deleted +Sparkle staging directory while the installed app and extension had matching new versions. +`chronod` reported `bundleStubNotSupported` and "Bundle version did not match" before error +1050. The process command shown by `ps` and the installed `Info.plist` do not establish the +version of the running executable. Compare its mapped executable using `lsof -p ` with +the installed extension, and redact paths before sharing logs. Reload requests and a fresh +snapshot alone do not replace a stale extension process. This failure is separate from +Homebrew deleting widget placements and from a snapshot containing no provider entries. + ### 1) Verify the extension bundle exists where macOS expects it ``` APP="/Applications/CodexBar.app"