diff --git a/AGENTS.md b/AGENTS.md index 9252e210c..05aef39d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Conventions for the per-provider modules under `Sources/OpenUsage/Providers/ 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 + ) + } + } +} diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift index a91bf3b57..53181638a 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift @@ -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 diff --git a/Sources/OpenUsage/Providers/Codex/CodexProvider.swift b/Sources/OpenUsage/Providers/Codex/CodexProvider.swift index 4fdbe95d8..ff10da1be 100644 --- a/Sources/OpenUsage/Providers/Codex/CodexProvider.swift +++ b/Sources/OpenUsage/Providers/Codex/CodexProvider.swift @@ -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 diff --git a/Sources/OpenUsage/Providers/Copilot/CopilotUsageMapper.swift b/Sources/OpenUsage/Providers/Copilot/CopilotUsageMapper.swift index ab203a59a..7b0c9abd0 100644 --- a/Sources/OpenUsage/Providers/Copilot/CopilotUsageMapper.swift +++ b/Sources/OpenUsage/Providers/Copilot/CopilotUsageMapper.swift @@ -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") diff --git a/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift b/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift index f8736a981..95ad8a8db 100644 --- a/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift +++ b/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift @@ -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 diff --git a/Sources/OpenUsage/Providers/Grok/GrokProvider.swift b/Sources/OpenUsage/Providers/Grok/GrokProvider.swift index 455a6962d..c436c291a 100644 --- a/Sources/OpenUsage/Providers/Grok/GrokProvider.swift +++ b/Sources/OpenUsage/Providers/Grok/GrokProvider.swift @@ -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 diff --git a/Sources/OpenUsage/Stores/ProviderSnapshotCache.swift b/Sources/OpenUsage/Stores/ProviderSnapshotCache.swift index 6831ab185..1c4f5cdd7 100644 --- a/Sources/OpenUsage/Stores/ProviderSnapshotCache.swift +++ b/Sources/OpenUsage/Stores/ProviderSnapshotCache.swift @@ -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) diff --git a/Sources/OpenUsage/Views/RateLimitResetsDetail.swift b/Sources/OpenUsage/Views/RateLimitResetsDetail.swift index 83b6ae689..4f7c4b8e1 100644 --- a/Sources/OpenUsage/Views/RateLimitResetsDetail.swift +++ b/Sources/OpenUsage/Views/RateLimitResetsDetail.swift @@ -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. @@ -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 @@ -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 - ) - } - } } diff --git a/Tests/OpenUsageTests/LoginShellEnvironmentTests.swift b/Tests/OpenUsageTests/LoginShellEnvironmentTests.swift index 95f8f8940..72de44006 100644 --- a/Tests/OpenUsageTests/LoginShellEnvironmentTests.swift +++ b/Tests/OpenUsageTests/LoginShellEnvironmentTests.swift @@ -1,3 +1,4 @@ +import os import XCTest @testable import OpenUsage @@ -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(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) } diff --git a/Tests/OpenUsageTests/ResetDisplayTests.swift b/Tests/OpenUsageTests/ResetDisplayTests.swift index 0c28c2a06..5603fc106 100644 --- a/Tests/OpenUsageTests/ResetDisplayTests.swift +++ b/Tests/OpenUsageTests/ResetDisplayTests.swift @@ -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 @@ -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") @@ -220,7 +220,7 @@ 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") @@ -228,7 +228,7 @@ final class ResetDisplayTests: XCTestCase { } func testResetsPopoverEmptyWhenNoCredits() { - XCTAssertTrue(RateLimitResetsDetail.entries(from: [], now: Date()).isEmpty) + XCTAssertTrue(RateLimitResetsPresentation.entries(from: [], now: Date()).isEmpty) } func testCompactDurationAlwaysShowsHoursAtDayScale() { @@ -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) diff --git a/docs/debugging.md b/docs/debugging.md index 1e7c3bc9b..0f3e921d2 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -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 diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 871012cc0..c3a7fe97a 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -5,8 +5,9 @@ set -euo pipefail # to /Applications. The dev build: # - is signed with a stable Apple Development identity, so keychain/permission grants stick across # rebuilds (macOS keys those to the signing identity + bundle id, not the install location); -# - uses its own bundle id (com.robinebers.openusage.dev), so it never touches the real installed -# app's settings or keychain. To run against the real app's data instead, set BUNDLE_ID to +# - uses its own bundle id (com.robinebers.openusage.dev), isolating app preferences and +# single-instance state. Provider credential files, databases, and keychain items remain shared +# intentionally. To test the release app's settings domain, set BUNDLE_ID to # com.robinebers.openusage below; # - ships no Sparkle feed, so it never checks for or installs updates (test updates with a real # signed + notarized release build — that's the only honest way). @@ -25,7 +26,7 @@ MIN_SYSTEM_VERSION="15.0" APP_VERSION="0.7.0" APP_BUILD="0.7.0" -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" DIST_DIR="$ROOT_DIR/dist" APP_BUNDLE="$DIST_DIR/$APP_DISPLAY.app" APP_CONTENTS="$APP_BUNDLE/Contents" @@ -36,7 +37,51 @@ INFO_PLIST="$APP_CONTENTS/Info.plist" RESOURCE_BUNDLE_NAME="${TARGET_NAME}_${TARGET_NAME}.bundle" ENTITLEMENTS="$ROOT_DIR/script/OpenUsage.dev.entitlements.plist" -pkill -x "$TARGET_NAME" >/dev/null 2>&1 || true +matching_app_pids() { + local exact_path="${1:-}" + local pid executable + while IFS= read -r pid; do + [ -n "$pid" ] || continue + executable="$(ps -ww -p "$pid" -o comm= 2>/dev/null)" || continue + if [ -n "$exact_path" ]; then + [ "$executable" = "$exact_path" ] || continue + elif [[ "$executable" != *"/$APP_DISPLAY.app/Contents/MacOS/$TARGET_NAME" ]]; then + continue + fi + printf '%s\n' "$pid" + done < <(pgrep -x "$TARGET_NAME" || true) +} + +wait_for_app_exit() { + local pid pids + pids="$(matching_app_pids)" + [ -n "$pids" ] || return 0 + while IFS= read -r pid; do + kill "$pid" >/dev/null 2>&1 || true + done <<<"$pids" + for ((attempt = 0; attempt < 50; attempt++)); do + if [ -z "$(matching_app_pids)" ]; then + return 0 + fi + sleep 0.1 + done + echo "timed out waiting for the previous $APP_DISPLAY process to exit" >&2 + return 1 +} + +wait_for_app_launch() { + for ((attempt = 0; attempt < 50; attempt++)); do + if [ -n "$(matching_app_pids "$APP_BINARY")" ]; then + echo "==> running" + return 0 + fi + sleep 0.1 + done + echo "$APP_DISPLAY exited before launch verification completed" >&2 + return 1 +} + +wait_for_app_exit echo "==> swift build ($CONFIG)" swift build -c "$CONFIG" @@ -177,8 +222,7 @@ case "$MODE" in ;; verify) launch_app - sleep 1 - pgrep -x "$TARGET_NAME" >/dev/null && echo "==> running" + wait_for_app_launch ;; *) echo "usage: $0 [run|build|logs|verify]" >&2