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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Conventions for the per-provider modules under `Sources/OpenUsage/Providers/<Nam
## Running / Testing Changes

- There is no hot reload. The app is a long-lived menu-bar process, so **every code change requires a full rebuild and restart of the running app** to take effect — kill the running instance, rebuild, and relaunch before testing.
- Swift warnings are build and CI errors for both the app and test targets; keep every build warning-free.

## Pull Requests

Expand Down
6 changes: 4 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,17 @@ let package = Package(
.copy("Resources/pricing_models_dev_snapshot.json")
],
swiftSettings: [
.swiftLanguageMode(.v6)
.swiftLanguageMode(.v6),
.treatAllWarnings(as: .error)
]
),
.testTarget(
name: "OpenUsageTests",
dependencies: ["OpenUsage"],
path: "Tests/OpenUsageTests",
swiftSettings: [
.swiftLanguageMode(.v6)
.swiftLanguageMode(.v6),
.treatAllWarnings(as: .error)
]
)
]
Expand Down
56 changes: 56 additions & 0 deletions Sources/OpenUsage/Models/RateLimitResetsPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation

/// Pure presentation data for the rate-limit-resets popover. Keeping this outside the SwiftUI view
/// makes the count/expiry state machine and timeline formatting independently testable and nonisolated.
enum RateLimitResetsPresentation {
/// What the popover body renders after resolving the available count and expiry list.
enum Content: Equatable {
case timeline([Entry])
case unknownExpiries(count: Int)
case empty
}

/// One timeline node's display strings, derived from a credit's expiry instant.
struct Entry: Identifiable, Equatable {
let id: Int // 0-based row index (soonest first)
let number: Int // 1-based reset number, shown inside the dot
let severity: WidgetData.MeterSeverity
let time: String // exact expiry, e.g. "Jul 12 at 5:30 PM"; "Expiring soon" when imminent
let countdown: String? // "12d 18h"; nil when imminent (no useful countdown to show)

var accessibilityLabel: String {
"Reset \(number), \(time)" + (countdown.map { ", expires in \($0)" } ?? "")
}
}

/// Empty `expiries` is ambiguous: a genuinely empty balance (`count == 0`) shows the empty state,
/// but a positive `count` with no expiries means the dedicated expiry fetch was unavailable and the
/// row fell back to the usage-body count — show that count rather than "no resets".
static func content(count: Int, expiries: [Date], now: Date = Date()) -> Content {
let entries = entries(from: expiries, now: now)
if !entries.isEmpty { return .timeline(entries) }
if count > 0 { return .unknownExpiries(count: count) }
return .empty
}

/// Build the timeline entries from raw expiry instants: sort soonest-first, number from 1, and pair
/// each exact expiry time with its countdown. A past-due or ≤5-minute expiry can't print a useful
/// exact time or countdown, so it reads "Expiring soon" with no trailing countdown. Imminence keys
/// off the *relative* window — `Formatters.whenLabel(.relative)` collapses to `soon` at ≤5 minutes,
/// while `.absolute` only collapses once past-due — so both formats agree instead of the exact time
/// printing a wall-clock while the countdown reads "soon".
static func entries(from expiries: [Date], now: Date = Date()) -> [Entry] {
expiries.sorted().enumerated().map { index, date in
let relative = Formatters.whenLabel(at: date, mode: .relative, now: now)
let absolute = Formatters.whenLabel(at: date, mode: .absolute, now: now)
let imminent = (relative == nil || relative == Formatters.imminent)
return Entry(
id: index,
number: index + 1,
severity: WidgetData.expirySeverity(secondsRemaining: date.timeIntervalSince(now)),
time: (imminent || absolute == nil) ? "Expiring soon" : absolute!,
countdown: imminent ? nil : relative
)
}
}
}
2 changes: 1 addition & 1 deletion Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ final class ClaudeProvider: ProviderRuntime {
usageClient: ClaudeUsageClient = ClaudeUsageClient(),
logUsageScanner: ClaudeLogUsageScanner = ClaudeLogUsageScanner(),
now: @escaping @Sendable () -> Date = Date.init,
pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() }
pricing: @escaping @Sendable () async -> ModelPricing = { ModelPricingStore.shared.current() }
) {
self.authStore = authStore
self.usageClient = usageClient
Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenUsage/Providers/Codex/CodexProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ final class CodexProvider: ProviderRuntime {
usageClient: CodexUsageClient = CodexUsageClient(),
logUsageScanner: CodexLogUsageScanner = CodexLogUsageScanner(),
now: @escaping @Sendable () -> Date = Date.init,
pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() }
pricing: @escaping @Sendable () async -> ModelPricing = { ModelPricingStore.shared.current() }
) {
self.authStore = authStore
self.usageClient = usageClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,9 @@ enum CopilotUsageMapper {
return dayOnlyFormatter.date(from: raw)
}

/// `nonisolated(unsafe)` is sound: `DateFormatter` is documented thread-safe on macOS 10.9+, and the
/// formatter is never mutated after creation (same pattern as `CursorUsageCSV`/`OpenUsageISO8601`).
private nonisolated(unsafe) static let dayOnlyFormatter: DateFormatter = {
/// Foundation marks `DateFormatter` as sendable on supported macOS versions. This formatter is
/// configured once during initialization and never mutated afterward.
private static let dayOnlyFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenUsage/Providers/Cursor/CursorProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ final class CursorProvider: ProviderRuntime {
authStore: CursorAuthStore = CursorAuthStore(),
usageClient: CursorUsageClient = CursorUsageClient(),
now: @escaping @Sendable () -> Date = Date.init,
pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() }
pricing: @escaping @Sendable () async -> ModelPricing = { ModelPricingStore.shared.current() }
) {
self.authStore = authStore
self.usageClient = usageClient
Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenUsage/Providers/Grok/GrokProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class GrokProvider: ProviderRuntime {
usageClient: GrokUsageClient = GrokUsageClient(),
logUsageScanner: GrokLogUsageScanner = GrokLogUsageScanner(),
now: @escaping @Sendable () -> Date = Date.init,
pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() }
pricing: @escaping @Sendable () async -> ModelPricing = { ModelPricingStore.shared.current() }
) {
self.authStore = authStore
self.usageClient = usageClient
Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenUsage/Stores/ProviderSnapshotCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ struct ProviderSnapshotCache {
AppLog.debug(.cache, "write \(snapshot.providerID)")
// Mark this provider as written *this session* so its snapshot now satisfies the freshness gate
// (a launch-loaded snapshot never does — see `sessionWrites`).
sessionWrites.withLock { $0.insert(snapshot.providerID) }
_ = sessionWrites.withLock { $0.insert(snapshot.providerID) }
var payload = loadPayload()
payload.snapshots[snapshot.providerID] = snapshot
save(payload)
Expand Down
56 changes: 3 additions & 53 deletions Sources/OpenUsage/Views/RateLimitResetsDetail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import SwiftUI
/// are available it shows a centered empty state. Mirrors `ModelUsageDetail` / `UsageTrendDetail`'s
/// calm — header + flat body — presented via `.popover`.
struct RateLimitResetsDetail: View {
private typealias Entry = RateLimitResetsPresentation.Entry

let title: String
/// The row's "N available" count. Only used to disambiguate an empty `expiries` list: 0 → genuinely
/// no credits (empty state); > 0 → credits we have but whose expiry times weren't fetched.
Expand All @@ -24,7 +26,7 @@ struct RateLimitResetsDetail: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
switch Self.content(count: count, expiries: expiries) {
switch RateLimitResetsPresentation.content(count: count, expiries: expiries) {
case .timeline(let entries): timeline(entries)
case .unknownExpiries(let count): unknownExpiriesState(count)
case .empty: emptyState
Expand Down Expand Up @@ -149,56 +151,4 @@ struct RateLimitResetsDetail: View {
.accessibilityLabel(entry.accessibilityLabel)
}

/// What the body renders, resolved once from the count and expiry list so the "empty vs. count-only
/// vs. timeline" choice is unit-testable and can't drift between the view and its tests.
enum Content: Equatable {
case timeline([Entry])
case unknownExpiries(count: Int)
case empty
}

/// Empty `expiries` is ambiguous: a genuinely empty balance (`count == 0`) shows the empty state,
/// but a positive `count` with no expiries means the dedicated expiry fetch was unavailable and the
/// row fell back to the usage-body count — show that count rather than "no resets".
static func content(count: Int, expiries: [Date], now: Date = Date()) -> Content {
let entries = entries(from: expiries, now: now)
if !entries.isEmpty { return .timeline(entries) }
if count > 0 { return .unknownExpiries(count: count) }
return .empty
}

/// One timeline node's display strings, derived from a credit's expiry instant. Pure and static so
/// the phrasing is unit-testable without a view.
struct Entry: Identifiable, Equatable {
let id: Int // 0-based row index (soonest first)
let number: Int // 1-based reset number, shown inside the dot
let severity: WidgetData.MeterSeverity
let time: String // exact expiry, e.g. "Jul 12 at 5:30 PM"; "Expiring soon" when imminent
let countdown: String? // "12d 18h"; nil when imminent (no useful countdown to show)

var accessibilityLabel: String {
"Reset \(number), \(time)" + (countdown.map { ", expires in \($0)" } ?? "")
}
}

/// Build the timeline entries from raw expiry instants: sort soonest-first, number from 1, and pair
/// each exact expiry time with its countdown. A past-due or ≤5-minute expiry can't print a useful
/// exact time or countdown, so it reads "Expiring soon" with no trailing countdown. Imminence keys
/// off the *relative* window — `Formatters.whenLabel(.relative)` collapses to `soon` at ≤5 minutes,
/// while `.absolute` only collapses once past-due — so both formats agree instead of the exact time
/// printing a wall-clock while the countdown reads "soon".
static func entries(from expiries: [Date], now: Date = Date()) -> [Entry] {
expiries.sorted().enumerated().map { index, date in
let relative = Formatters.whenLabel(at: date, mode: .relative, now: now)
let absolute = Formatters.whenLabel(at: date, mode: .absolute, now: now)
let imminent = (relative == nil || relative == Formatters.imminent)
return Entry(
id: index,
number: index + 1,
severity: WidgetData.expirySeverity(secondsRemaining: date.timeIntervalSince(now)),
time: (imminent || absolute == nil) ? "Expiring soon" : absolute!,
countdown: imminent ? nil : relative
)
}
}
}
7 changes: 4 additions & 3 deletions Tests/OpenUsageTests/LoginShellEnvironmentTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import XCTest
@testable import OpenUsage

Expand Down Expand Up @@ -35,13 +36,13 @@ final class LoginShellEnvironmentTests: XCTestCase {
let runner = RecordingRunner(stdout: [begin, "OPENROUTER_API_KEY=sk-or-test", end].joined(separator: "\0"))
let env = LoginShellEnvironment(runner: runner)
let captured = expectation(description: "captured off-main")
var value: String?
let value = OSAllocatedUnfairLock<String?>(initialState: nil)
DispatchQueue.global().async {
value = env.value(for: "OPENROUTER_API_KEY")
value.withLock { $0 = env.value(for: "OPENROUTER_API_KEY") }
captured.fulfill()
}
wait(for: [captured], timeout: 5)
XCTAssertEqual(value, "sk-or-test")
XCTAssertEqual(value.withLock { $0 }, "sk-or-test")
XCTAssertEqual(runner.callCount, 1)
}

Expand Down
14 changes: 7 additions & 7 deletions Tests/OpenUsageTests/ResetDisplayTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ final class ResetDisplayTests: XCTestCase {
// The popover timeline sorts the credits soonest-first, numbers them from 1, and pairs each
// exact expiry time with its countdown. Per-credit dot color reuses the row's severity bands.
let now = Date(timeIntervalSince1970: 1_800_000_000)
let entries = RateLimitResetsDetail.entries(
let entries = RateLimitResetsPresentation.entries(
from: [
// Deliberately out of order to prove the sort.
now.addingTimeInterval(12 * 24 * 3600 + 18 * 3600), // ~12d18h -> blue
Expand All @@ -207,7 +207,7 @@ final class ResetDisplayTests: XCTestCase {
// wall-clock time or countdown, so it collapses to "Expiring soon" with no trailing countdown —
// matching Formatters.imminent. Its dot stays red.
let now = Date(timeIntervalSince1970: 1_800_000_000)
let entries = RateLimitResetsDetail.entries(from: [now.addingTimeInterval(-60)], now: now)
let entries = RateLimitResetsPresentation.entries(from: [now.addingTimeInterval(-60)], now: now)

XCTAssertEqual(entries.count, 1)
XCTAssertEqual(entries[0].time, "Expiring soon")
Expand All @@ -220,15 +220,15 @@ final class ResetDisplayTests: XCTestCase {
// exact time must not print a wall-clock while the countdown vanishes — both collapse to
// "Expiring soon" with no countdown.
let now = Date(timeIntervalSince1970: 1_800_000_000)
let entries = RateLimitResetsDetail.entries(from: [now.addingTimeInterval(180)], now: now)
let entries = RateLimitResetsPresentation.entries(from: [now.addingTimeInterval(180)], now: now)

XCTAssertEqual(entries.count, 1)
XCTAssertEqual(entries[0].time, "Expiring soon")
XCTAssertNil(entries[0].countdown)
}

func testResetsPopoverEmptyWhenNoCredits() {
XCTAssertTrue(RateLimitResetsDetail.entries(from: [], now: Date()).isEmpty)
XCTAssertTrue(RateLimitResetsPresentation.entries(from: [], now: Date()).isEmpty)
}

func testCompactDurationAlwaysShowsHoursAtDayScale() {
Expand All @@ -245,18 +245,18 @@ final class ResetDisplayTests: XCTestCase {
let now = Date(timeIntervalSince1970: 1_800_000_000)

// Zero credits -> the genuine empty state.
XCTAssertEqual(RateLimitResetsDetail.content(count: 0, expiries: [], now: now), .empty)
XCTAssertEqual(RateLimitResetsPresentation.content(count: 0, expiries: [], now: now), .empty)

// Credits present but no expiry list (dedicated fetch unavailable -> usage-body count fallback):
// must NOT read "no resets"; it states the count instead.
XCTAssertEqual(
RateLimitResetsDetail.content(count: 3, expiries: [], now: now),
RateLimitResetsPresentation.content(count: 3, expiries: [], now: now),
.unknownExpiries(count: 3)
)

// Expiries present -> the timeline.
let expiries = [now.addingTimeInterval(4 * 24 * 3600)]
guard case .timeline(let entries) = RateLimitResetsDetail.content(count: 1, expiries: expiries, now: now) else {
guard case .timeline(let entries) = RateLimitResetsPresentation.content(count: 1, expiries: expiries, now: now) else {
return XCTFail("expected timeline")
}
XCTAssertEqual(entries.count, 1)
Expand Down
13 changes: 10 additions & 3 deletions docs/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@ The project script owns the build/run loop. From the repo root:
./script/build_and_run.sh verify # launch and confirm the process is running
```

Every invocation asks any running `OpenUsage.app` process to exit and waits up to five seconds before
rebuilding. `verify` also polls this worktree's staged app for up to five seconds. The script ignores
unrelated commands that happen to be named `OpenUsage`, and fails loudly if either app lifecycle
transition stalls.

The script builds a signed app bundle under `dist/` and launches it in place — nothing is installed to
`/Applications`. The dev build uses its own bundle id (`com.robinebers.openusage.dev`), so it keeps its
own settings and keychain and never disturbs a released OpenUsage. It ships no update feed, so it never
checks for updates — test updates with a real signed, notarized release build.
`/Applications`. The dev build uses its own bundle id (`com.robinebers.openusage.dev`), so its app
preferences and single-instance state stay separate from the released bundle. Provider credential
files, databases, keychain items, and OpenUsage API-key files remain shared intentionally, so
authentication changes can affect provider tools or a released OpenUsage. The dev build ships no
update feed; test updates with a real signed, notarized release build.

## Stream logs

Expand Down
Loading