diff --git a/.bumper/Sources/WhereProjectRules.swift b/.bumper/Sources/WhereProjectRules.swift index 006e1783..1c4606c7 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -5,17 +5,17 @@ let whereProjectRules = RuleSet { Rules.constructionOwnership( "WhereServices", allowed: whereServicesConstructionScope, - id: "where.services_composition_ownership" + id: "where.services_composition_ownership", ) Rules.constructionOwnership( "CoreLocationSource", allowed: .files(["Where/WhereUI/Sources/Launch/WhereLaunch.swift"]), - id: "where.live_location_source_ownership" + id: "where.live_location_source_ownership", ) Rules.singleNominalSpelling( suffix: "Log", owner: whereLoggingScope, - id: "where.logging_type_ownership" + id: "where.logging_type_ownership", ) productionStoreOpeningRule checkedConcurrencyBoundaryRule @@ -46,7 +46,7 @@ private let productionStoreOpeningPaths: Set = [ private let productionStoreOpeningRule = Rules.files( "where.production_store_opening", severity: .error, - summary: "Production SwiftData stores open only at the app and share-extension composition roots." + summary: "Production SwiftData stores open only at the app and share-extension composition roots.", ) { file in functionCalls() .filter { match in @@ -59,8 +59,8 @@ private let productionStoreOpeningRule = Rules.files( message: "SwiftDataStore.make is called outside a process composition root.", evidence: ViolationEvidence( observed: "SwiftDataStore.make in \(file.path.rawValue)", - expectation: "open the production store in WhereLaunch or ShareEvidenceModel" - ) + expectation: "open the production store in WhereLaunch or ShareEvidenceModel", + ), ) } } @@ -76,7 +76,7 @@ private let documentedUnsafeConcurrencyPaths: Set = [ private let checkedConcurrencyBoundaryRule = Rules.files( "where.checked_concurrency_boundaries", severity: .error, - summary: "Unchecked concurrency escape hatches stay inside documented lifecycle boundaries." + summary: "Unchecked concurrency escape hatches stay inside documented lifecycle boundaries.", ) { file in let preconcurrencyFailures = SyntaxQuery() .filter { match in @@ -88,8 +88,8 @@ private let checkedConcurrencyBoundaryRule = Rules.files( message: "Production code uses an @preconcurrency escape hatch.", evidence: ViolationEvidence( observed: match.node.trimmedDescription, - expectation: "use checked Swift concurrency" - ) + expectation: "use checked Swift concurrency", + ), ) } @@ -105,8 +105,8 @@ private let checkedConcurrencyBoundaryRule = Rules.files( message: "nonisolated(unsafe) is outside a documented lifecycle boundary.", evidence: ViolationEvidence( observed: match.node.trimmedDescription, - expectation: "checked isolation or a documented existing boundary" - ) + expectation: "checked isolation or a documented existing boundary", + ), ) } @@ -116,7 +116,7 @@ private let checkedConcurrencyBoundaryRule = Rules.files( private let gregorianCalendarRule = Rules.files( "where.gregorian_calendar", severity: .error, - summary: "Where day and year calculations do not use the device's potentially non-Gregorian current calendar." + summary: "Where day and year calculations do not use the device's potentially non-Gregorian current calendar.", ) { file in SyntaxQuery() .filter { match in @@ -129,8 +129,8 @@ private let gregorianCalendarRule = Rules.files( message: "Where uses Calendar.current instead of an explicit Gregorian calendar.", evidence: ViolationEvidence( observed: match.node.trimmedDescription, - expectation: "an injected Gregorian calendar or Calendar.whereIntents" - ) + expectation: "an injected Gregorian calendar or Calendar.whereIntents", + ), ) } } @@ -151,7 +151,7 @@ private let whereStoreMutatingMethods: Set = [ private let storeTransactionBoundaryRule = Rules.files( "where.store_transaction_boundary", severity: .error, - summary: "WhereStore mutations occur inside the transaction owned by store.perform." + summary: "WhereStore mutations occur inside the transaction owned by store.perform.", ) { file in functionCalls() .filter { match in @@ -170,8 +170,8 @@ private let storeTransactionBoundaryRule = Rules.files( message: "WhereStore mutation occurs outside store.perform.", evidence: ViolationEvidence( observed: match.node.calledExpression.trimmedDescription, - expectation: "call the mutation from inside store.perform { ... }" - ) + expectation: "call the mutation from inside store.perform { ... }", + ), ) } } @@ -195,7 +195,7 @@ private func isInsideStorePerform(_ node: FunctionCallExprSyntax) -> Bool { private let appShortcutsProviderOwnershipRule = Rules.files( "where.app_shortcuts_provider_ownership", severity: .error, - summary: "AppShortcutsProvider conformances live in the Where app target." + summary: "AppShortcutsProvider conformances live in the Where app target.", ) { file in guard file.component.rawValue != WhereComponent.app.rawValue else { return [] } return SyntaxQuery() @@ -206,8 +206,8 @@ private let appShortcutsProviderOwnershipRule = Rules.files( message: "AppShortcutsProvider conformance is outside the Where app target.", evidence: ViolationEvidence( observed: file.path.rawValue, - expectation: "a source owned by the Where app component" - ) + expectation: "a source owned by the Where app component", + ), ) } } @@ -215,7 +215,7 @@ private let appShortcutsProviderOwnershipRule = Rules.files( private let loggingFacadeRule = Rules.files( "where.logging_facade", severity: .error, - summary: "Where production logging goes through its typed Periscope facades." + summary: "Where production logging goes through its typed Periscope facades.", ) { file in let rawLoggingImports = SyntaxQuery() .filter { $0.node.path.trimmedDescription == "OSLog" } @@ -225,8 +225,8 @@ private let loggingFacadeRule = Rules.files( message: "Where production code imports OSLog directly.", evidence: ViolationEvidence( observed: "import OSLog", - expectation: "WhereLog or RegionLog" - ) + expectation: "WhereLog or RegionLog", + ), ) } @@ -238,8 +238,8 @@ private let loggingFacadeRule = Rules.files( message: "Where production code prints directly.", evidence: ViolationEvidence( observed: "print", - expectation: "a typed WhereLog or RegionLog event" - ) + expectation: "a typed WhereLog or RegionLog event", + ), ) } @@ -254,7 +254,7 @@ private let previewCoverageRule = Rules.files( "where.preview_coverage", severity: .error, summary: "Every WhereUI or widget source file declaring a previewable component includes a #Preview.", - scope: previewScope + scope: previewScope, ) { file in let previewableDeclarations = SyntaxQuery() .filter { match in @@ -283,8 +283,8 @@ private let previewCoverageRule = Rules.files( message: "\(match.node.name.text) has no #Preview in its source file.", evidence: ViolationEvidence( observed: file.path.rawValue, - expectation: "at least one #Preview in the same file" - ) + expectation: "at least one #Preview in the same file", + ), ) } } diff --git a/.bumper/Tests/WhereArchitectureTests.swift b/.bumper/Tests/WhereArchitectureTests.swift index 783c1058..63d612b8 100644 --- a/.bumper/Tests/WhereArchitectureTests.swift +++ b/.bumper/Tests/WhereArchitectureTests.swift @@ -9,16 +9,16 @@ func `Where architecture accepts downward dependencies`() throws { files: [ SourceInput( path: "Where/WhereCore/Sources/Service.swift", - component: try ComponentID(WhereComponent.whereCore.rawValue), - source: "import RegionKit\nstruct Service {}" + component: ComponentID(WhereComponent.whereCore.rawValue), + source: "import RegionKit\nstruct Service {}", ), SourceInput( path: "Where/WhereUI/Sources/Screen.swift", - component: try ComponentID(WhereComponent.whereUI.rawValue), - source: "import WhereCore\nimport SwiftUI\nstruct Screen {}" + component: ComponentID(WhereComponent.whereUI.rawValue), + source: "import WhereCore\nimport SwiftUI\nstruct Screen {}", ), - ] - ) + ], + ), ) #expect(report.violations.isEmpty) @@ -32,11 +32,11 @@ func `RegionKit cannot depend upward on WhereCore`() throws { files: [ SourceInput( path: "Where/RegionKit/Sources/Region.swift", - component: try ComponentID(WhereComponent.regionKit.rawValue), - source: "import WhereCore\nstruct Region {}" + component: ComponentID(WhereComponent.regionKit.rawValue), + source: "import WhereCore\nstruct Region {}", ), - ] - ) + ], + ), ) let violation = try #require(report.violations.first) @@ -53,11 +53,11 @@ func `WhereUI cannot import persistence`() throws { files: [ SourceInput( path: "Where/WhereUI/Sources/Screen.swift", - component: try ComponentID(WhereComponent.whereUI.rawValue), - source: "import SwiftData\nstruct Screen {}" + component: ComponentID(WhereComponent.whereUI.rawValue), + source: "import SwiftData\nstruct Screen {}", ), - ] - ) + ], + ), ) let violation = try #require(report.violations.first) @@ -74,16 +74,16 @@ func `Where adapters cannot link Broadway directly`() throws { files: [ SourceInput( path: "Where/WhereWidgets/Sources/Widget.swift", - component: try ComponentID(WhereComponent.widgets.rawValue), - source: "import BroadwayUI\nstruct Widget {}" + component: ComponentID(WhereComponent.widgets.rawValue), + source: "import BroadwayUI\nstruct Widget {}", ), SourceInput( path: "Where/WhereIntents/Sources/Intent.swift", - component: try ComponentID(WhereComponent.whereIntents.rawValue), - source: "import BroadwayCore\nstruct Intent {}" + component: ComponentID(WhereComponent.whereIntents.rawValue), + source: "import BroadwayCore\nstruct Intent {}", ), - ] - ) + ], + ), ) #expect(report.violations.count == 2) diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index d8a06a63..a2a3c4a9 100644 --- a/.bumper/Tests/WhereProjectRulesTests.swift +++ b/.bumper/Tests/WhereProjectRulesTests.swift @@ -2,21 +2,20 @@ import BumperBowlingCore import BumperBowlingTestSupport import Testing -@Suite("Where project rules") struct WhereProjectRulesTests { @Test func `production store opens at process composition roots`() throws { let allowed = try evaluate( path: "Where/WhereUI/Sources/Launch/WhereLaunch.swift", component: .whereUI, - source: "func open() throws { _ = try SwiftDataStore.make() }" + source: "func open() throws { _ = try SwiftDataStore.make() }", ) let rejectedPath: RelativeFilePath = "Where/WhereUI/Sources/Model/CompetingStoreOwner.swift" let rejected = try evaluate( path: rejectedPath, component: .whereUI, - source: "func open() throws { _ = try SwiftDataStore.make() }" + source: "func open() throws { _ = try SwiftDataStore.make() }", ) #expect(allowed.violations.isEmpty) @@ -28,8 +27,8 @@ struct WhereProjectRulesTests { #expect( violation.evidence == ViolationEvidence( observed: "SwiftDataStore.make in \(rejectedPath.rawValue)", - expectation: "open the production store in WhereLaunch or ShareEvidenceModel" - ) + expectation: "open the production store in WhereLaunch or ShareEvidenceModel", + ), ) } @@ -38,14 +37,14 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereUI/Sources/Model/WhereSession.swift", component: .whereUI, - source: "final class Session { nonisolated(unsafe) var task: Task? }" + source: "final class Session { nonisolated(unsafe) var task: Task? }", ) let rejectedPath: RelativeFilePath = "Where/WhereUI/Sources/Model/CompetingSession.swift" let rejected = try evaluate( path: rejectedPath, component: .whereUI, - source: "final class Session { nonisolated(unsafe) var task: Task? }" + source: "final class Session { nonisolated(unsafe) var task: Task? }", ) #expect(allowed.violations.isEmpty) @@ -63,7 +62,7 @@ struct WhereProjectRulesTests { let report = try evaluate( path: path, component: .whereCore, - source: "@preconcurrency import Foundation" + source: "@preconcurrency import Foundation", ) let violation = try #require(report.violations.first) @@ -78,19 +77,19 @@ struct WhereProjectRulesTests { let core = try evaluate( path: "Where/WhereCore/Sources/WhereServices.swift", component: .whereCore, - source: "func assemble() { _ = WhereServices() }" + source: "func assemble() { _ = WhereServices() }", ) let preview = try evaluate( path: "Where/WhereUI/Sources/Preview/PreviewSupport.swift", component: .whereUI, - source: "func preview() { _ = WhereServices() }" + source: "func preview() { _ = WhereServices() }", ) let rejectedPath: RelativeFilePath = "Where/WhereIntents/Sources/CompetingServices.swift" let rejected = try evaluate( path: rejectedPath, component: .whereIntents, - source: "func assemble() { _ = WhereServices() }" + source: "func assemble() { _ = WhereServices() }", ) #expect(core.violations.isEmpty) @@ -106,14 +105,14 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereUI/Sources/Launch/WhereLaunch.swift", component: .whereUI, - source: "func assemble() { _ = CoreLocationSource() }" + source: "func assemble() { _ = CoreLocationSource() }", ) let rejectedPath: RelativeFilePath = "Where/WhereIntents/Sources/CompetingLocationSource.swift" let rejected = try evaluate( path: rejectedPath, component: .whereIntents, - source: "func assemble() { _ = CoreLocationSource() }" + source: "func assemble() { _ = CoreLocationSource() }", ) #expect(allowed.violations.isEmpty) @@ -128,14 +127,14 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereUI/Sources/Logging/ScreenLog.swift", component: .whereUI, - source: "enum ScreenLog {}" + source: "enum ScreenLog {}", ) let rejectedPath: RelativeFilePath = "Where/WhereUI/Sources/Model/ScreenLog.swift" let rejected = try evaluate( path: rejectedPath, component: .whereUI, - source: "enum ScreenLog {}" + source: "enum ScreenLog {}", ) #expect(allowed.violations.isEmpty) @@ -150,26 +149,26 @@ struct WhereProjectRulesTests { let intentsAllowed = try evaluate( path: "Where/WhereIntents/Sources/CalendarUse.swift", component: .whereIntents, - source: "let calendar = Calendar.whereIntents" + source: "let calendar = Calendar.whereIntents", ) let uiAllowed = try evaluate( path: "Where/WhereUI/Sources/CalendarUse.swift", component: .whereUI, - source: "let calendar = Calendar(identifier: .gregorian)" + source: "let calendar = Calendar(identifier: .gregorian)", ) let intentsRejectedPath: RelativeFilePath = "Where/WhereIntents/Sources/DriftingCalendar.swift" let intentsRejected = try evaluate( path: intentsRejectedPath, component: .whereIntents, - source: "let calendar = Calendar.current" + source: "let calendar = Calendar.current", ) let uiRejectedPath: RelativeFilePath = "Where/WhereUI/Sources/DriftingCalendar.swift" let uiRejected = try evaluate( path: uiRejectedPath, component: .whereUI, - source: "let calendar = Calendar.current" + source: "let calendar = Calendar.current", ) #expect(intentsAllowed.violations.isEmpty) @@ -181,8 +180,8 @@ struct WhereProjectRulesTests { #expect( intentsViolation.evidence == ViolationEvidence( observed: "Calendar.current", - expectation: "an injected Gregorian calendar or Calendar.whereIntents" - ) + expectation: "an injected Gregorian calendar or Calendar.whereIntents", + ), ) let uiViolation = try #require(uiRejected.violations.first) #expect(uiRejected.violations.count == 1) @@ -195,14 +194,14 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereCore/Sources/Journal.swift", component: .whereCore, - source: "func save() async throws { try await store.perform { try await store.add(sample: sample) } }" + source: "func save() async throws { try await store.perform { try await store.add(sample: sample) } }", ) let rejectedPath: RelativeFilePath = "Where/WhereCore/Sources/CompetingWriter.swift" let rejected = try evaluate( path: rejectedPath, component: .whereCore, - source: "func save() async throws { try await store.add(sample: sample) }" + source: "func save() async throws { try await store.add(sample: sample) }", ) #expect(allowed.violations.isEmpty) @@ -217,14 +216,14 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/Where/Sources/WhereShortcuts.swift", component: .app, - source: "struct WhereShortcuts: AppShortcutsProvider {}" + source: "struct WhereShortcuts: AppShortcutsProvider {}", ) let rejectedPath: RelativeFilePath = "Where/WhereIntents/Sources/WhereShortcuts.swift" let rejected = try evaluate( path: rejectedPath, component: .whereIntents, - source: "struct WhereShortcuts: AppShortcutsProvider {}" + source: "struct WhereShortcuts: AppShortcutsProvider {}", ) #expect(allowed.violations.isEmpty) @@ -239,17 +238,17 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereCore/Sources/Worker.swift", component: .whereCore, - source: "func run() { WhereLog.root(WorkerLog.self) { .completed } }" + source: "func run() { WhereLog.root(WorkerLog.self) { .completed } }", ) let printRejected = try evaluate( path: "Where/WhereCore/Sources/PrintingWorker.swift", component: .whereCore, - source: "func run() { print(\"done\") }" + source: "func run() { print(\"done\") }", ) let osLogRejected = try evaluate( path: "Where/WhereUI/Sources/LoggingScreen.swift", component: .whereUI, - source: "import OSLog\nstruct LoggingScreen {}" + source: "import OSLog\nstruct LoggingScreen {}", ) #expect(allowed.violations.isEmpty) @@ -262,19 +261,19 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereUI/Sources/PreviewedView.swift", component: .whereUI, - source: "struct PreviewedView: View {}\n#Preview { PreviewedView() }" + source: "struct PreviewedView: View {}\n#Preview { PreviewedView() }", ) let rejectedPath: RelativeFilePath = "Where/WhereUI/Sources/UnpreviewedView.swift" let rejected = try evaluate( path: rejectedPath, component: .whereUI, - source: "struct UnpreviewedView: View {}" + source: "struct UnpreviewedView: View {}", ) let coreView = try evaluate( path: "Where/WhereCore/Sources/DomainView.swift", component: .whereCore, - source: "struct DomainView: View {}" + source: "struct DomainView: View {}", ) #expect(allowed.violations.isEmpty) @@ -288,12 +287,12 @@ struct WhereProjectRulesTests { private func evaluate( path: RelativeFilePath, component: WhereComponent, - source: String + source: String, ) throws -> RuleReport { try RuleTestHarness(whereProjectRules).evaluate( VirtualRepository { VirtualSourceFile.swift(path, component: component, source: source) - } + }, ) } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f5935cb..f15a9217 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,3 +188,41 @@ jobs: path: diagnostics if-no-files-found: warn retention-days: 7 + + test-macos: + name: Build & Test (macOS) + needs: format + # Same image as the other jobs so every target builds against one toolchain. + runs-on: xcode-27 + # The macOS-only Ledger scheme is self-contained and fast (no simulator, no + # LFS), so this cap is well clear of a normal run. + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v3 + # The workspace mixes iOS targets and the native-macOS Ledger targets, and + # no single xcodebuild destination can build both — so the macOS scheme + # runs here, in parallel with the iOS `test` job, rather than adding to it. + - name: Build & Test (macOS) + run: mise exec -- tuist test Ledger-macOS-Tests --no-selective-testing -- -destination 'platform=macOS' + # Tests can crash the host process on CI without leaving any error in + # Tuist's summarized log. On failure, capture crash reports + the .xcresult + # bundle (which holds the per-test crash backtraces) and upload them. + - name: Collect test diagnostics + if: ${{ failure() }} + run: | + mkdir -p diagnostics/crashes diagnostics/xcresult + cp -R "$HOME/Library/Logs/DiagnosticReports/." diagnostics/crashes/ 2>/dev/null || true + find "$HOME/Library/Developer/Xcode/DerivedData" -maxdepth 6 -name '*.xcresult' \ + -exec cp -R {} diagnostics/xcresult/ \; 2>/dev/null || true + cp -R "$HOME/.local/state/tuist/sessions" diagnostics/tuist-sessions 2>/dev/null || true + echo '--- collected diagnostics ---' + find diagnostics -maxdepth 3 | head -200 + - name: Upload test diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-diagnostics-macos + path: diagnostics + if-no-files-found: warn + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 2fb2377e..468ae1b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,8 +202,8 @@ that nothing in this repo depends on a skill having been loaded. bundles, read [`Package.swift`](Package.swift) and [`Project.swift`](Project.swift); each module's own `README.md` / `AGENTS.md` says what it is and how it may be used. -- Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). -- **CI scheme**: CI runs the explicit shared **Stuff-iOS-Tests** scheme (all test bundles) rather than the autogenerated `Stuff-Workspace` scheme. New test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them. +- Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper; native-macOS test bundles are declared directly, like `LedgerCoreTests`, since that helper hosts iOS bundles in StuffTestHost). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). +- **CI schemes**: CI runs explicit shared schemes rather than the autogenerated `Stuff-Workspace` scheme. **Stuff-iOS-Tests** covers the iOS bundles, and **Ledger-macOS-Tests** (the Ledger app + `LedgerCoreTests`) runs in its own `test-macos` job — the workspace mixes iOS targets with the native-macOS **Ledger** ones, and no single xcodebuild destination can build both. A new test bundle must be added to the matching scheme in `Project.swift` or CI won't run it. - **Image snapshots are the exception: one bundle per module, one shared scheme**: each module that owns image references has its own `*SnapshotTests` target (currently **WhereUISnapshotTests**, **PeriscopeToolsSnapshotTests**, **SwiftDataInspectorSnapshotTests**), and they are all listed in the single shared **StuffSnapshotTests** scheme, which a dedicated CI `snapshot` job runs in one invocation. They are slow and LFS-backed, so they stay deliberately **out of** `Stuff-iOS-Tests`. Reference images under any `__Snapshots__/` directory are stored in **Git LFS** (see `.gitattributes`); the CI job checks out with `lfs: true`. The framework halves live in `Shared/SnapshotKit` (shippable matrix + previews) and `Shared/SnapshotKitTesting` (test-only capture/compare pipeline). The pipeline's own regression tests are a *separate* bundle, **SnapshotKitTestingTests**, which pixel-probes captures (no LFS references) and so runs in the normal `Stuff-iOS-Tests` scheme / `test` job. - **A new image suite gets a target, not a scheme.** Add a `*SnapshotTests` target over the module's `SnapshotTests/` directory, list only `SnapshotKitTesting` in `extraPackageProducts`, and add it to the `StuffSnapshotTests` scheme's build and test target lists — never a scheme or CI job of its own. A module's image bundle should link only what that module needs, which is the point of splitting them: the Periscope and SwiftDataInspector suites don't build against WhereUI at all. References follow the sources automatically, since swift-snapshot-testing derives the `__Snapshots__` directory from the calling file's `#filePath`. - **Why separate snapshot bundles are safe — and what would make them unsafe.** Each `.xctest` statically embeds its own copy of `SnapshotKitTesting`, whose capture state is module-global and therefore per copy: the safe-area swizzle's depth counter and override globals, `SnapshotCaptureLock`, and the `UIView` category it installs. Two such copies **in one process** would count separate depths against the single shared `UIView` method exchange (captures silently rendering with the simulator's real safe-area insets), and neither lock would see the other's captures. That doesn't happen because **xcodebuild gives each test bundle its own `StuffTestHost` process** — measured on Xcode 27 by probing `ProcessInfo.processIdentifier` from two bundles in one scheme, which reported different PIDs on both a filtered and a full unfiltered run. This is the load-bearing assumption behind the layout: if a toolchain ever starts sharing one host process across bundles, re-measure before adding another image bundle. @@ -249,16 +249,19 @@ authority on whether a given duplication is harmful, not the general claim. ## Deployment -Platforms and minimum OS live in [`Project.swift`](Project.swift). To get the -app onto a connected iPhone without the Xcode UI, use +Platforms and minimum OS live in [`Project.swift`](Project.swift) — the iOS +targets and the native-macOS **Ledger** app, which is why the package declares +both platforms. To get the app onto a connected iPhone without the Xcode UI, use [`./Where/install`](Where/install) — macOS-only, and it needs a signing team configured once via `./ide --team-id` (see [`Where/AGENTS.md`](Where/AGENTS.md#installing-to-a-device)). +[`./Ledger/install`](Ledger/install) is the equivalent for Ledger: it builds a +Release and installs it to `/Applications` (ad-hoc signed, no team needed). ## Per-module docs Shared modules live under `Shared/`, feature modules under a top-level folder -per feature (`Where/`). **Every module is a folder containing `Sources/`, +per feature (`Where/`, `Ledger/`). **Every module is a folder containing `Sources/`, `Tests/`, `README.md`, and `AGENTS.md`** (apps additionally carry `Resources/`), and a new module must add both docs: @@ -751,4 +754,6 @@ mise install ./swiftformat --lint mise exec -- tuist test Stuff-iOS-Tests --no-selective-testing -- \ -destination "platform=iOS Simulator,id=$(./simulator --os 27.0)" +mise exec -- tuist test Ledger-macOS-Tests --no-selective-testing -- \ + -destination 'platform=macOS' ``` diff --git a/Ledger/Ledger/AGENTS.md b/Ledger/Ledger/AGENTS.md new file mode 100644 index 00000000..00bbb93f --- /dev/null +++ b/Ledger/Ledger/AGENTS.md @@ -0,0 +1,56 @@ +# Ledger – Module Shape + +Ledger is the native-macOS menu bar app that displays your current-cycle Cursor +spend. It is a thin SwiftUI/AppKit shell over [`LedgerCore`](../LedgerCore), +which does all the fetching and modeling; see [`README.md`](README.md) for the +narrative. + +This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns the +build system, formatting, and global conventions. Read that first. + +## Scope & dependencies + +- Depends only on **LedgerCore** (plus SwiftUI/AppKit). It's the app target in + [`Project.swift`](../../Project.swift) (`.mac` destination, `com.stuff.ledger`, + `LSUIElement`), paired with the hostless `LedgerCoreTests` bundle in the same + file and driven by the `Ledger` / `Ledger-macOS-Tests` schemes. +- No behavior lives here — persistence, networking, and domain rules are all in + LedgerCore. This target renders `LoadState` and routes intents. + +## Architecture + +- `LedgerApp` + `AppDelegate` — the `NSStatusItem` + `NSPopover` shell + (deliberately AppKit, not `MenuBarExtra`). The status item hosts a SwiftUI + `MenuBarLabel` (a click-through `NSHostingView`) bound to the observable + session, so the amount updates itself and gets the numeric-text transition; + the app delegate only sizes the item to the label's reported width. The + SwiftUI `Settings` scene hosts `SettingsView`. +- `LedgerSession` — the thin `@Observable` facade over `LedgerServices`: views + read its mirrored state and call its intent methods (`refresh`, + `setManualToken`, …); it owns the Core root. +- `SpendView` — the popover; renders the single `LoadState` (current-cycle + spend, today/this-week deltas, included-usage, top models). A failed refresh + keeps the loaded data and shows a stale "Updated…" warning (`session.isStale`) + rather than the error screen. +- `SettingsView` — a System-Settings-style sidebar (General + Account panes); + Account shows the auto-detect status and an optional pasted-token override. +- `CurrencyFormat` — the one place spend is formatted as USD. + +## Invariants + +- **The menu-bar amount is driven by observation, not polling.** `MenuBarLabel` + binds to the observable session and re-renders itself; don't add a timer that + writes the status title. +- **Auth is mostly zero-config.** Ledger auto-detects the Cursor session; the + Account pane's token field is an *optional override* that commits on an + explicit button and goes straight to the Keychain via `session.setManualToken` + — never mirror it into `@AppStorage` or the config JSON. +- **No `Binding(get:set:)`.** Bind to the observable session/settings; use + local `@State` drafts for the explicit-commit fields. + +## Testing + +The app target has no test bundle of its own — logic is tested in +`LedgerCoreTests`. `PreviewSupport` (DEBUG) builds sessions from +`ScriptedDashboardProvider` + `StubTokenSource` + `InMemoryKeychainStore`, so +previews never hit the network, the Keychain, or Cursor's local state. diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md new file mode 100644 index 00000000..c9810d9c --- /dev/null +++ b/Ledger/Ledger/README.md @@ -0,0 +1,95 @@ +# Ledger + +A macOS **menu bar app** that shows your current-cycle Cursor spend at a +glance. The status item displays the cycle-to-date dollar amount; clicking it +opens a popover with this cycle's spend, the included-usage breakdown, your +plan, and the top models by usage. + +All behavior lives in [`LedgerCore`](../LedgerCore); this target is the thin +SwiftUI/AppKit shell. + +## Running + +```bash +./ide --no-open # regenerate the Xcode project +mise exec -- tuist build Ledger +``` + +or run the `Ledger` scheme from Xcode. The app lives in the menu bar (showing +the current-cycle amount once loaded) and shows no Dock icon (`LSUIElement`). +It keeps running until you quit it from the popover. + +### Install to /Applications + +To run it standalone (no Xcode), use the install script — it builds a Release +build, installs it to `/Applications`, and launches it: + +```bash +Ledger/install # build, install to /Applications, and launch +Ledger/install --no-open # build and install without launching +``` + +The app is ad-hoc code-signed (no Apple Developer account needed) and built +locally (no Gatekeeper quarantine). Re-run it to update your installed copy; it +quits any running instance first. + +## Setup + +If you're signed in to the Cursor app, **there's nothing to configure** — Ledger +auto-detects your session from Cursor's local state. The Account pane shows +"Using your signed-in Cursor session". + +If auto-detect can't find a session (or it expired), paste a token in +**Settings › Account**: on `cursor.com`, open DevTools › Application › Cookies, +copy `WorkosCursorSessionToken`, and paste it. It's stored in your Keychain and +overrides auto-detect until you clear it. + +The **General** pane has *Launch Ledger at login* (via `SMAppService`), with a +shortcut to System Settings if macOS needs you to approve the login item. + +## What it shows + +- **Menu-bar title** — the current billing cycle's usage-based spend, refreshed + automatically every 5 minutes (configurable in Settings › General). Opening + the popover just shows the latest fetched state; it doesn't trigger a network + request. +- **Popover** — this cycle's spend and date range, **today** and **this week** + spend (differenced from locally recorded history — hidden until enough exists), + your plan tier, an + **included usage** as two side-by-side bars (first-party/Auto and + third-party/API — a single blended figure would hide that one pool can be + maxed while the other is barely used), **top models + this cycle** as usage shares (each model ≥5% gets its own bar; smaller ones + roll into a single multi-colored "Other models" bar with a legend), and when + it last updated. A **Refresh** button forces an immediate fetch (including the + model breakdown, which the automatic refresh only re-walks every 15 minutes + since it costs several requests). If a refresh + fails (e.g. you go offline) the last figures stay on screen and the "Updated…" + caption turns into an amber stale warning rather than blanking. The full error + screen (with a shortcut to Settings) shows only before anything has loaded — + no session yet, an expired session, or a first-load network failure. + +There's intentionally no year-to-date total: the monthly-invoice endpoint is a +billing ledger with cross-month credit/adjustment lines, so summing it isn't a +meaningful "spend this year" (see `LedgerCore`'s README). + +The per-model rows are shown as **relative shares**, not dollars: their summed +cost (from `get-filtered-usage-events`) is total usage value — more than the +billed on-demand headline (by the included allowance) — so showing dollars +alongside the headline would look like they don't add up. + +## Design notes + +- The status item and popover are **AppKit** (`NSStatusItem` + `NSPopover`), not + `MenuBarExtra`: the menu-bar title mirrors observable model state via an + `Observations` loop, which the AppKit path drives reliably. (The old Foreman + menu-bar app landed on the same pattern for the same reason.) +- Data comes from Cursor's **undocumented dashboard API** (the same endpoints + the website calls), so it can change without notice. + +## Limitations + +- Reuses your Cursor **web session**; when it expires you re-open Cursor (or + paste a fresh token). +- On a plan with usage-based pricing off, the `$` figures reflect the value of + included compute, not money owed. diff --git a/Ledger/Ledger/Sources/CurrencyFormat.swift b/Ledger/Ledger/Sources/CurrencyFormat.swift new file mode 100644 index 00000000..d031884e --- /dev/null +++ b/Ledger/Ledger/Sources/CurrencyFormat.swift @@ -0,0 +1,16 @@ +import Foundation + +/// USD formatting for spend figures. +enum CurrencyFormat { + /// A full currency string, e.g. `$1,234.56` — used in the popover. + static func dollars(_ dollars: Double) -> String { + dollars.formatted(.currency(code: "USD")) + } + + /// A glanceable amount for the menu bar: the popover figure with the cents + /// dropped (truncated, so it never reads higher than the real amount and + /// shares its visible digits — `$3,662.77` → `$3,662`). + static func menuBar(_ dollars: Double) -> String { + dollars.rounded(.down).formatted(.currency(code: "USD").precision(.fractionLength(0))) + } +} diff --git a/Ledger/Ledger/Sources/LedgerApp.swift b/Ledger/Ledger/Sources/LedgerApp.swift new file mode 100644 index 00000000..3d94d043 --- /dev/null +++ b/Ledger/Ledger/Sources/LedgerApp.swift @@ -0,0 +1,109 @@ +import AppKit +import LedgerCore +import SwiftUI + +/// The status item and popover are AppKit (not `MenuBarExtra`) on purpose (the +/// same reason the old Foreman app landed here). The status item hosts a small +/// SwiftUI `MenuBarLabel` so the amount gets the numeric-text roll-over and +/// updates itself from the observable session. See the module README. +@main +struct LedgerApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + // The only SwiftUI scene: the standard Settings window (Cmd-, / the + // popover's Settings button). The menu-bar UI itself is AppKit-managed. + Settings { + SettingsView(session: appDelegate.session) + } + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + let session = LedgerSession() + + private var statusItem: NSStatusItem? + private var popover: NSPopover? + + func applicationDidFinishLaunching(_: Notification) { + session.start() + + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem = item + guard let button = item.button else { return } + button.target = self + button.action = #selector(togglePopover) + button.setAccessibilityTitle("Cursor spend") + + // Host the SwiftUI label inside the status button. It's click-through + // (see ClickThroughHostingView) so the button still receives the click + // that toggles the popover. A variable-length item doesn't auto-size to + // a hosted view, so the label reports its width and we set the item's + // length to match (otherwise the amount is clipped to just the icon). + let label = + ClickThroughHostingView(rootView: MenuBarLabel(session: session) { [weak self] width in + self?.statusItem?.length = ceil(width) + }) + label.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: button.leadingAnchor), + label.trailingAnchor.constraint(equalTo: button.trailingAnchor), + label.topAnchor.constraint(equalTo: button.topAnchor), + label.bottomAnchor.constraint(equalTo: button.bottomAnchor), + ]) + } + + /// Stop the refresh loop on quit. + func applicationWillTerminate(_: Notification) { + session.stop() + } + + /// Status-item click: dismiss the popover when it's open, otherwise show it + /// anchored to the button. Deliberately does not fetch — see below. + @objc private func togglePopover() { + guard let button = statusItem?.button else { return } + let popover = popover ?? makePopover() + self.popover = popover + + if popover.isShown { + popover.performClose(nil) + return + } + // Don't fetch on open — the periodic refresh loop keeps both the title + // and the popover current; opening just shows the latest state. Use the + // popover's Refresh button to force an immediate fetch. + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + // The popover's own window must become key for text fields / buttons to + // take input reliably. + popover.contentViewController?.view.window?.makeKey() + } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: SpendView(session: session), + ) + return popover + } +} + +/// An `NSHostingView` that never claims mouse hits, so the SwiftUI content it +/// draws is display-only and the enclosing status-item button keeps receiving +/// the click that toggles the popover. +private final class ClickThroughHostingView: NSHostingView { + override func hitTest(_: NSPoint) -> NSView? { + nil + } + + required init(rootView: Content) { + super.init(rootView: rootView) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("not used") + } +} diff --git a/Ledger/Ledger/Sources/LedgerSession.swift b/Ledger/Ledger/Sources/LedgerSession.swift new file mode 100644 index 00000000..25d90912 --- /dev/null +++ b/Ledger/Ledger/Sources/LedgerSession.swift @@ -0,0 +1,138 @@ +import Foundation +import LedgerCore +import Observation + +/// Thin app-side facade over the LedgerCore model tree. Views read the +/// observable ``LedgerServices`` state (and bind its settings) directly; this +/// class owns the root and forwards the few app-level intents (launch, refresh, +/// quit teardown, token edits). +@MainActor +@Observable +final class LedgerSession { + let services: LedgerServices + + /// The spend-load state the popover renders. + var loadState: LedgerServices.LoadState { + services.loadState + } + + /// When the last successful fetch completed (for the "updated …" caption). + var lastUpdated: Date? { + services.lastUpdated + } + + /// Whether a fetch is in flight (drives the header spinner without clearing + /// the shown data). + var isRefreshing: Bool { + services.isRefreshing + } + + /// Whether the shown data is stale — the last refresh failed but prior data + /// is still displayed. + var isStale: Bool { + services.loadError != nil + } + + /// Why the data is stale, for a tooltip on the warning. + var staleMessage: String? { + services.loadError?.message + } + + /// Whether a token was pasted (a manual override) vs. auto-detected. + var hasManualToken: Bool { + services.hasManualToken + } + + /// Whether a token can be auto-detected from the local Cursor app. + var autoTokenAvailable: Bool { + services.autoTokenAvailable + } + + /// Settings; `SettingsView` binds these observable properties. + var settings: LedgerSettings { + services.settings + } + + /// How often spend is auto-refreshed, in seconds. `SettingsView` binds this + /// two-way; the change is persisted in Core and picked up by the refresh + /// loop on its next cycle. + var refreshInterval: TimeInterval { + get { services.settings.refreshInterval } + set { services.settings.refreshInterval = newValue } + } + + /// Whether Ledger launches at login. `SettingsView` binds this two-way. + var startsAtLogin: Bool { + get { services.startsAtLogin } + set { services.startsAtLogin = newValue } + } + + var loginItemNeedsApproval: Bool { + services.loginItemNeedsApproval + } + + var loginItemError: String? { + services.loginItemError + } + + /// The status-bar title: the current-cycle dollar amount once loaded, and a + /// `$—` placeholder until then (so the item always shows something findable + /// in the menu bar). Observed by the app delegate. + var statusTitle: String { + switch services.loadState { + case let .loaded(snapshot): + CurrencyFormat.menuBar(snapshot.currentCycleDollars) + case .idle, .loading, .failed: + "$—" + } + } + + init(services: LedgerServices) { + self.services = services + } + + convenience init() { + self.init(services: LedgerServices()) + } + + func start() { + services.start() + } + + func stop() { + services.stop() + } + + /// Fetches spend now (the manual Refresh button, or after a token edit). + /// Forces the per-model breakdown too — this is explicit user intent, so it + /// bypasses the throttle the periodic refresh respects. + func refresh() { + Task { await services.refresh(force: true) } + } + + /// Stores (or clears, for an empty string) a pasted session token, then refreshes. + func setManualToken(_ token: String) throws { + try services.setManualToken(token) + refresh() + } + + /// Removes any pasted token (falls back to auto-detection), then refreshes. + func clearManualToken() throws { + try services.clearManualToken() + refresh() + } + + func refreshLoginItemStatus() { + services.refreshLoginItemStatus() + } + + /// Re-reads the credential sources so the Account pane reflects reality — + /// the user can sign in/out of Cursor while Ledger runs. + func refreshTokenStatus() { + services.refreshTokenStatus() + } + + func openSystemSettingsLoginItems() { + services.openSystemSettingsLoginItems() + } +} diff --git a/Ledger/Ledger/Sources/MenuBarLabel.swift b/Ledger/Ledger/Sources/MenuBarLabel.swift new file mode 100644 index 00000000..c6fae50b --- /dev/null +++ b/Ledger/Ledger/Sources/MenuBarLabel.swift @@ -0,0 +1,28 @@ +import LedgerCore +import SwiftUI + +/// The SwiftUI content hosted in the status item: just the current-cycle +/// amount (no icon — the "$" in the text already identifies it, and a +/// dollar-sign glyph beside it read as redundant), with the numeric-text +/// content transition so digit changes roll over like the popover's figure. +/// Bound to the observable session, it re-renders itself when the amount +/// changes — no manual title updates needed. +/// +/// A hosted view doesn't auto-size a variable-length `NSStatusItem`, so the +/// label reports its rendered width via `onWidthChange`; the app delegate sets +/// the item's length to match (otherwise the amount is clipped). +struct MenuBarLabel: View { + let session: LedgerSession + let onWidthChange: (CGFloat) -> Void + + var body: some View { + Text(session.statusTitle) + .monospacedDigit() + .contentTransition(.numericText()) + .font(.system(size: 13)) + .padding(.horizontal, 4) + .fixedSize() + .animation(.default, value: session.statusTitle) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { onWidthChange($0) } + } +} diff --git a/Ledger/Ledger/Sources/PreviewSupport.swift b/Ledger/Ledger/Sources/PreviewSupport.swift new file mode 100644 index 00000000..990071c7 --- /dev/null +++ b/Ledger/Ledger/Sources/PreviewSupport.swift @@ -0,0 +1,58 @@ +#if DEBUG + import Foundation + @_spi(Testing) import LedgerCore + + /// Preview fixtures. Sessions are backed by a stub token source and a + /// scripted dashboard provider — they never read the real Cursor state, + /// touch the network, or write Application Support. + @MainActor + enum PreviewSupport { + /// A session already showing spend. + static func loadedSession() -> LedgerSession { + let provider = ScriptedDashboardProvider( + .success( + summary: .fixture( + onDemandCents: 315_609, + membershipType: "ultra", + includedUsed: 40000, + includedLimit: 40000, + autoPercentUsed: 0.69, + apiPercentUsed: 100, + ), + ), + events: UsageEventFixture.events([ + "claude-opus-4-8-thinking-xhigh": 28929, + "claude-fable-5-thinking-xhigh": 21082, + "claude-opus-5-thinking-high": 16800, + "composer-2.5-fast": 8008, + "github_bugbot": 606, + ]), + ) + let session = session( + provider: provider, + autoToken: SessionToken(cookieValue: "user_preview::jwt"), + ) + session.refresh() + return session + } + + private static func session( + provider: any DashboardProvider, + autoToken: SessionToken?, + ) -> LedgerSession { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("LedgerPreview-\(UUID().uuidString)") + let store = LedgerConfigStore(directory: base) + try? store.save(LedgerConfiguration(refreshInterval: 900)) + let services = LedgerServices( + configStore: store, + keychain: InMemoryKeychainStore(), + tokenSource: StubTokenSource(token: autoToken), + provider: provider, + loginItem: LoginItemController(), + historyStore: SpendHistoryStore(directory: base), + ) + return LedgerSession(services: services) + } + } +#endif diff --git a/Ledger/Ledger/Sources/SettingsView.swift b/Ledger/Ledger/Sources/SettingsView.swift new file mode 100644 index 00000000..44b54299 --- /dev/null +++ b/Ledger/Ledger/Sources/SettingsView.swift @@ -0,0 +1,233 @@ +import AppKit +import LedgerCore +import SwiftUI + +/// A pane in the settings sidebar: static identity/metadata plus its own +/// content view, built from the session. Conformers are ordinary `View`s; +/// `SettingsView` discovers their title/icon and builds them without a central +/// enum or switch. +private protocol SettingsPane: View { + /// Sidebar label and navigation title. + static var title: String { get } + /// Sidebar SF Symbol. + static var icon: String { get } + + init(session: LedgerSession) +} + +/// Type-erased sidebar entry: a pane's metadata plus a builder for its content. +/// Keyed by the pane type's `ObjectIdentifier`, so the sidebar selection stays +/// a typed token rather than a stringly value. +private struct SettingsPaneItem: Identifiable { + let id: ObjectIdentifier + let title: String + let icon: String + let makeView: (LedgerSession) -> AnyView + + init(_ pane: (some SettingsPane).Type) { + id = ObjectIdentifier(pane) + title = pane.title + icon = pane.icon + makeView = { AnyView(pane.init(session: $0)) } + } +} + +/// Global settings, laid out like a modern macOS System Settings window: a +/// `NavigationSplitView` sidebar over the registered ``SettingsPaneItem``s. +struct SettingsView: View { + let session: LedgerSession + + private let panes: [SettingsPaneItem] = [ + SettingsPaneItem(GeneralSettingsPane.self), + SettingsPaneItem(AccountSettingsPane.self), + ] + + @State private var selection: ObjectIdentifier? + + var body: some View { + NavigationSplitView(columnVisibility: .constant(.all)) { + List(panes, selection: $selection) { pane in + Label(pane.title, systemImage: pane.icon) + } + .navigationSplitViewColumnWidth(min: 170, ideal: 190, max: 220) + .toolbar(removing: .sidebarToggle) + } detail: { + detail + } + .frame(minWidth: 460, minHeight: 360) + .onAppear { + if selection == nil { selection = panes.first?.id } + } + } + + private var detail: AnyView { + let pane = panes.first { $0.id == selection } ?? panes.first + return pane?.makeView(session) ?? AnyView(EmptyView()) + } +} + +/// General preferences: whether Ledger launches at login. +private struct GeneralSettingsPane: SettingsPane { + static let title = "General" + static let icon = "gearshape" + + @Bindable var session: LedgerSession + + @State private var isWindowVisible = true + + init(session: LedgerSession) { + _session = Bindable(session) + } + + /// Auto-refresh cadence options, in seconds. + private let refreshOptions: [(label: String, seconds: TimeInterval)] = [ + ("Every minute", 60), + ("Every 5 minutes", 5 * 60), + ("Every 15 minutes", 15 * 60), + ("Every 30 minutes", 30 * 60), + ("Every hour", 60 * 60), + ] + + var body: some View { + Form { + Toggle("Launch Ledger at login", isOn: $session.startsAtLogin) + Text( + "Ledger starts automatically when you log in and keeps your Cursor spend in the menu bar.", + ) + .font(.caption) + .foregroundStyle(.secondary) + + Picker("Refresh", selection: $session.refreshInterval) { + ForEach(refreshOptions, id: \.seconds) { option in + Text(option.label).tag(option.seconds) + } + } + Text( + "How often Ledger fetches your latest spend. The Refresh button in the popover updates it immediately.", + ) + .font(.caption) + .foregroundStyle(.secondary) + + if session.loginItemNeedsApproval { + LabeledContent { + Button("Open Login Items") { + session.openSystemSettingsLoginItems() + } + } label: { + Label( + "Approval needed in System Settings", + systemImage: "exclamationmark.triangle.fill", + ) + .foregroundStyle(.orange) + } + } + + if let error = session.loginItemError { + Label(error, systemImage: "xmark.octagon.fill") + .font(.callout) + .foregroundStyle(.red) + } + } + .formStyle(.grouped) + .navigationTitle(Self.title) + .onAppear { session.refreshLoginItemStatus() } + .background(WindowVisibilityReader(isVisible: $isWindowVisible)) + .onChange(of: isWindowVisible) { _, visible in + if visible { session.refreshLoginItemStatus() } + } + } +} + +/// Account settings: how Ledger authenticates to the Cursor dashboard. It +/// auto-detects the session from your local Cursor app; a pasted token is an +/// optional override for when that isn't available (or has expired). +private struct AccountSettingsPane: SettingsPane { + static let title = "Account" + static let icon = "person.crop.circle" + + let session: LedgerSession + + @State private var tokenDraft: String = "" + @State private var tokenError: String? + + init(session: LedgerSession) { + self.session = session + } + + var body: some View { + Form { + Section("Cursor session") { + if session.hasManualToken { + Label("Using a pasted session token", systemImage: "key.fill") + .foregroundStyle(.secondary) + } else if session.autoTokenAvailable { + Label("Using your signed-in Cursor session", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + } else { + Label( + "No Cursor session found — sign in to Cursor, or paste a token below", + systemImage: "exclamationmark.triangle.fill", + ) + .foregroundStyle(.orange) + } + } + + Section("Session token (optional)") { + SecureField("Session token", text: $tokenDraft, prompt: Text( + session.hasManualToken ? "•••••••• (stored)" : "Paste WorkosCursorSessionToken", + )) + HStack { + Button(session.hasManualToken ? "Update Token" : "Save Token") { saveToken() } + .disabled(tokenDraft.trimmingCharacters(in: .whitespaces).isEmpty) + if session.hasManualToken { + Button("Clear Token", role: .destructive) { clearToken() } + } + } + if let tokenError { + Label(tokenError, systemImage: "xmark.octagon.fill") + .font(.callout) + .foregroundStyle(.red) + } + Text( + "Only needed if auto-detect fails. Stored securely in your Keychain. Copy the " + + + "WorkosCursorSessionToken cookie from cursor.com (DevTools › Application › Cookies).", + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .navigationTitle(Self.title) + // The Cursor session can change while Ledger runs (signing in or out), + // and the token is otherwise read only on demand — so re-read it + // whenever this pane comes on screen. + .onAppear { session.refreshTokenStatus() } + } + + private func saveToken() { + do { + try session.setManualToken(tokenDraft) + tokenDraft = "" + tokenError = nil + } catch { + tokenError = "Couldn't save the token: \(error.localizedDescription)" + } + } + + private func clearToken() { + do { + try session.clearManualToken() + tokenDraft = "" + tokenError = nil + } catch { + tokenError = "Couldn't clear the token: \(error.localizedDescription)" + } + } +} + +#if DEBUG + #Preview { + SettingsView(session: PreviewSupport.loadedSession()) + } +#endif diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift new file mode 100644 index 00000000..0b834fdb --- /dev/null +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -0,0 +1,379 @@ +import LedgerCore +import SwiftUI + +/// The menu-bar popover: the current billing cycle's Cursor spend, today's and +/// this week's spend, the included-usage pools, and the per-model breakdown, +/// plus a footer with Refresh, Settings, and Quit. Renders the single +/// ``LedgerServices/LoadState`` — spinner, error, or value — so the three +/// states can never overlap. +struct SpendView: View { + @Bindable var session: LedgerSession + + /// Models at or above this share get their own bar; the rest are rolled up. + private static let rollupThreshold = 0.05 + + /// Distinct colors assigned to models in share order (cycled if exhausted). + private static let palette: [Color] = [ + .blue, + .green, + .orange, + .purple, + .pink, + .teal, + .indigo, + .yellow, + .red, + .mint, + ] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + Divider() + content + Divider() + footer + } + .padding(16) + .frame(width: 300) + } + + private var header: some View { + HStack(spacing: 8) { + Text("Cursor Spend") + .font(.headline) + Spacer() + if session.isRefreshing { + ProgressView() + .controlSize(.small) + } + if let plan = planLabel { + Text(plan) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(.secondary.opacity(0.15)) + .clipShape(.capsule) + } + } + } + + /// The plan tier for the header badge, shown once loaded. + private var planLabel: String? { + if case let .loaded(snapshot) = session.loadState { + snapshot.membershipType.capitalized + } else { + nil + } + } + + @ViewBuilder + private var content: some View { + switch session.loadState { + case .idle, .loading: + placeholder + case let .loaded(snapshot): + loaded(snapshot) + case let .failed(error): + failure(error) + } + } + + private var placeholder: some View { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + .frame(height: 60) + } + + private func loaded(_ snapshot: SpendSnapshot) -> some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(CurrencyFormat.dollars(snapshot.currentCycleDollars)) + .font(.system(size: 34, weight: .semibold, design: .rounded)) + .monospacedDigit() + .contentTransition(.numericText(value: snapshot.currentCycleDollars)) + .animation(.default, value: snapshot.currentCycleDollars) + deltas(snapshot.deltas) + if let range = cycleRange(snapshot) { + Text(range) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + if snapshot.autoFractionUsed != nil || snapshot.apiFractionUsed != nil { + includedUsage(auto: snapshot.autoFractionUsed, api: snapshot.apiFractionUsed) + } + + if !snapshot.modelShares.isEmpty { + models(snapshot.modelShares) + } + } + } + + /// Today's and this-week's spend, derived from local history. Shows only + /// the figures that have enough history to be meaningful. + @ViewBuilder + private func deltas(_ deltas: SpendDeltas) -> some View { + if deltas.todayDollars != nil || deltas.thisWeekDollars != nil { + HStack(spacing: 12) { + if let today = deltas.todayDollars { + deltaLabel(today, label: "today") + } + if let week = deltas.thisWeekDollars { + deltaLabel(week, label: "this week") + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func deltaLabel(_ dollars: Double, label: String) -> some View { + HStack(spacing: 2) { + Image(systemName: "arrow.up") + .imageScale(.small) + Text("\(CurrencyFormat.dollars(dollars)) \(label)") + .monospacedDigit() + } + } + + /// Included allowance shown as two side-by-side gauges — first-party (Auto) + /// and third-party (API) — since a single blended figure hides that one + /// pool can be exhausted while the other is barely touched. + private func includedUsage(auto: Double?, api: Double?) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("Included usage") + .font(.callout) + .foregroundStyle(.secondary) + HStack(alignment: .top, spacing: 12) { + if let auto { + usageGauge(label: "First-party", fraction: auto, color: .teal) + } + if let api { + usageGauge(label: "API", fraction: api, color: .blue) + } + } + } + } + + private func usageGauge(label: String, fraction: Double, color: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(label) + .lineLimit(1) + Spacer() + Text(fraction.formatted(.percent.precision(.fractionLength(0)))) + .monospacedDigit() + } + .font(.caption2) + .foregroundStyle(.secondary) + ShareBar(segments: [ShareSegment(color: color, fraction: fraction)]) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Models this cycle: each model with ≥5% share gets its own bar; the rest + /// roll into a single multi-colored "Other models" bar (one segment per + /// model), with a compact legend. + private func models(_ shares: [ModelShare]) -> some View { + let colored = shares.enumerated().map { index, share in + ColoredShare( + name: share.name, + fraction: share.fraction, + color: Self.palette[index % Self.palette.count], + ) + } + let majors = colored.filter { $0.fraction >= Self.rollupThreshold } + let minors = colored.filter { $0.fraction < Self.rollupThreshold } + let minorsTotal = minors.reduce(0) { $0 + $1.fraction } + + return VStack(alignment: .leading, spacing: 6) { + Text("Top models this cycle") + .font(.caption) + .foregroundStyle(.secondary) + + ForEach(majors) { model in + shareRow( + fraction: model.fraction, + segments: [ShareSegment(color: model.color, fraction: model.fraction)], + ) { + ModelLabel(name: ModelName.parse(model.name)) + } + } + + if !minors.isEmpty { + shareRow( + fraction: minorsTotal, + segments: minors.map { ShareSegment(color: $0.color, fraction: $0.fraction) }, + ) { + Text("Other models") + .lineLimit(1) + .truncationMode(.middle) + } + // Legend so the rolled-up colors are identifiable. + ForEach(minors) { model in + HStack(spacing: 6) { + Circle() + .fill(model.color) + .frame(width: 7, height: 7) + ModelLabel(name: ModelName.parse(model.name)) + Spacer() + Text(model.fraction.formatted(.percent.precision(.fractionLength(0)))) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + + private func shareRow( + fraction: Double, + segments: [ShareSegment], + @ViewBuilder label: () -> some View, + ) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + label() + Spacer() + Text(fraction.formatted(.percent.precision(.fractionLength(0)))) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .font(.caption) + ShareBar(segments: segments) + } + } + + private func cycleRange(_ snapshot: SpendSnapshot) -> String? { + guard let start = snapshot.cycleStart, let end = snapshot.cycleEnd else { return nil } + let formatter = Date.FormatStyle.dateTime.month(.abbreviated).day() + return "\(start.formatted(formatter)) – \(end.formatted(formatter))" + } + + private func failure(_ error: LedgerServices.LoadError) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label(error.message, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.secondary) + if error == .missingCredentials || error == .notAuthenticated { + SettingsLink { + Text("Open Settings") + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var footer: some View { + HStack { + Button { + session.refresh() + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Refresh") + if let updated = session.lastUpdated { + HStack(spacing: 3) { + if session.isStale { + Image(systemName: "exclamationmark.triangle.fill") + } + Text("Updated \(updated.formatted(date: .omitted, time: .shortened))") + } + .font(.caption2) + .foregroundStyle(session.isStale ? Color.orange : Color.secondary) + .help(session.isStale ? (session.staleMessage ?? "Couldn't refresh") : "") + } + Spacer() + SettingsLink { + Image(systemName: "gearshape") + } + .help("Settings") + Button { + NSApp.terminate(nil) + } label: { + Image(systemName: "power") + } + .help("Quit Ledger") + } + .buttonStyle(.borderless) + } +} + +/// Renders a parsed ``ModelName`` — the friendly name plus small badge chips +/// (reasoning effort, speed, mode). Adopts the ambient font for the name; the +/// badges stay compact. +private struct ModelLabel: View { + let name: ModelName + + var body: some View { + HStack(spacing: 4) { + Text(name.displayName) + .lineLimit(1) + .truncationMode(.tail) + ForEach(name.badges, id: \.self) { badge in + Text(badge) + .font(.system(size: 9, weight: .semibold)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(.secondary.opacity(0.2), in: .capsule) + .foregroundStyle(.secondary) + } + } + } +} + +/// A model paired with its display color for the models breakdown. +private struct ColoredShare: Identifiable { + let name: String + let fraction: Double + let color: Color + + var id: String { + name + } +} + +/// One colored slice of a ``ShareBar`` (its width is `fraction` of the track). +private struct ShareSegment { + let color: Color + let fraction: Double +} + +/// A rounded track filled with one or more colored segments, each sized as a +/// fraction (0...1) of the full width. A single segment reads as a simple bar; +/// several read as a stacked breakdown. +private struct ShareBar: View { + let segments: [ShareSegment] + + var body: some View { + GeometryReader { geometry in + HStack(spacing: 1) { + ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in + segment.color + .frame(width: max( + 0, + geometry.size.width * min(max(segment.fraction, 0), 1), + )) + } + } + } + .frame(height: 6) + .background(Color.secondary.opacity(0.15)) + .clipShape(.capsule) + } +} + +#if DEBUG + #Preview { + SpendView(session: PreviewSupport.loadedSession()) + } +#endif diff --git a/Ledger/Ledger/Sources/WindowVisibilityReader.swift b/Ledger/Ledger/Sources/WindowVisibilityReader.swift new file mode 100644 index 00000000..266519c4 --- /dev/null +++ b/Ledger/Ledger/Sources/WindowVisibilityReader.swift @@ -0,0 +1,61 @@ +import AppKit +import SwiftUI + +/// Reports whether the hosting window is actually on screen — visible and +/// not fully occluded — into a binding. Ordered-out windows keep their +/// SwiftUI hierarchy alive (`.task` loops keep running; verified +/// empirically), so views doing periodic work need this signal to pause. +/// +/// Use as a `.background`; the represented view has no size or appearance. +struct WindowVisibilityReader: NSViewRepresentable { + @Binding var isVisible: Bool + + func makeNSView(context _: Context) -> ReaderView { + ReaderView { visible in + if visible != isVisible { + isVisible = visible + } + } + } + + func updateNSView(_: ReaderView, context _: Context) {} + + final class ReaderView: NSView { + private let onChange: (Bool) -> Void + private var observer: NSObjectProtocol? + + init(onChange: @escaping (Bool) -> Void) { + self.onChange = onChange + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("not used") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let observer { + NotificationCenter.default.removeObserver(observer) + self.observer = nil + } + guard let window else { return } + observer = NotificationCenter.default.addObserver( + forName: NSWindow.didChangeOcclusionStateNotification, + object: window, + queue: .main, + ) { [weak self] notification in + guard let window = notification.object as? NSWindow else { return } + self?.onChange(window.occlusionState.contains(.visible)) + } + onChange(window.occlusionState.contains(.visible)) + } + + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + } +} diff --git a/Ledger/LedgerCore/AGENTS.md b/Ledger/LedgerCore/AGENTS.md new file mode 100644 index 00000000..85dc3d96 --- /dev/null +++ b/Ledger/LedgerCore/AGENTS.md @@ -0,0 +1,99 @@ +# LedgerCore – Module Shape + +LedgerCore is the model layer for the Ledger menu bar app: a tree of +`@MainActor @Observable` objects rooted in `LedgerServices` that fetches the +current-cycle Cursor spend from Cursor's +undocumented dashboard API and reduces it to one observable `LoadState`. The +SwiftUI/AppKit layer lives in the app target ([`Ledger/Ledger`](../Ledger)) and +binds the tree directly; see [`README.md`](README.md) for the narrative and +per-type detail. + +``` +LedgerServices ── LedgerSettings (refresh interval) + ├────────── SessionTokenSource ── CursorLocalTokenSource (state.vscdb, read-only) + ├────────── KeychainStore (a pasted token override) + ├────────── DashboardProvider ── CursorDashboardAPI (usage-summary, get-filtered-usage-events) + └────────── LoginItemController (SMAppService) +``` + +This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns the +build system, formatting, and global conventions. Read that first. + +## Scope & dependencies + +- **Foundation + Observation + Security + ServiceManagement + SQLite3 + LogKit + only.** No SwiftUI, no AppKit UI — views and the thin session facade belong to + the app target. LedgerCore is the repo's only macOS-only package library + (`.macOS(.v26)` in [`Package.swift`](../../Package.swift)). +- The hostless macOS test bundle `LedgerCoreTests` is declared directly in + [`Project.swift`](../../Project.swift) and runs via the `Ledger-macOS-Tests` + scheme. + +## Invariants + +- **One `LoadState`, never a mix.** Success, failure, loading, and "not loaded + yet" are the four cases of `LedgerServices.LoadState` — the UI reads exactly + one. +- **Auth is a session cookie, not an API key.** The cookie value must be + `"::"`; `SessionToken` derives the `userId` from the JWT `sub` + when given a bare JWT (as the local Cursor app stores it). A raw JWT alone is + a guaranteed 401 — never send it un-prefixed. +- **Token precedence: pasted overrides auto.** A Keychain token wins; otherwise + the local Cursor session (`CursorLocalTokenSource`) is used. No token at all + is `LoadError.missingCredentials`. +- **Read Cursor's `state.vscdb` read-only.** Open with `SQLITE_OPEN_READONLY` + and never write/lock it — Cursor may hold it open. Any failure (missing file, + missing key, locked) degrades to "no auto-token" (`nil`), not a throw. +- **`onDemand.used` is "this cycle"; there is no year-to-date total.** The + `get-monthly-invoice` endpoint is a billing ledger with cross-month + adjustments (negative "mid-month usage paid for " credits) whose + contents shift as billing settles, so summing months is not a meaningful + yearly spend (it can go negative). Don't reintroduce a summed YTD. +- **Today/this-week spend is differenced from local history, not the API.** + `onDemand.used` is a cycle-cumulative running total, so `SpendHistory` diffs + recorded `SpendSample`s (baseline scoped to the current cycle) to get + per-window spend — real billed dollars, unlike the per-model usage figures. + A window with no baseline returns `nil` (hidden), never a guessed number; + deltas clamp at 0. History persistence (`SpendHistoryStore`) is best-effort. +- **Per-model usage is a dollar-free share, from the fresh per-event endpoint.** + `ModelShare.shares(from:)` sums `get-filtered-usage-events`' per-event + `chargedCents` per model (the aggregated endpoint is stale for some accounts + and omits recent models). That summed cost is *total usage value* (included + allowance + on-demand), which exceeds the billed on-demand headline, so + `ModelShare` carries only a fraction — never present it as spend next to the + headline. `LedgerServices.cycleEvents` paginates the cycle (capped, + newest-first, no `teamId`), which costs several requests, so it is + **throttled** (`modelRefreshInterval`) rather than refetched at the headline + cadence — the cache is reused in between, and bypassed only by an explicit + `refresh(force: true)` or a cycle rollover. Best-effort — a failure logs and + keeps the last good breakdown. +- **Only the newest refresh may mutate state.** `refresh` stamps a generation + and everything after the fetch — recording history included — runs behind the + `generation == requestGeneration` guard. A superseded response recording + history would append an older reading at a later timestamp and skew future + day/week baselines. +- **Failures are observable, never swallowed.** Transport/HTTP/decode failures + become a typed `DashboardError`, mapped into a `LoadError` and logged; 401 + maps to `.notAuthenticated` (expired session). A slow response superseded by a + newer fetch is dropped via the request-generation counter. +- **A failed refresh keeps prior data (stale), not blanks it.** If spend is + already `.loaded`, a failure keeps the last snapshot on screen and surfaces on + `loadError` (the UI shows a stale "Updated…" warning); only a failure with + nothing loaded yet becomes `LoadState.failed`. Success clears `loadError`. +- **The login item is OS-owned, not persisted config** (see + `LoginItemController`); `startsAtLogin` is a live read whose setter keeps the + observed value honest and surfaces failures on `loginItemError`. +- **No secrets in JSON.** `LedgerConfiguration` persists only the refresh + interval; a pasted token lives in the Keychain, the auto-token in Cursor's own + store. + +## Testing + +Swift Testing in [`Tests/`](Tests), hostless on macOS (`tuist test +LedgerCoreTests -- -destination 'platform=macOS'`). Shared fixtures live in +[`LedgerCoreTestSupport.swift`](Tests/LedgerCoreTestSupport.swift). The network, +token-source, and Keychain seams use the module's `@_spi(Testing)` DEBUG doubles +(`ScriptedDashboardProvider`, `StubTokenSource`, `InMemoryKeychainStore`); the +`CursorLocalTokenSource` suite builds a throwaway SQLite file, and other +filesystem tests use unique temp directories — never the user's real state, +Application Support, or Keychain. diff --git a/Ledger/LedgerCore/README.md b/Ledger/LedgerCore/README.md new file mode 100644 index 00000000..68aa2811 --- /dev/null +++ b/Ledger/LedgerCore/README.md @@ -0,0 +1,99 @@ +# LedgerCore + +The model layer for the **Ledger** menu bar app: it fetches your current +Cursor billing-cycle spend from the same +undocumented dashboard endpoints the `cursor.com/dashboard/usage` page uses, +authenticated with your Cursor **session token**. The SwiftUI/AppKit shell +lives in the [`Ledger`](../Ledger) app target and binds this tree directly. + +## What it does + +- Resolves a session token — **auto-detected** from your local Cursor app + (`state.vscdb` → `cursorAuth/accessToken`), or a value you **paste** into + Settings (stored in the Keychain, which overrides auto-detect). +- Calls `GET /api/usage-summary` for the current cycle's dates, plan type, and + live usage-based spend, and `POST /api/dashboard/get-filtered-usage-events` + (paginated over the cycle) for the per-model breakdown (best-effort). +- Reduces it all to one observable `LoadState` (`idle` / `loading` / + `loaded(SpendSnapshot)` / `failed(LoadError)`). + +Works for **individual** accounts (no team/Admin API needed). + +## Authentication + +The dashboard uses WorkOS session cookies. The cookie value must be +`"::"`; the Cursor app stores only the raw JWT, so +`SessionToken(rawToken:)` derives the `userId` from the JWT's `sub` claim +(`auth0|user_ABC` → `user_ABC`) and builds the cookie. A bare JWT is rejected +by the API (HTTP 401) — hence the prefix. + +`CursorLocalTokenSource` reads Cursor's `state.vscdb` (a SQLite key-value store) +**read-only**. Nothing is written or locked; a missing file/key is simply "no +auto-token", surfaced as `LoadError.missingCredentials`. + +## Public API + +- `LedgerServices` — the `@MainActor @Observable` root: `loadState`, + `lastUpdated`, `hasManualToken`, `autoTokenAvailable`, `settings`, + `startsAtLogin`, `refresh()`, `setManualToken(_:)` / `clearManualToken()`, + `start()` / `stop()`. +- `SessionToken` / `SessionTokenSource` / `CursorLocalTokenSource` — the auth + seam. +- `DashboardProvider` + `CursorDashboardAPI` — the network seam. +- `ModelName` — parses a raw model id (`claude-opus-4-8-thinking-xhigh`, + `github_bugbot`, …) into a friendly `displayName` + `badges` (effort/speed/mode). +- `UsageSummary`, `UsageEvent`/`UsageEventsPage`, `SpendSnapshot` — the wire + view models + (cents are integers). +- `KeychainStore` / `SystemKeychainStore` — a pasted token's storage. +- `LedgerSettings` / `LedgerConfiguration` / `LedgerConfigStore` — the persisted + refresh interval (no secrets). +- `LoginItemController` — launch-at-login via `SMAppService`. +- `LedgerLog` — the LogKit logging facade (subsystem `com.stuff.ledger`). + +## How the figures are computed + +- **This cycle** = `usage-summary` → `individualUsage.onDemand.used` (cents), + the live usage-based spend. +- **Today / this week** = differences of the cumulative `onDemand.used` across + locally recorded samples (`SpendSample` / `SpendHistoryStore` / `SpendHistory`). + Because that value is a server-side running total, the difference between two + samples is real billed spend for the interval — even across times the app + wasn't running — as long as a sample exists near the window's start. Baselines + are scoped to the current cycle; each figure is `nil` (hidden) until there's + enough history. The API itself exposes no per-range billed figure, so this + local differencing is the only reliable way to get it. +- There is deliberately **no year-to-date total**: the `get-monthly-invoice` + endpoint is a billing ledger with cross-month adjustments (negative + "mid-month usage paid for " credit lines) whose contents shift as + billing settles, so summing months doesn't yield a meaningful "spend this + year" (it can even go negative). Rather than show a wrong number, Ledger omits + it. + +- **Model shares** = per-event `chargedCents` from `get-filtered-usage-events` + (paginated over the cycle), summed per model, each shown as a **share** of the + total (all models, highest first; the UI rolls sub-5% shares into one bar). + Deliberately dollar-free: that summed cost is *total usage value* (included + allowance + on-demand), so it exceeds the billed on-demand headline and must + not be presented as spend. (The older `get-aggregated-usage-events` was + dropped — it goes stale and omits recently released models.) Best-effort — a + failure logs and keeps the last good breakdown rather than failing the load. + + Walking every event costs several paginated requests, so this fetch is + **throttled to at most every 15 minutes** instead of running at the headline + refresh cadence (which can be as fast as once a minute). The cached breakdown + is reused in between; the popover's **Refresh** button forces a fresh fetch, + as does a new billing cycle. + +All money is cents. Note: on a plan with usage-based pricing off, these `$` +figures reflect included-compute value, not money owed. + +## Testing + +Swift Testing in [`Tests/`](Tests), hostless on macOS (`tuist test +LedgerCoreTests -- -destination 'platform=macOS'`). Shared fixtures live in +[`LedgerCoreTestSupport.swift`](Tests/LedgerCoreTestSupport.swift). The network, +token source, and Keychain are behind protocol seams, so `ScriptedDashboardProvider`, +`StubTokenSource`, and `InMemoryKeychainStore` (all `@_spi(Testing)`, DEBUG-only) +drive the suites without real HTTP, `state.vscdb`, or Keychain access. The +`CursorLocalTokenSource` suite builds a throwaway SQLite file to exercise the +real reader. diff --git a/Ledger/LedgerCore/Sources/DashboardProvider.swift b/Ledger/LedgerCore/Sources/DashboardProvider.swift new file mode 100644 index 00000000..b2832b07 --- /dev/null +++ b/Ledger/LedgerCore/Sources/DashboardProvider.swift @@ -0,0 +1,156 @@ +import Foundation + +/// A failure talking to the Cursor dashboard API. Kept transport-shaped; +/// ``LedgerServices`` maps it into its user-facing ``LedgerServices/LoadError``. +public enum DashboardError: Error, Equatable, Sendable { + /// HTTP 401 — the session token is missing, malformed, or expired. + case notAuthenticated + /// Another non-2xx HTTP response, carrying the status code. + case http(Int) + /// The transport failed (offline, DNS, TLS, timeout). + case network(String) + /// A 2xx response whose body didn't decode. + case decode(String) +} + +/// The seam between ``LedgerServices`` and Cursor's dashboard API. Production +/// uses ``CursorDashboardAPI``; tests conform ``ScriptedDashboardProvider``. +public protocol DashboardProvider: Sendable { + /// The current billing cycle's usage summary. + func usageSummary(token: SessionToken) async throws -> UsageSummary + /// One page of individual usage events in `[startDate, endDate]` (newest + /// first). Pages are 1-based. + func usageEvents( + startDate: Date, + endDate: Date, + page: Int, + pageSize: Int, + token: SessionToken, + ) async throws -> UsageEventsPage +} + +/// The production ``DashboardProvider``: the same undocumented endpoints the +/// `cursor.com/dashboard/usage` page calls, authenticated with the +/// `WorkosCursorSessionToken` cookie. +public struct CursorDashboardAPI: DashboardProvider { + private let baseURL: URL + private let session: URLSession + + /// The dashboard's origin. + public static let defaultBaseURL = URL(string: "https://cursor.com")! + + private static let logger = LedgerLog.dashboard + + /// A session dedicated to the dashboard API: ephemeral with **no cookie + /// storage**, because auth is the `Cookie` header we set explicitly. Left + /// to the shared session, URLSession's own cookie handling could store a + /// `Set-Cookie` from a response and then send *that* in place of the token + /// we resolved — an intermittent 401 that would read as "session expired" + /// with no trace of the substitution. The timeout is well under the 60s + /// default since a per-model fetch chains several requests. + public static func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.httpCookieAcceptPolicy = .never + configuration.timeoutIntervalForRequest = 30 + return URLSession(configuration: configuration) + } + + public init( + baseURL: URL = CursorDashboardAPI.defaultBaseURL, + session: URLSession = CursorDashboardAPI.makeSession(), + ) { + self.baseURL = baseURL + self.session = session + } + + public func usageSummary(token: SessionToken) async throws -> UsageSummary { + let request = makeRequest( + path: "/api/usage-summary", + method: "GET", + token: token, + body: nil, + ) + return try await send(request, decoding: UsageSummary.self) + } + + public func usageEvents( + startDate: Date, + endDate: Date, + page: Int, + pageSize: Int, + token: SessionToken, + ) async throws -> UsageEventsPage { + // Dates are epoch milliseconds. Note: this endpoint must NOT be sent a + // `teamId` for an individual account — including it 401s. + let body = try JSONSerialization.data(withJSONObject: [ + "startDate": Int(startDate.timeIntervalSince1970 * 1000), + "endDate": Int(endDate.timeIntervalSince1970 * 1000), + "page": page, + "pageSize": pageSize, + ]) + let request = makeRequest( + path: "/api/dashboard/get-filtered-usage-events", + method: "POST", + token: token, + body: body, + ) + return try await send(request, decoding: UsageEventsPage.self) + } + + private func makeRequest( + path: String, + method: String, + token: SessionToken, + body: Data?, + ) -> URLRequest { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = method + // Our `Cookie` header is the only credential; never let URLSession + // substitute a stored one (see `makeSession()`). + request.httpShouldHandleCookies = false + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue("https://cursor.com", forHTTPHeaderField: "Origin") + request.setValue("https://cursor.com/dashboard?tab=usage", forHTTPHeaderField: "Referer") + request.setValue( + "WorkosCursorSessionToken=\(token.cookieValue)", + forHTTPHeaderField: "Cookie", + ) + request.httpBody = body + return request + } + + private func send( + _ request: URLRequest, + decoding _: Response.Type, + ) async throws -> Response { + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + Self.logger.error("Dashboard request transport failed: \(error.localizedDescription)") + throw DashboardError.network(error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw DashboardError.network("Non-HTTP response") + } + if http.statusCode == 401 { + throw DashboardError.notAuthenticated + } + guard (200 ..< 300).contains(http.statusCode) else { + Self.logger.error("Dashboard request returned HTTP \(http.statusCode)") + throw DashboardError.http(http.statusCode) + } + + do { + return try JSONDecoder().decode(Response.self, from: data) + } catch { + Self.logger.error("Dashboard response failed to decode: \(error.localizedDescription)") + throw DashboardError.decode(error.localizedDescription) + } + } +} diff --git a/Ledger/LedgerCore/Sources/KeychainStore.swift b/Ledger/LedgerCore/Sources/KeychainStore.swift new file mode 100644 index 00000000..d2c7c2e9 --- /dev/null +++ b/Ledger/LedgerCore/Sources/KeychainStore.swift @@ -0,0 +1,110 @@ +import Foundation +import Security + +/// A failure reading or writing the Keychain. Wraps the raw `OSStatus` so a +/// caller can log something actionable rather than swallowing the error. +public struct KeychainError: LocalizedError, Equatable, Sendable { + public let status: OSStatus + public init(status: OSStatus) { + self.status = status + } + + public var errorDescription: String? { + let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error" + return "\(message) (OSStatus \(status))" + } +} + +/// Stores a single secret string (a pasted Cursor session token) securely. +/// The seam is a protocol so tests use an in-memory fake — the real Keychain +/// isn't available in a hostless test process without a signed, entitled host. +public protocol KeychainStore: Sendable { + /// The stored secret, or `nil` when nothing is stored. Throws + /// ``KeychainError`` on an unexpected Keychain failure (a missing item is + /// `nil`, not an error). + func read() throws -> String? + /// Stores `secret`, replacing any existing value. Passing an empty or + /// whitespace-only string removes the item. + func write(_ secret: String) throws + /// Removes the stored secret, if any. + func remove() throws +} + +/// The production ``KeychainStore``: a generic-password item in the login +/// Keychain, keyed by `service`/`account`. Ledger isn't sandboxed (like the +/// old Foreman app), so it reaches the default login Keychain without a +/// keychain-access-group entitlement. +public struct SystemKeychainStore: KeychainStore { + private let service: String + private let account: String + + /// Defaults to the app's bundle-style service and a fixed account name; + /// there is only ever one secret (a pasted session token). + public init(service: String = "com.stuff.ledger", account: String = "session-token") { + self.service = service + self.account = account + } + + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + public func read() throws -> String? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let data = item as? Data, + let string = String(data: data, encoding: .utf8) + else { + return nil + } + return string + case errSecItemNotFound: + return nil + default: + throw KeychainError(status: status) + } + } + + public func write(_ secret: String) throws { + let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + try remove() + return + } + + let data = Data(trimmed.utf8) + let attributes: [String: Any] = [kSecValueData as String: data] + + let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) + switch updateStatus { + case errSecSuccess: + return + case errSecItemNotFound: + var addQuery = baseQuery + addQuery[kSecValueData as String] = data + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw KeychainError(status: addStatus) + } + default: + throw KeychainError(status: updateStatus) + } + } + + public func remove() throws { + let status = SecItemDelete(baseQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError(status: status) + } + } +} diff --git a/Ledger/LedgerCore/Sources/LedgerConfigStore.swift b/Ledger/LedgerCore/Sources/LedgerConfigStore.swift new file mode 100644 index 00000000..da947d43 --- /dev/null +++ b/Ledger/LedgerCore/Sources/LedgerConfigStore.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Everything Ledger persists as JSON: just the refresh interval. Identity +/// comes from the Cursor session token (auto-detected or pasted), and any +/// pasted token lives in the Keychain — never in this plaintext file. +public struct LedgerConfiguration: Codable, Equatable, Sendable { + /// Seconds between automatic refreshes. + public var refreshInterval: TimeInterval + + public init(refreshInterval: TimeInterval) { + self.refreshInterval = refreshInterval + } + + /// The configuration a fresh install starts from: refreshing every 5 + /// minutes. + public static let initial = LedgerConfiguration(refreshInterval: 5 * 60) +} + +/// Loads and saves the ``LedgerConfiguration`` JSON file. +/// +/// A missing file is the legitimate first-launch state and loads as +/// ``LedgerConfiguration/initial``; a file that exists but can't be read or +/// decoded is corrupt and `load()` throws rather than silently resetting. +public struct LedgerConfigStore: Sendable { + private let fileURL: URL + + /// A store whose config file is `configuration.json` inside `directory` + /// (created on first save). + public init(directory: URL) { + fileURL = directory.appendingPathComponent("configuration.json") + } + + /// The production store, under + /// `~/Library/Application Support/com.stuff.ledger/`. The path is + /// deterministic (Ledger is not sandboxed), so this can't fail; the + /// directory itself is created on first save. + public static func applicationSupport() -> LedgerConfigStore { + let base = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + return LedgerConfigStore(directory: base.appendingPathComponent("com.stuff.ledger")) + } + + public func load() throws -> LedgerConfiguration { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return .initial + } + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode(LedgerConfiguration.self, from: data) + } + + public func save(_ configuration: LedgerConfiguration) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(configuration).write(to: fileURL, options: .atomic) + } +} diff --git a/Ledger/LedgerCore/Sources/LedgerLog.swift b/Ledger/LedgerCore/Sources/LedgerLog.swift new file mode 100644 index 00000000..96c4b72d --- /dev/null +++ b/Ledger/LedgerCore/Sources/LedgerLog.swift @@ -0,0 +1,47 @@ +import PeriscopeCore + +/// Phantom root event naming Ledger's log scope tree. It is never emitted — its +/// only job is to give ``LedgerLog``'s root `Log` the scope name `"Ledger"`, so +/// every event sits under one filterable subtree in the process-wide +/// `Periscope.shared` system. +public struct LedgerRoot: LogEvent { + public static let eventName = "Ledger" + + public var message: String { + "" + } +} + +/// Central logging facade for the Ledger menu bar app. +/// +/// Every logger derives from the one `"Ledger"` root `Log` and emits into the +/// process-wide Periscope system, so the app's events form a filterable subtree. +/// Ledger logs freeform diagnostics (a failed fetch, an unreadable config), so +/// its loggers emit plain messages rather than structured events — see +/// `WhereLog` for the typed-leaf pattern to follow if a payload ever needs to be +/// queryable. +/// +/// Scopes are a typed enum rather than raw strings so a new logger can't +/// silently typo into an untracked scope. +public enum LedgerLog { + /// The `"Ledger"` root every logger descends from. + public static let root = Log(system: .shared) + + /// The model tree — `LedgerServices` and its collaborators (config, history, + /// token resolution). + public static let services = scope(.services) + + /// The Cursor dashboard API client. + public static let dashboard = scope(.dashboard) + + private static func scope(_ area: Area) -> Log { + root(for: area) + } + + /// The grouping scopes under ``root``. A plain `Hashable` token whose case + /// name becomes the scope name (`services`, `dashboard`). + enum Area: Hashable { + case services + case dashboard + } +} diff --git a/Ledger/LedgerCore/Sources/LedgerServices.swift b/Ledger/LedgerCore/Sources/LedgerServices.swift new file mode 100644 index 00000000..3dcdd8a5 --- /dev/null +++ b/Ledger/LedgerCore/Sources/LedgerServices.swift @@ -0,0 +1,473 @@ +import Foundation +import Observation + +/// The root of the Ledger model tree. Resolves a Cursor session token +/// (auto-detected from the local Cursor app, or pasted and kept in the +/// Keychain), fetches the current cycle's usage summary and per-model usage +/// from the dashboard API, and exposes a single observable ``LoadState`` the +/// UI renders. +@MainActor +@Observable +public final class LedgerServices { + /// The spend-load state machine: exactly one of these holds at a time, so + /// the UI can't see a half-loaded mix of value + error + spinner. + public enum LoadState: Sendable, Equatable { + case idle + case loading + case loaded(SpendSnapshot) + case failed(LoadError) + } + + /// Why a spend fetch couldn't produce a value. + public enum LoadError: Sendable, Equatable { + /// No session token could be found (Cursor signed out / not installed, + /// and nothing pasted). + case missingCredentials + /// HTTP 401 — the token is expired or malformed. + case notAuthenticated + /// Another non-2xx HTTP response. + case http(Int) + /// The transport failed (offline, DNS, TLS, timeout). + case network(String) + /// A 2xx response that didn't decode. + case decode(String) + + /// A short, user-facing explanation for the popover. + public var message: String { + switch self { + case .missingCredentials: + "Sign in to the Cursor app, or paste a session token in Settings." + case .notAuthenticated: + "Your Cursor session expired. Reopen Cursor (or paste a fresh token in Settings)." + case let .http(status): + "The dashboard request failed (HTTP \(status))." + case let .network(reason): + "Couldn't reach Cursor: \(reason)" + case let .decode(reason): + "Couldn't read the dashboard response: \(reason)" + } + } + } + + /// The current spend-load state. Observable so the UI reflects it. + public private(set) var loadState: LoadState = .idle + + /// When the last successful fetch completed, for the "updated …" caption. + public private(set) var lastUpdated: Date? + + /// The most recent refresh failure *while spend is still shown* — i.e. the + /// data is stale. `nil` when the last refresh succeeded. (A failure with no + /// prior data goes to `loadState = .failed` instead.) + public private(set) var loadError: LoadError? + + /// Whether a fetch is currently in flight. Distinct from `loadState`: + /// during a refresh the last loaded data stays visible (so the UI doesn't + /// clear), and this drives only a subtle in-progress indicator. + public private(set) var isRefreshing: Bool = false + + /// Whether a token was pasted into the Keychain (a manual override). + public private(set) var hasManualToken: Bool = false + + /// Whether a token can be auto-detected from the local Cursor app. + public private(set) var autoTokenAvailable: Bool = false + + /// Settings; created from the loaded configuration. + @ObservationIgnored + public private(set) lazy var settings: LedgerSettings = .init( + refreshInterval: configuration.refreshInterval, + onPersistentChange: { [unowned self] in settingsDidChange() }, + ) + + /// Whether Ledger is registered to launch at login. Backed by + /// `SMAppService` (the OS owns the real state). + public var startsAtLogin: Bool { + get { loginItem.isEnabled } + set { + do { + try loginItem.setEnabled(newValue) + loginItemError = nil + } catch { + Self.logger.error("Couldn't update the login item: \(error)") + loginItemError = newValue + ? "Couldn't turn on Launch at login: \(error.localizedDescription)" + : "Couldn't turn off Launch at login: \(error.localizedDescription)" + } + } + } + + /// The login item is registered but macOS needs the user to approve it. + public var loginItemNeedsApproval: Bool { + loginItem.needsApproval + } + + /// The most recent login-item failure, surfaced in Settings. + public private(set) var loginItemError: String? + + private static let logger = LedgerLog.services + + /// Minimum time between per-model fetches. That breakdown costs several + /// paginated requests (it walks every usage event in the cycle), while the + /// headline refreshes as often as once a minute — so it is deliberately + /// *not* refetched at the headline cadence. The mix changes slowly; the + /// manual Refresh button bypasses this, as does a new billing cycle. + private static let modelRefreshInterval: TimeInterval = 15 * 60 + + @ObservationIgnored private var configuration: LedgerConfiguration + @ObservationIgnored private let configStore: LedgerConfigStore + @ObservationIgnored private let keychain: any KeychainStore + @ObservationIgnored private let tokenSource: any SessionTokenSource + @ObservationIgnored private let provider: any DashboardProvider + @ObservationIgnored private let loginItem: LoginItemController + @ObservationIgnored private let historyStore: SpendHistoryStore + @ObservationIgnored private var history: [SpendSample] = [] + @ObservationIgnored private let calendar: Calendar + @ObservationIgnored private let now: @Sendable () -> Date + @ObservationIgnored private var refreshLoop: Task? + /// Increments per fetch so a slow earlier response can't clobber a newer one. + @ObservationIgnored private var requestGeneration = 0 + /// The resolved credential, cached so refreshes don't re-read the Keychain + /// and Cursor's SQLite store every time (see ``resolveToken()``). + @ObservationIgnored private var cachedToken: SessionToken? + /// The last per-model breakdown, reused between throttled fetches (see + /// ``modelRefreshInterval``) and kept when a per-model fetch fails. + @ObservationIgnored private var cachedModelShares: [ModelShare] = [] + /// When ``cachedModelShares`` was last fetched, and for which cycle. + @ObservationIgnored private var lastModelFetch: Date? + @ObservationIgnored private var cachedModelCycle: Date? + + public convenience init() { + self.init( + configStore: .applicationSupport(), + keychain: SystemKeychainStore(), + tokenSource: CursorLocalTokenSource(), + provider: CursorDashboardAPI(), + loginItem: LoginItemController(), + historyStore: .applicationSupport(), + ) + } + + @_spi(Testing) + public init( + configStore: LedgerConfigStore, + keychain: any KeychainStore, + tokenSource: any SessionTokenSource, + provider: any DashboardProvider, + loginItem: LoginItemController, + historyStore: SpendHistoryStore, + calendar: Calendar = .current, + now: @escaping @Sendable () -> Date = { Date() }, + ) { + self.configStore = configStore + self.keychain = keychain + self.tokenSource = tokenSource + self.provider = provider + self.loginItem = loginItem + self.historyStore = historyStore + self.calendar = calendar + self.now = now + do { + configuration = try configStore.load() + } catch { + Self.logger.error("Couldn't load configuration: \(error)") + configuration = .initial + } + do { + history = try historyStore.load() + } catch { + Self.logger.error("Couldn't load spend history: \(error)") + history = [] + } + refreshTokenStatus() + } + + // MARK: - Lifecycle + + /// Kicks off the first fetch and the periodic refresh loop. + public func start() { + guard refreshLoop == nil else { return } + refreshLoop = Task { [weak self] in + while !Task.isCancelled { + // Periodic refreshes respect the per-model throttle; only an + // explicit user refresh forces that (expensive) fetch. + await self?.refresh(force: false) + guard let interval = self?.settings.refreshInterval, interval > 0 else { return } + try? await Task.sleep(for: .seconds(interval)) + } + } + } + + /// Cancels the periodic refresh loop (the app's quit path). + public func stop() { + refreshLoop?.cancel() + refreshLoop = nil + } + + // MARK: - Spend + + /// Fetches the current cycle's usage summary (and, subject to the + /// ``modelRefreshInterval`` throttle, the per-model breakdown), then builds + /// a ``SpendSnapshot``. Pass `force` for an explicit user refresh, which + /// bypasses that throttle. Safe to call concurrently: a stale response is + /// dropped without touching state or recorded history. + public func refresh(force: Bool) async { + guard let token = resolveToken() else { + applyFailure(.missingCredentials) + return + } + + requestGeneration += 1 + let generation = requestGeneration + // Keep any already-loaded data on screen during a refresh — only show + // the full-screen loading state for the very first load. The header + // spinner (driven by `isRefreshing`) signals the in-flight fetch. + if case .loaded = loadState {} else { + loadState = .loading + } + isRefreshing = true + defer { + // Don't clear the flag for a newer refresh that superseded this one. + if generation == requestGeneration { isRefreshing = false } + } + + do { + let summary = try await provider.usageSummary(token: token) + let models = await modelShares( + cycleStart: summary.cycleStart, + token: token, + force: force, + ) + + // Everything below mutates state, so it must run only for the + // newest request: recording history from a superseded (older) + // response would append a lower reading at a later timestamp and + // skew future day/week baselines. + guard generation == requestGeneration else { return } + let deltas = recordHistory(summary: summary) + let snapshot = SpendSnapshot( + currentCycleCents: summary.onDemandCents, + deltas: deltas, + cycleStart: summary.cycleStart, + cycleEnd: summary.cycleEnd, + membershipType: summary.membershipType, + autoFractionUsed: summary.autoFractionUsed, + apiFractionUsed: summary.apiFractionUsed, + modelShares: models, + ) + loadState = .loaded(snapshot) + lastUpdated = now() + loadError = nil + } catch let error as DashboardError { + guard generation == requestGeneration else { return } + if error == .notAuthenticated { + // The credential we cached was rejected — drop it so the next + // refresh re-reads (the user may have signed back in to Cursor, + // rotating the stored token). + cachedToken = nil + } + applyFailure(error.asLoadError) + } catch { + guard generation == requestGeneration else { return } + Self.logger.error("Unexpected dashboard error: \(error.localizedDescription)") + applyFailure(.network(error.localizedDescription)) + } + } + + /// Records a refresh failure. If spend is already loaded, the last good data + /// stays on screen and the failure surfaces as ``loadError`` (a stale + /// warning); otherwise — nothing loaded yet — it becomes the full error + /// state. + private func applyFailure(_ error: LoadError) { + if case .loaded = loadState { + loadError = error + } else { + loadState = .failed(error) + loadError = nil + } + } + + /// Appends a sample of the cumulative on-demand spend, prunes and persists + /// the history (best-effort), and returns today's / this-week's deltas. + private func recordHistory(summary: UsageSummary) -> SpendDeltas { + let timestamp = now() + let sample = SpendSample( + timestamp: timestamp, + cycleStart: summary.cycleStart, + onDemandCents: summary.onDemandCents, + ) + history = historyStore.pruned(history + [sample], now: timestamp) + do { + try historyStore.save(history) + } catch { + Self.logger.warning("Couldn't save spend history: \(error.localizedDescription)") + } + return SpendHistory.deltas( + current: sample, + samples: history, + calendar: calendar, + now: timestamp, + ) + } + + /// The models by usage for the current cycle, as relative shares, derived + /// from the per-event endpoint — throttled by ``modelRefreshInterval``, so + /// most refreshes reuse the cached breakdown instead of re-walking every + /// event. Best-effort: a failure (or an unknown cycle start) logs and keeps + /// the last good breakdown rather than failing the whole load. + private func modelShares( + cycleStart: Date?, + token: SessionToken, + force: Bool, + ) async -> [ModelShare] { + guard let cycleStart else { return [] } + guard shouldFetchModels(cycleStart: cycleStart, force: force) else { + return cachedModelShares + } + do { + let events = try await cycleEvents(since: cycleStart, token: token) + cachedModelShares = ModelShare.shares(from: events) + cachedModelCycle = cycleStart + lastModelFetch = now() + return cachedModelShares + } catch { + Self.logger.warning("Couldn't load per-model usage: \(error.localizedDescription)") + return cachedModelShares + } + } + + /// Whether the per-model breakdown is due for a refetch: on an explicit + /// user refresh, when the billing cycle rolled over (the cached breakdown + /// belongs to the previous cycle), or once the throttle window has elapsed. + private func shouldFetchModels(cycleStart: Date, force: Bool) -> Bool { + if force || cycleStart != cachedModelCycle { return true } + guard let lastModelFetch else { return true } + return now().timeIntervalSince(lastModelFetch) >= Self.modelRefreshInterval + } + + /// Fetches all usage events from `cycleStart` to now, paginating newest-first + /// until the reported total is covered (capped to bound request count). + private func cycleEvents( + since cycleStart: Date, + token: SessionToken, + ) async throws -> [UsageEvent] { + let pageSize = 250 + let maxPages = 40 + let end = now() + var events: [UsageEvent] = [] + var page = 1 + while page <= maxPages { + let result = try await provider.usageEvents( + startDate: cycleStart, + endDate: end, + page: page, + pageSize: pageSize, + token: token, + ) + events.append(contentsOf: result.usageEventsDisplay) + if result.usageEventsDisplay.isEmpty || events.count >= result.totalUsageEventsCount { + break + } + page += 1 + } + return events + } + + // MARK: - Token + + /// The session token, read once and then cached. Reading it touches the + /// Keychain *and* opens Cursor's SQLite store, and the value doesn't change + /// between refreshes — so the cache is dropped only when it actually can + /// change: the user edits the pasted token, the API rejects it (401), or + /// Settings asks for a fresh read. + private func resolveToken() -> SessionToken? { + if let cachedToken { return cachedToken } + cachedToken = readToken() + return cachedToken + } + + /// Reads both credential sources — a pasted token (Keychain) overrides the + /// auto-detected local Cursor session — and mirrors what it found onto the + /// observable availability flags. + private func readToken() -> SessionToken? { + let manual = manualToken() + hasManualToken = manual != nil + let auto = tokenSource.currentToken() + autoTokenAvailable = auto != nil + + if let manual, let token = SessionToken(rawToken: manual) { + return token + } + return auto + } + + /// Re-reads the credential sources now, refreshing ``hasManualToken`` and + /// ``autoTokenAvailable``. Settings calls this when it appears, since the + /// underlying state changes outside the app (signing in/out of Cursor). + public func refreshTokenStatus() { + cachedToken = nil + _ = resolveToken() + } + + private func manualToken() -> String? { + do { + let value = try keychain.read() + return (value?.isEmpty ?? true) ? nil : value + } catch { + Self.logger.error("Couldn't read the pasted token: \(error.localizedDescription)") + return nil + } + } + + /// Stores (or clears, for an empty string) a pasted session token. + public func setManualToken(_ token: String) throws { + try keychain.write(token) + refreshTokenStatus() + } + + /// Removes any pasted token (falling back to auto-detection). + public func clearManualToken() throws { + try keychain.remove() + refreshTokenStatus() + } + + // MARK: - Login item + + public func refreshLoginItemStatus() { + loginItem.refresh() + } + + public func openSystemSettingsLoginItems() { + loginItem.openSystemSettingsLoginItems() + } + + // MARK: - Persistence + + private func settingsDidChange() { + configuration.refreshInterval = settings.refreshInterval + persist() + // Apply a new cadence now rather than after the current sleep: restart + // the loop (which refreshes immediately) if it's running. + if refreshLoop != nil { + stop() + start() + } + } + + private func persist() { + do { + try configStore.save(configuration) + } catch { + Self.logger.error("Couldn't save configuration: \(error)") + } + } +} + +extension DashboardError { + fileprivate var asLoadError: LedgerServices.LoadError { + switch self { + case .notAuthenticated: .notAuthenticated + case let .http(status): .http(status) + case let .network(reason): .network(reason) + case let .decode(reason): .decode(reason) + } + } +} diff --git a/Ledger/LedgerCore/Sources/LedgerSettings.swift b/Ledger/LedgerCore/Sources/LedgerSettings.swift new file mode 100644 index 00000000..9294c8bd --- /dev/null +++ b/Ledger/LedgerCore/Sources/LedgerSettings.swift @@ -0,0 +1,33 @@ +import Foundation +import Observation + +/// The settings node of the Ledger model tree: how often to refresh. Identity +/// comes from the Cursor session token (auto-detected from the local Cursor +/// app, or pasted and kept in the Keychain), so there's no email or key here — +/// only the non-secret preference that persists as JSON. +/// +/// Mutations notify the injected funnel so the owning tree persists on any +/// change; reassigning an equal value is a no-op. +@MainActor +@Observable +public final class LedgerSettings { + /// How often the spend is auto-refreshed while the app runs. + public var refreshInterval: TimeInterval { + didSet { + guard oldValue != refreshInterval else { return } + onPersistentChange() + } + } + + private let onPersistentChange: @MainActor () -> Void + + /// Initial values are the saved configuration; assigning them here does + /// not invoke the funnel. + public init( + refreshInterval: TimeInterval, + onPersistentChange: @escaping @MainActor () -> Void, + ) { + self.refreshInterval = refreshInterval + self.onPersistentChange = onPersistentChange + } +} diff --git a/Ledger/LedgerCore/Sources/LoginItemController.swift b/Ledger/LedgerCore/Sources/LoginItemController.swift new file mode 100644 index 00000000..c2000e6b --- /dev/null +++ b/Ledger/LedgerCore/Sources/LoginItemController.swift @@ -0,0 +1,126 @@ +import Foundation +import Observation +import ServiceManagement + +/// The login-item state Ledger cares about, distilled from +/// `SMAppService.Status`. +/// +/// `requiresApproval` is distinct from `notRegistered`: the app *is* registered +/// but macOS is waiting for the user to approve it in System Settings before it +/// will actually launch. Collapsing it into "off" would misreport an enabled +/// (pending) item as disabled. +public enum LoginItemStatus: Sendable, Equatable { + case enabled + case requiresApproval + case notRegistered +} + +/// Registers, unregisters, and reports the app's login-item state behind +/// ``LoginItemController``. Production uses the `SMAppService.mainApp`-backed +/// implementation; tests conform a fake so no real login item is touched. +@MainActor +@_spi(Testing) +public protocol LoginItemBackend: AnyObject { + var status: LoginItemStatus { get } + func register() throws + func unregister() throws + /// Opens System Settings › General › Login Items so the user can approve + /// or inspect the item. + func openSystemSettingsLoginItems() +} + +/// The real backend: `SMAppService.mainApp`. Registering the *main app* as a +/// login item needs no helper bundle and no special entitlement. +@MainActor +final class MainAppLoginItemBackend: LoginItemBackend { + var status: LoginItemStatus { + switch SMAppService.mainApp.status { + case .enabled: .enabled + case .requiresApproval: .requiresApproval + // `.notFound` means the service isn't registered from this bundle; + // for the toggle that reads the same as "not registered". + case .notRegistered, .notFound: .notRegistered + @unknown default: .notRegistered + } + } + + func register() throws { + try SMAppService.mainApp.register() + } + + func unregister() throws { + try SMAppService.mainApp.unregister() + } + + func openSystemSettingsLoginItems() { + SMAppService.openSystemSettingsLoginItems() + } +} + +/// Reflects and controls whether Ledger launches at login, wrapping +/// `SMAppService.mainApp`. +/// +/// The OS owns the real state, so ``status`` is read from the backend (and +/// re-read via ``refresh()``, since the user can change it in System Settings +/// while the app runs). ``setEnabled(_:)`` registers or unregisters and then +/// re-syncs ``status`` from the backend so the observed value never lies — a +/// failed registration leaves the toggle honestly off, not falsely on. +@MainActor +@Observable +public final class LoginItemController { + /// The current login-item status. Observable so the settings UI reflects + /// it (including the pending-approval state). + public private(set) var status: LoginItemStatus + + @ObservationIgnored private let backend: any LoginItemBackend + + /// Whether the login item is on. Includes ``LoginItemStatus/requiresApproval``: + /// the user asked for it and it's registered — it just needs a nod in + /// System Settings — so the toggle should read on, not off. + public var isEnabled: Bool { + status != .notRegistered + } + + /// The login item is registered but macOS needs the user to approve it in + /// System Settings before it will actually launch. + public var needsApproval: Bool { + status == .requiresApproval + } + + public init() { + backend = MainAppLoginItemBackend() + status = backend.status + } + + /// Swaps the real login-item backend for a test double. + @_spi(Testing) + public init(backend: any LoginItemBackend) { + self.backend = backend + status = backend.status + } + + /// Re-reads the OS status; it can change outside the app (System Settings + /// › General › Login Items). + public func refresh() { + status = backend.status + } + + /// Registers or unregisters the login item, then re-syncs ``status`` from + /// the backend. Rethrows the underlying `SMAppService` error; the observed + /// value stays honest whether it succeeds or throws. + public func setEnabled(_ enabled: Bool) throws { + defer { status = backend.status } + guard enabled != isEnabled else { return } + if enabled { + try backend.register() + } else { + try backend.unregister() + } + } + + /// Opens System Settings › General › Login Items (used to approve a + /// pending item). + public func openSystemSettingsLoginItems() { + backend.openSystemSettingsLoginItems() + } +} diff --git a/Ledger/LedgerCore/Sources/ModelName.swift b/Ledger/LedgerCore/Sources/ModelName.swift new file mode 100644 index 00000000..4ee814c6 --- /dev/null +++ b/Ledger/LedgerCore/Sources/ModelName.swift @@ -0,0 +1,123 @@ +import Foundation + +/// A raw model identifier (e.g. `claude-opus-4-8-thinking-xhigh`, +/// `github_bugbot`, `non-max-composer-2.5-fast`) parsed into a friendly display +/// name plus a set of short badges (reasoning effort, speed, mode). +/// +/// This is a best-effort heuristic tokenizer, not an exhaustive registry: it +/// recognizes the vendor/family words, version numbers, and effort/mode +/// suffixes seen in Cursor's model ids, and title-cases anything it doesn't +/// know — so a brand-new model still renders reasonably (`mistral-large-2` → +/// "Mistral Large 2") rather than as a raw slug. +public struct ModelName: Equatable, Sendable { + /// The cleaned-up name, e.g. "Claude Opus 4.8". + public var displayName: String + /// Short badges in display order (mode, then effort, then speed), e.g. + /// `["non-max", "xhigh"]`. + public var badges: [String] + + public init(displayName: String, badges: [String]) { + self.displayName = displayName + self.badges = badges + } + + /// Reasoning-effort tokens → badge label. + private static let effortLabels: [String: String] = [ + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh", + "min": "min", + ] + + /// Vendor/family words → their display casing. Anything absent is + /// title-cased. + private static let wordLabels: [String: String] = [ + "claude": "Claude", + "opus": "Opus", + "sonnet": "Sonnet", + "haiku": "Haiku", + "fable": "Fable", + "gpt": "GPT", + "codex": "Codex", + "sol": "Sol", + "grok": "Grok", + "composer": "Composer", + "gemini": "Gemini", + "github": "GitHub", + "bugbot": "Bugbot", + "auto": "Auto", + ] + + /// Hosting/qualifier words dropped from the display name. + private static let droppedWords: Set = ["cursor"] + + /// Parses `raw` into a display name and badges. An unrecognized or empty + /// input falls back to the raw string as the display name. + public static func parse(_ raw: String) -> ModelName { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return ModelName(displayName: raw, badges: []) } + + var tokens = trimmed.lowercased().split { $0 == "-" || $0 == "_" }.map(String.init) + + var modeBadges: [String] = [] + // A leading "non-max" (→ ["non", "max"]) is a mode, not part of the name. + if tokens.first == "non", tokens.count > 1, tokens[1] == "max" { + modeBadges.append("non-max") + tokens.removeFirst(2) + } + + var effortBadges: [String] = [] + var speedBadges: [String] = [] + var nameTokens: [String] = [] + for token in tokens { + if let effort = effortLabels[token] { + effortBadges.append(effort) + } else if token == "max" { + modeBadges.append("max") + } else if token == "fast" { + speedBadges.append("fast") + } else if token == "thinking" { + continue // implied by the effort badge; omit from the name + } else if droppedWords.contains(token) { + continue + } else { + nameTokens.append(token) + } + } + + let displayName = formatName(nameTokens) + let badges = modeBadges + effortBadges + speedBadges + return ModelName( + displayName: displayName.isEmpty ? trimmed : displayName, + badges: badges, + ) + } + + /// Joins name tokens, mapping known words and collapsing runs of integer + /// tokens into a dotted version (`["4", "8"]` → `"4.8"`). + private static func formatName(_ tokens: [String]) -> String { + var parts: [String] = [] + var index = 0 + while index < tokens.count { + if tokens[index].isAllDigits { + var numbers: [String] = [] + while index < tokens.count, tokens[index].isAllDigits { + numbers.append(tokens[index]) + index += 1 + } + parts.append(numbers.joined(separator: ".")) + } else { + parts.append(wordLabels[tokens[index]] ?? tokens[index].capitalized) + index += 1 + } + } + return parts.joined(separator: " ") + } +} + +extension String { + fileprivate var isAllDigits: Bool { + !isEmpty && allSatisfy(\.isNumber) + } +} diff --git a/Ledger/LedgerCore/Sources/SessionToken.swift b/Ledger/LedgerCore/Sources/SessionToken.swift new file mode 100644 index 00000000..32bb1c91 --- /dev/null +++ b/Ledger/LedgerCore/Sources/SessionToken.swift @@ -0,0 +1,64 @@ +import Foundation + +/// A Cursor dashboard session credential, normalized to the exact value the +/// `WorkosCursorSessionToken` cookie needs: `"::"`. +/// +/// The dashboard API rejects a bare JWT (HTTP 401) — the cookie must carry the +/// user id prefix. The Cursor app stores only the raw JWT (in `state.vscdb` / +/// Keychain), so ``init(rawToken:)`` derives the prefix from the JWT's `sub` +/// claim (`"auth0|user_ABC"` → `user_ABC`). A value the user pastes may already +/// be in `userId::jwt` form, in which case it's used as-is. +public struct SessionToken: Equatable, Sendable { + /// The ready-to-send cookie value (`userId::jwt`). + public let cookieValue: String + + /// Wraps an already-formed `userId::jwt` cookie value verbatim. + public init(cookieValue: String) { + self.cookieValue = cookieValue + } + + /// Builds the cookie value from whatever the user or the local Cursor app + /// provides: a `userId::jwt` string is kept as-is; a bare JWT gets its + /// `userId` derived from the `sub` claim and prepended. Returns `nil` for + /// an empty string. + public init?(rawToken: String) { + let trimmed = rawToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.contains("::") { + cookieValue = trimmed + return + } + guard let userID = Self.userID(fromJWT: trimmed) else { + // Not a JWT we can parse — keep it verbatim so a mis-paste surfaces + // as an honest 401 rather than being silently dropped. + cookieValue = trimmed + return + } + cookieValue = "\(userID)::\(trimmed)" + } + + /// Extracts the user id from a JWT's `sub` claim, stripping any + /// `"provider|"` prefix (e.g. `"auth0|user_ABC"` → `"user_ABC"`). + static func userID(fromJWT jwt: String) -> String? { + let segments = jwt.split(separator: ".") + guard segments.count >= 2 else { return nil } + guard let payload = base64URLDecoded(String(segments[1])) else { return nil } + guard + let object = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let sub = object["sub"] as? String + else { return nil } + return sub.contains("|") ? String(sub.split(separator: "|").last ?? "") : sub + } + + /// Decodes a base64url segment (no padding, `-`/`_` alphabet) to `Data`. + private static func base64URLDecoded(_ input: String) -> Data? { + var base64 = input.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let remainder = base64.count % 4 + if remainder > 0 { + base64 += String(repeating: "=", count: 4 - remainder) + } + return Data(base64Encoded: base64) + } +} diff --git a/Ledger/LedgerCore/Sources/SessionTokenSource.swift b/Ledger/LedgerCore/Sources/SessionTokenSource.swift new file mode 100644 index 00000000..3b67b176 --- /dev/null +++ b/Ledger/LedgerCore/Sources/SessionTokenSource.swift @@ -0,0 +1,84 @@ +import Foundation +import SQLite3 + +/// Resolves the Cursor session token automatically from the locally installed +/// Cursor app, so the menu-bar app can reuse the session you're already signed +/// into without anything to paste. The seam is a protocol so tests inject a +/// fixed token instead of reading a real database. +public protocol SessionTokenSource: Sendable { + /// The current auto-detected token, or `nil` when none is available + /// (Cursor not installed, signed out, or the store moved). + func currentToken() -> SessionToken? +} + +/// Reads the Cursor IDE's stored access token from its `state.vscdb` +/// key-value store (`ItemTable`, key `cursorAuth/accessToken`) and normalizes +/// it into a ``SessionToken``. +/// +/// The database is opened **read-only**; Cursor may hold it open, so we never +/// write or lock it. A missing file, missing key, or open failure is a plain +/// `nil` (auto-detect simply isn't available) — not a thrown error. +public struct CursorLocalTokenSource: SessionTokenSource { + private let databaseURL: URL + private let key: String + + /// The default location of Cursor's global state store on macOS. + public static var defaultDatabaseURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent( + "Library/Application Support/Cursor/User/globalStorage/state.vscdb", + isDirectory: false, + ) + } + + public init( + databaseURL: URL = CursorLocalTokenSource.defaultDatabaseURL, + key: String = "cursorAuth/accessToken", + ) { + self.databaseURL = databaseURL + self.key = key + } + + public func currentToken() -> SessionToken? { + guard let raw = readValue(forKey: key), let token = SessionToken(rawToken: raw) else { + return nil + } + return token + } + + /// Reads a single `ItemTable.value` for `key`, or `nil` on any failure. + private func readValue(forKey key: String) -> String? { + guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil } + + var db: OpaquePointer? + // Read-only, and don't create the file if it's missing. + guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(db) + return nil + } + defer { sqlite3_close(db) } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT value FROM ItemTable WHERE key = ? LIMIT 1", + -1, + &statement, + nil, + ) == SQLITE_OK else { + return nil + } + defer { sqlite3_finalize(statement) } + + // SQLITE_TRANSIENT tells SQLite to copy the bound string. + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, key, -1, transient) + + guard sqlite3_step(statement) == SQLITE_ROW, + let bytes = sqlite3_column_text(statement, 0) + else { + return nil + } + return String(cString: bytes) + } +} diff --git a/Ledger/LedgerCore/Sources/SpendHistory.swift b/Ledger/LedgerCore/Sources/SpendHistory.swift new file mode 100644 index 00000000..060616b9 --- /dev/null +++ b/Ledger/LedgerCore/Sources/SpendHistory.swift @@ -0,0 +1,102 @@ +import Foundation + +/// One recorded point of the cycle's cumulative on-demand spend. Persisted over +/// time so per-day / per-week spend can be derived by differencing (the API has +/// no per-range billed figure — see the module README). +public struct SpendSample: Codable, Equatable, Sendable { + /// When the sample was taken. + public var timestamp: Date + /// The billing cycle this sample belongs to (samples only difference within + /// one cycle, since `onDemand.used` resets at the boundary). + public var cycleStart: Date? + /// Cumulative usage-based spend so far this cycle, in cents. + public var onDemandCents: Int + + public init(timestamp: Date, cycleStart: Date?, onDemandCents: Int) { + self.timestamp = timestamp + self.cycleStart = cycleStart + self.onDemandCents = onDemandCents + } +} + +/// Per-window spend derived from the history, in cents. `nil` means "not enough +/// history yet" (no baseline near the window start), so the UI can hide it +/// rather than show a wrong number. +public struct SpendDeltas: Equatable, Sendable { + public var todayCents: Int? + public var thisWeekCents: Int? + + public init(todayCents: Int?, thisWeekCents: Int?) { + self.todayCents = todayCents + self.thisWeekCents = thisWeekCents + } + + public var todayDollars: Double? { + todayCents.map { Double($0) / 100 } + } + + public var thisWeekDollars: Double? { + thisWeekCents.map { Double($0) / 100 } + } +} + +/// Differences the cumulative on-demand total across recorded ``SpendSample``s +/// to produce today's and this-week's spend. +/// +/// Because `onDemand.used` is a server-side running total, the spend between any +/// two samples is just their difference — so this captures usage even while the +/// app wasn't running, as long as a sample exists near the window's start. +/// Baselines are scoped to the current billing cycle; a window that begins +/// before the cycle started counts the whole cycle-to-date (on-demand resets at +/// the boundary anyway). +public enum SpendHistory { + /// Today's and this-(calendar-)week's spend for `current`, given prior + /// `samples`. + public static func deltas( + current: SpendSample, + samples: [SpendSample], + calendar: Calendar, + now: Date, + ) -> SpendDeltas { + let startOfDay = calendar.startOfDay(for: now) + let startOfWeek = calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? startOfDay + return SpendDeltas( + todayCents: spend(since: startOfDay, current: current, samples: samples), + thisWeekCents: spend(since: startOfWeek, current: current, samples: samples), + ) + } + + /// Spend from `windowStart` to `current`, or `nil` when no baseline is + /// available (insufficient history). + static func spend( + since windowStart: Date, + current: SpendSample, + samples: [SpendSample], + ) -> Int? { + guard let baseline = baselineCents(at: windowStart, current: current, samples: samples) + else { + return nil + } + // On-demand should only grow within a cycle; clamp against rare + // adjustments so a delta never reads negative. + return max(0, current.onDemandCents - baseline) + } + + /// The cumulative on-demand cents as of `windowStart`, within `current`'s + /// cycle. Returns 0 when the cycle itself began at/after the window (the + /// whole cycle-to-date falls inside it), or `nil` when the window predates + /// the cycle but no sample covers it. + private static func baselineCents( + at windowStart: Date, + current: SpendSample, + samples: [SpendSample], + ) -> Int? { + if let cycleStart = current.cycleStart, cycleStart >= windowStart { + return 0 + } + let baseline = samples + .filter { $0.cycleStart == current.cycleStart && $0.timestamp <= windowStart } + .max { $0.timestamp < $1.timestamp } + return baseline?.onDemandCents + } +} diff --git a/Ledger/LedgerCore/Sources/SpendHistoryStore.swift b/Ledger/LedgerCore/Sources/SpendHistoryStore.swift new file mode 100644 index 00000000..c04d9d79 --- /dev/null +++ b/Ledger/LedgerCore/Sources/SpendHistoryStore.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Persists the rolling list of ``SpendSample``s as JSON, pruning old points. +/// +/// Only a couple of weeks are needed (the furthest baseline is the start of the +/// current week), so the file stays small. A missing file is the legitimate +/// first-run state (empty history); a file that exists but can't be decoded +/// throws rather than silently resetting. +public struct SpendHistoryStore: Sendable { + private let fileURL: URL + + /// Samples older than this are pruned on save — comfortably longer than the + /// oldest baseline the deltas need (start of the current week). + public static let retention: TimeInterval = 14 * 24 * 60 * 60 + + public init(directory: URL) { + fileURL = directory.appendingPathComponent("history.json") + } + + /// The production store, alongside the configuration under + /// `~/Library/Application Support/com.stuff.ledger/`. + public static func applicationSupport() -> SpendHistoryStore { + let base = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + return SpendHistoryStore(directory: base.appendingPathComponent("com.stuff.ledger")) + } + + public func load() throws -> [SpendSample] { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return [] + } + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode([SpendSample].self, from: data) + } + + public func save(_ samples: [SpendSample]) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + let data = try JSONEncoder().encode(samples) + try data.write(to: fileURL, options: .atomic) + } + + /// Drops samples older than ``retention`` relative to `now`. + public func pruned(_ samples: [SpendSample], now: Date) -> [SpendSample] { + let cutoff = now.addingTimeInterval(-Self.retention) + return samples.filter { $0.timestamp >= cutoff } + } +} diff --git a/Ledger/LedgerCore/Sources/SpendSnapshot.swift b/Ledger/LedgerCore/Sources/SpendSnapshot.swift new file mode 100644 index 00000000..5ea384b6 --- /dev/null +++ b/Ledger/LedgerCore/Sources/SpendSnapshot.swift @@ -0,0 +1,50 @@ +import Foundation + +/// The spend figures Ledger renders, distilled from the usage summary and the +/// per-model aggregation into one value the UI binds to. All money is cents. +public struct SpendSnapshot: Equatable, Sendable { + /// Usage-based spend for the current billing cycle (live, from the usage + /// summary). + public var currentCycleCents: Int + /// Today's and this-week's spend, derived from local history (each `nil` + /// until enough history exists). + public var deltas: SpendDeltas + /// The current billing cycle's start/end, when known. + public var cycleStart: Date? + public var cycleEnd: Date? + /// Plan tier (`"pro"`, `"ultra"`, …). + public var membershipType: String + /// Fraction (0...1) of the included first-party/Auto allowance used this + /// cycle, when known. + public var autoFractionUsed: Double? + /// Fraction (0...1) of the included third-party/API allowance used this + /// cycle, when known. + public var apiFractionUsed: Double? + /// Models by usage this cycle, as relative shares highest-first (dollar-free + /// — see ``AggregatedUsage``). Empty when the per-model fetch is unavailable. + public var modelShares: [ModelShare] + + public init( + currentCycleCents: Int, + deltas: SpendDeltas, + cycleStart: Date?, + cycleEnd: Date?, + membershipType: String, + autoFractionUsed: Double?, + apiFractionUsed: Double?, + modelShares: [ModelShare], + ) { + self.currentCycleCents = currentCycleCents + self.deltas = deltas + self.cycleStart = cycleStart + self.cycleEnd = cycleEnd + self.membershipType = membershipType + self.autoFractionUsed = autoFractionUsed + self.apiFractionUsed = apiFractionUsed + self.modelShares = modelShares + } + + public var currentCycleDollars: Double { + Double(currentCycleCents) / 100 + } +} diff --git a/Ledger/LedgerCore/Sources/TestDoubles.swift b/Ledger/LedgerCore/Sources/TestDoubles.swift new file mode 100644 index 00000000..3fa0da79 --- /dev/null +++ b/Ledger/LedgerCore/Sources/TestDoubles.swift @@ -0,0 +1,143 @@ +#if DEBUG + import Foundation + + /// A ``DashboardProvider`` that returns scripted results instead of hitting + /// the network — used by unit tests and SwiftUI previews. Lives in the + /// module (behind `@_spi(Testing)` + `#if DEBUG`) so both callers share one + /// double that conforms to the production protocol. + @_spi(Testing) + public struct ScriptedDashboardProvider: DashboardProvider { + public enum Outcome: Sendable { + case success(summary: UsageSummary) + case failure(DashboardError) + } + + private let outcome: Outcome + private let events: [UsageEvent] + /// When set, only `usageEvents` throws it — exercises the best-effort + /// per-model path (the summary still succeeds). + private let eventsFailure: DashboardError? + + public init( + _ outcome: Outcome, + events: [UsageEvent] = [], + eventsFailure: DashboardError? = nil, + ) { + self.outcome = outcome + self.events = events + self.eventsFailure = eventsFailure + } + + /// Convenience: a successful summary. + public init(summary: UsageSummary) { + outcome = .success(summary: summary) + events = [] + eventsFailure = nil + } + + public func usageSummary(token _: SessionToken) async throws -> UsageSummary { + switch outcome { + case let .success(summary): summary + case let .failure(error): throw error + } + } + + public func usageEvents( + startDate _: Date, + endDate _: Date, + page: Int, + pageSize _: Int, + token _: SessionToken, + ) async throws -> UsageEventsPage { + if let eventsFailure { throw eventsFailure } + if case let .failure(error) = outcome { throw error } + // All events on page 1; later pages are empty (single-page fixture). + let display = page == 1 ? events : [] + return UsageEventsPage(usageEventsDisplay: display, totalUsageEventsCount: events.count) + } + } + + /// A ``SessionTokenSource`` that returns a fixed token (or none), so + /// auto-detection is testable without reading a real `state.vscdb`. + @_spi(Testing) + public struct StubTokenSource: SessionTokenSource { + private let token: SessionToken? + public init(token: SessionToken?) { + self.token = token + } + + public func currentToken() -> SessionToken? { + token + } + } + + /// An in-memory ``KeychainStore`` — the real Keychain needs a signed, + /// entitled host that hostless test processes and previews don't have. + @_spi(Testing) + public final class InMemoryKeychainStore: KeychainStore, @unchecked Sendable { + private let lock = NSLock() + private var secret: String? + /// When set, `read`/`write`/`remove` throw it — exercises the error path. + private let failure: KeychainError? + + public init(secret: String? = nil, failure: KeychainError? = nil) { + self.secret = secret + self.failure = failure + } + + public func read() throws -> String? { + if let failure { throw failure } + return lock.withLock { secret } + } + + public func write(_ secret: String) throws { + if let failure { throw failure } + let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) + lock.withLock { self.secret = trimmed.isEmpty ? nil : trimmed } + } + + public func remove() throws { + if let failure { throw failure } + lock.withLock { secret = nil } + } + } + + extension UsageSummary { + /// A minimal summary for previews/tests. + public static func fixture( + onDemandCents: Int, + membershipType: String = "pro", + includedUsed: Int = 0, + includedLimit: Int? = nil, + autoPercentUsed: Double? = nil, + apiPercentUsed: Double? = nil, + cycleStart: String = "2026-07-04T18:16:08.000Z", + cycleEnd: String = "2026-08-04T18:16:08.000Z", + ) -> UsageSummary { + UsageSummary( + billingCycleStart: cycleStart, + billingCycleEnd: cycleEnd, + membershipType: membershipType, + individualUsage: .init( + onDemand: .init(enabled: true, used: onDemandCents, limit: nil, remaining: nil), + plan: .init( + enabled: true, + used: includedUsed, + limit: includedLimit, + remaining: nil, + breakdown: nil, + autoPercentUsed: autoPercentUsed, + apiPercentUsed: apiPercentUsed, + ), + ), + ) + } + } + + public enum UsageEventFixture { + /// Builds usage events from `[model: costCents]` pairs (one event each). + public static func events(_ modelCents: KeyValuePairs) -> [UsageEvent] { + modelCents.map { UsageEvent(model: $0.key, chargedCents: $0.value) } + } + } +#endif diff --git a/Ledger/LedgerCore/Sources/UsageEvents.swift b/Ledger/LedgerCore/Sources/UsageEvents.swift new file mode 100644 index 00000000..aac88004 --- /dev/null +++ b/Ledger/LedgerCore/Sources/UsageEvents.swift @@ -0,0 +1,66 @@ +import Foundation + +/// One decoded row of `POST /api/dashboard/get-filtered-usage-events` — a +/// single usage event with its model and real charged cost. Only the fields +/// Ledger aggregates are modeled; the endpoint returns many more. +public struct UsageEvent: Codable, Equatable, Sendable { + public var model: String + /// The event's charged cost in cents (the endpoint reports it as a number, + /// sometimes fractional). Absent/negative reads as 0. + public var chargedCents: Double? + + public init(model: String, chargedCents: Double?) { + self.model = model + self.chargedCents = chargedCents + } + + /// The event's cost in cents, floored at 0. + public var cents: Double { + max(0, chargedCents ?? 0) + } +} + +/// A page of the `get-filtered-usage-events` response. +public struct UsageEventsPage: Codable, Equatable, Sendable { + public var usageEventsDisplay: [UsageEvent] + public var totalUsageEventsCount: Int + + public init(usageEventsDisplay: [UsageEvent], totalUsageEventsCount: Int) { + self.usageEventsDisplay = usageEventsDisplay + self.totalUsageEventsCount = totalUsageEventsCount + } +} + +/// One model's relative share of usage this cycle (0...1). Deliberately +/// dollar-free: the events' summed cost is *total usage value* (included +/// allowance + on-demand), which is more than the billed on-demand headline, +/// so showing it as spend alongside the headline would mislead. +public struct ModelShare: Equatable, Sendable, Identifiable { + public var name: String + public var fraction: Double + + public var id: String { + name + } + + public init(name: String, fraction: Double) { + self.name = name + self.fraction = fraction + } + + /// Aggregates events into per-model shares of the total charged cost, + /// highest first (ties broken by name for a stable order). + public static func shares(from events: [UsageEvent]) -> [ModelShare] { + var totals: [String: Double] = [:] + for event in events where event.cents > 0 { + totals[event.model, default: 0] += event.cents + } + let total = totals.values.reduce(0, +) + guard total > 0 else { return [] } + return totals + .map { ModelShare(name: $0.key, fraction: $0.value / total) } + .sorted { + $0.fraction > $1.fraction || ($0.fraction == $1.fraction && $0.name < $1.name) + } + } +} diff --git a/Ledger/LedgerCore/Sources/UsageSummary.swift b/Ledger/LedgerCore/Sources/UsageSummary.swift new file mode 100644 index 00000000..24feb59b --- /dev/null +++ b/Ledger/LedgerCore/Sources/UsageSummary.swift @@ -0,0 +1,103 @@ +import Foundation + +/// The decoded `GET /api/usage-summary` response — the current billing cycle's +/// dates, plan type, and live usage/spend for an individual account. +/// +/// Only the fields Ledger reads are modeled; synthesized `Codable` ignores the +/// rest. Cent amounts are integers here (the dashboard reports whole cents). +public struct UsageSummary: Codable, Equatable, Sendable { + /// ISO-8601 start of the current billing cycle (kept as the wire string; + /// see ``cycleStart``). + public var billingCycleStart: String + /// ISO-8601 end of the current billing cycle. + public var billingCycleEnd: String + /// `"pro"`, `"ultra"`, `"free"`, etc. + public var membershipType: String + public var individualUsage: IndividualUsage + + public struct IndividualUsage: Codable, Equatable, Sendable { + /// Usage-based (pay-per-use) spend — the money beyond the subscription. + public var onDemand: OnDemand + /// Usage against the plan's included allowance. + public var plan: Plan + } + + public struct OnDemand: Codable, Equatable, Sendable { + public var enabled: Bool + /// Usage-based spend this cycle, in cents. + public var used: Int + /// The spend cap in cents, or `nil` when uncapped. + public var limit: Int? + public var remaining: Int? + } + + public struct Plan: Codable, Equatable, Sendable { + public var enabled: Bool + /// Included-allowance usage this cycle, in cents. + public var used: Int + /// The included allowance in cents. + public var limit: Int? + public var remaining: Int? + public var breakdown: Breakdown? + /// Percentage (0...100) of the included first-party/Auto allowance used + /// this cycle, when reported (some account types omit these). + public var autoPercentUsed: Double? = nil + /// Percentage (0...100) of the included third-party/API allowance used. + public var apiPercentUsed: Double? = nil + } + + public struct Breakdown: Codable, Equatable, Sendable { + public var included: Int + public var bonus: Int + public var total: Int + } + + public init( + billingCycleStart: String, + billingCycleEnd: String, + membershipType: String, + individualUsage: IndividualUsage, + ) { + self.billingCycleStart = billingCycleStart + self.billingCycleEnd = billingCycleEnd + self.membershipType = membershipType + self.individualUsage = individualUsage + } + + /// The parsed cycle start, or `nil` if the wire string doesn't parse. + public var cycleStart: Date? { + Self.parseDate(billingCycleStart) + } + + /// The parsed cycle end. + public var cycleEnd: Date? { + Self.parseDate(billingCycleEnd) + } + + /// Usage-based spend this cycle, in cents. + public var onDemandCents: Int { + individualUsage.onDemand.used + } + + /// Fraction (0...1) of the included **first-party / Auto** allowance used + /// this cycle, when the API reports it. + public var autoFractionUsed: Double? { + individualUsage.plan.autoPercentUsed.map { $0 / 100 } + } + + /// Fraction (0...1) of the included **third-party / API** allowance used + /// this cycle, when the API reports it. + public var apiFractionUsed: Double? { + individualUsage.plan.apiPercentUsed.map { $0 / 100 } + } + + private static func parseDate(_ string: String) -> Date? { + // Dashboard timestamps carry fractional seconds ("…T18:16:08.000Z"). + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFraction.date(from: string) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: string) + } +} diff --git a/Ledger/LedgerCore/Tests/CursorDashboardAPITests.swift b/Ledger/LedgerCore/Tests/CursorDashboardAPITests.swift new file mode 100644 index 00000000..fe0d2292 --- /dev/null +++ b/Ledger/LedgerCore/Tests/CursorDashboardAPITests.swift @@ -0,0 +1,165 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +/// Covers the wire shape of ``CursorDashboardAPI`` — the details that are only +/// visible to the server and so can't fail a normal test: which keys the body +/// carries, the units of the dates, and the auth header. Each has already cost +/// a real 401 during development. +@Suite(.serialized) +struct CursorDashboardAPITests { + private let token = SessionToken(cookieValue: "user_X::jwt") + + private func makeAPI() -> CursorDashboardAPI { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + return CursorDashboardAPI(session: URLSession(configuration: configuration)) + } + + @Test func usageEventsSendsNoTeamID() async throws { + StubURLProtocol.reset(body: DashboardFixture.usageEventsJSON) + _ = try await makeAPI().usageEvents( + startDate: Date(timeIntervalSince1970: 1000), + endDate: Date(timeIntervalSince1970: 2000), + page: 2, + pageSize: 250, + token: token, + ) + + let body = try #require(StubURLProtocol.lastBodyObject) + // Sending `teamId` on this endpoint 401s an individual account. The + // sibling aggregated endpoint *did* want it, so it looks plausible — + // hence this guard. + #expect(body["teamId"] == nil) + #expect(body["page"] as? Int == 2) + #expect(body["pageSize"] as? Int == 250) + } + + @Test func usageEventsSendsDatesAsEpochMilliseconds() async throws { + StubURLProtocol.reset(body: DashboardFixture.usageEventsJSON) + _ = try await makeAPI().usageEvents( + startDate: Date(timeIntervalSince1970: 1000), + endDate: Date(timeIntervalSince1970: 2000), + page: 1, + pageSize: 250, + token: token, + ) + + let body = try #require(StubURLProtocol.lastBodyObject) + // Seconds instead of milliseconds would silently query 1970 and return + // an empty window rather than failing. + #expect(body["startDate"] as? Int == 1_000_000) + #expect(body["endDate"] as? Int == 2_000_000) + } + + @Test func sendsTheSessionCookieAndOptsOutOfCookieHandling() async throws { + StubURLProtocol.reset(body: DashboardFixture.usageSummaryJSON) + _ = try await makeAPI().usageSummary(token: token) + + let request = try #require(StubURLProtocol.lastRequest) + #expect(request + .value(forHTTPHeaderField: "Cookie") == "WorkosCursorSessionToken=user_X::jwt") + // Auth is this header alone; URLSession must never substitute a stored + // cookie for it. + #expect(request.httpShouldHandleCookies == false) + #expect(request.url?.path == "/api/usage-summary") + #expect(request.httpMethod == "GET") + } + + @Test func mapsA401ToNotAuthenticated() async { + StubURLProtocol.reset(body: "{}", statusCode: 401) + await #expect(throws: DashboardError.notAuthenticated) { + try await makeAPI().usageSummary(token: token) + } + } + + @Test func mapsOtherFailureStatusesToHTTP() async { + StubURLProtocol.reset(body: "{}", statusCode: 503) + await #expect(throws: DashboardError.http(503)) { + try await makeAPI().usageSummary(token: token) + } + } + + @Test func mapsAnUndecodableBodyToDecode() async { + StubURLProtocol.reset(body: "not json") + await #expect(throws: DashboardError.self) { + try await makeAPI().usageSummary(token: token) + } + } +} + +/// A `URLProtocol` that answers every request from a canned response and +/// records what was sent. Serialized suite + reset-per-test keeps the shared +/// state safe (URLProtocol registration is inherently process-global). +private final class StubURLProtocol: URLProtocol, @unchecked Sendable { + private nonisolated(unsafe) static let lock = NSLock() + private nonisolated(unsafe) static var responseBody = "{}" + private nonisolated(unsafe) static var responseStatus = 200 + private nonisolated(unsafe) static var captured: URLRequest? + private nonisolated(unsafe) static var capturedBody: Data? + + static func reset(body: String, statusCode: Int = 200) { + lock.withLock { + responseBody = body + responseStatus = statusCode + captured = nil + capturedBody = nil + } + } + + /// The most recent request (headers, URL, method). + static var lastRequest: URLRequest? { + lock.withLock { captured } + } + + /// The most recent request body, decoded as a JSON object. + static var lastBodyObject: [String: Any]? { + guard let data = lock.withLock({ capturedBody }) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + // `URLProtocol` strips `httpBody` from the request it hands back, so + // read it from the body stream when present. + let body = request.httpBody ?? request.httpBodyStream.map(Self.drain) + Self.lock.withLock { + Self.captured = request + Self.capturedBody = body + } + + let (status, text) = Self.lock.withLock { (Self.responseStatus, Self.responseBody) } + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: nil, + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(text.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static func drain(_ stream: InputStream) -> Data { + stream.open() + defer { stream.close() } + var data = Data() + let size = 4096 + var buffer = [UInt8](repeating: 0, count: size) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: size) + guard read > 0 else { break } + data.append(buffer, count: read) + } + return data + } +} diff --git a/Ledger/LedgerCore/Tests/DashboardProviderTests.swift b/Ledger/LedgerCore/Tests/DashboardProviderTests.swift new file mode 100644 index 00000000..c6e2a99c --- /dev/null +++ b/Ledger/LedgerCore/Tests/DashboardProviderTests.swift @@ -0,0 +1,54 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct DashboardProviderTests { + private let token = SessionToken(cookieValue: "user_X::jwt") + + @Test func scriptedProviderReturnsItsScriptedSummary() async throws { + let provider = ScriptedDashboardProvider(summary: .fixture(onDemandCents: 4200)) + #expect(try await provider.usageSummary(token: token).onDemandCents == 4200) + } + + @Test func scriptedProviderThrowsItsFailureOutcome() async { + let provider = ScriptedDashboardProvider(.failure(.notAuthenticated)) + await #expect(throws: DashboardError.notAuthenticated) { + try await provider.usageSummary(token: token) + } + } + + @Test func scriptedProviderReturnsScriptedEvents() async throws { + let provider = ScriptedDashboardProvider( + .success(summary: .fixture(onDemandCents: 0)), + events: UsageEventFixture.events(["a": 100]), + ) + let page = try await provider.usageEvents( + startDate: .now, + endDate: .now, + page: 1, + pageSize: 250, + token: token, + ) + #expect(page.usageEventsDisplay.count == 1) + #expect(page.totalUsageEventsCount == 1) + } + + @Test func scriptedProviderCanFailOnlyEvents() async throws { + let provider = ScriptedDashboardProvider( + .success(summary: .fixture(onDemandCents: 10)), + eventsFailure: .http(500), + ) + // Summary still succeeds… + _ = try await provider.usageSummary(token: token) + // …while the per-model events call fails. + await #expect(throws: DashboardError.http(500)) { + try await provider.usageEvents( + startDate: .now, + endDate: .now, + page: 1, + pageSize: 250, + token: token, + ) + } + } +} diff --git a/Ledger/LedgerCore/Tests/KeychainStoreTests.swift b/Ledger/LedgerCore/Tests/KeychainStoreTests.swift new file mode 100644 index 00000000..7fbca8f0 --- /dev/null +++ b/Ledger/LedgerCore/Tests/KeychainStoreTests.swift @@ -0,0 +1,36 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct KeychainStoreTests { + @Test func readsBackWhatItWrites() throws { + let store = InMemoryKeychainStore() + #expect(try store.read() == nil) + + try store.write("api-key-123") + #expect(try store.read() == "api-key-123") + } + + @Test func writingWhitespaceRemovesTheSecret() throws { + let store = InMemoryKeychainStore(secret: "existing") + try store.write(" ") + #expect(try store.read() == nil) + } + + @Test func writeTrimsSurroundingWhitespace() throws { + let store = InMemoryKeychainStore() + try store.write(" padded-key\n") + #expect(try store.read() == "padded-key") + } + + @Test func removeClearsTheSecret() throws { + let store = InMemoryKeychainStore(secret: "existing") + try store.remove() + #expect(try store.read() == nil) + } + + @Test func surfacesInjectedFailures() { + let store = InMemoryKeychainStore(failure: KeychainError(status: -25300)) + #expect(throws: KeychainError.self) { try store.read() } + } +} diff --git a/Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift b/Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift new file mode 100644 index 00000000..da2283a9 --- /dev/null +++ b/Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift @@ -0,0 +1,37 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct LedgerConfigStoreTests { + /// A store in a unique temp directory that never touches the user's real + /// Application Support. + private func makeStore() -> (store: LedgerConfigStore, directory: URL) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LedgerConfigStoreTests-\(UUID().uuidString)") + return (LedgerConfigStore(directory: directory), directory) + } + + @Test func missingFileLoadsAsInitial() throws { + let (store, _) = makeStore() + #expect(try store.load() == .initial) + } + + @Test func roundTripsAConfiguration() throws { + let (store, directory) = makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + + let configuration = LedgerConfiguration(refreshInterval: 300) + try store.save(configuration) + #expect(try store.load() == configuration) + } + + @Test func corruptFileThrowsRatherThanResetting() throws { + let (store, directory) = makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("not json".utf8).write(to: directory.appendingPathComponent("configuration.json")) + + #expect(throws: (any Error).self) { try store.load() } + } +} diff --git a/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift new file mode 100644 index 00000000..d2021779 --- /dev/null +++ b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift @@ -0,0 +1,114 @@ +import Foundation +@_spi(Testing) import LedgerCore + +/// A `LoginItemBackend` that records register/unregister/open calls in memory +/// and can be told to fail, so login-item wiring is testable without touching +/// the real `SMAppService`. +@MainActor +final class LoginItemRecorder: LoginItemBackend { + private(set) var status: LoginItemStatus + private(set) var registerCount = 0 + private(set) var unregisterCount = 0 + private(set) var openCount = 0 + + /// When set, both `register()` and `unregister()` throw it (and leave + /// `status` unchanged), simulating an `SMAppService` failure. + var failure: (any Error)? + + init(status: LoginItemStatus = .notRegistered, failure: (any Error)? = nil) { + self.status = status + self.failure = failure + } + + func register() throws { + registerCount += 1 + if let failure { throw failure } + status = .enabled + } + + func unregister() throws { + unregisterCount += 1 + if let failure { throw failure } + status = .notRegistered + } + + func openSystemSettingsLoginItems() { + openCount += 1 + } +} + +/// A stand-in error for login-item failure injection. +struct LoginItemTestError: Error {} + +enum DashboardFixture { + /// Builds a signed-looking JWT (unsigned; only the payload matters) whose + /// `sub` is `sub`. base64url, no padding. + static func jwt(sub: String) -> String { + func segment(_ object: [String: Any]) -> String { + let data = try! JSONSerialization.data(withJSONObject: object) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return "\(segment(["alg": "HS256"])).\(segment(["sub": sub])).signature" + } + + /// A trimmed but realistic `/api/usage-summary` body. + static let usageSummaryJSON = """ + { + "billingCycleStart": "2026-07-04T18:16:08.000Z", + "billingCycleEnd": "2026-08-04T18:16:08.000Z", + "membershipType": "ultra", + "limitType": "user", + "isUnlimited": false, + "individualUsage": { + "plan": { + "enabled": true, + "used": 40000, + "limit": 40000, + "remaining": 0, + "breakdown": { "included": 40000, "bonus": 12158, "total": 52158 }, + "autoPercentUsed": 0.69, + "apiPercentUsed": 100, + "totalPercentUsed": 20.87 + }, + "onDemand": { "enabled": true, "used": 315609, "limit": null, "remaining": null } + }, + "teamUsage": {} + } + """ + + /// A `get-filtered-usage-events` body — with fields Ledger ignores, to + /// prove decoding tolerates them. + static let usageEventsJSON = """ + { + "totalUsageEventsCount": 40, + "usageEventsDisplay": [ + { + "timestamp": "1784939309797", + "model": "claude-opus-5-thinking-high", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 53.69, + "usageBasedCosts": "$5.37", + "isTokenBasedCall": true, + "chargedCents": 536.9, + "isChargeable": true + }, + { + "timestamp": "1784939000000", + "model": "claude-opus-4-8-thinking-high", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "usageBasedCosts": "$5.08", + "chargedCents": 508.28 + }, + { + "timestamp": "1784938000000", + "model": "github_bugbot", + "usageBasedCosts": "$0.00", + "chargedCents": 0 + } + ] + } + """ +} diff --git a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift new file mode 100644 index 00000000..6767e875 --- /dev/null +++ b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift @@ -0,0 +1,660 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +@MainActor +struct LedgerServicesTests { + private func makeServices( + provider: any DashboardProvider = ScriptedDashboardProvider(.failure(.network("unused"))), + manualToken: String? = nil, + autoToken: SessionToken? = nil, + tokenSource: (any SessionTokenSource)? = nil, + historyStore: SpendHistoryStore? = nil, + ) -> LedgerServices { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LedgerServicesTests-\(UUID().uuidString)") + let store = LedgerConfigStore(directory: directory) + return LedgerServices( + configStore: store, + keychain: InMemoryKeychainStore(secret: manualToken), + tokenSource: tokenSource ?? StubTokenSource(token: autoToken), + provider: provider, + loginItem: LoginItemController(backend: LoginItemRecorder()), + historyStore: historyStore ?? SpendHistoryStore(directory: directory), + ) + } + + /// A history store in its own temp directory, so a test can read back what + /// the services persisted. + private func makeHistoryStore() -> SpendHistoryStore { + SpendHistoryStore(directory: FileManager.default.temporaryDirectory + .appendingPathComponent("LedgerHistoryTests-\(UUID().uuidString)")) + } + + @Test func startsIdle() { + #expect(makeServices().loadState == .idle) + } + + @Test func failsWithMissingCredentialsWhenNoTokenAnywhere() async { + let services = makeServices(autoToken: nil) + await services.refresh(force: false) + #expect(services.loadState == .failed(.missingCredentials)) + } + + @Test func loadsUsingTheAutoDetectedToken() async { + let provider = ScriptedDashboardProvider(.success( + summary: .fixture( + onDemandCents: 5000, + membershipType: "ultra", + includedUsed: 40000, + includedLimit: 40000, + ), + )) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + await services.refresh(force: false) + + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + #expect(snapshot.currentCycleCents == 5000) + #expect(snapshot.membershipType == "ultra") + #expect(services.lastUpdated != nil) + } + + @Test func loadsUsingAPastedTokenWhenNoAutoToken() async { + let jwt = DashboardFixture.jwt(sub: "auth0|user_PASTE") + let provider = ScriptedDashboardProvider(summary: .fixture(onDemandCents: 999)) + let services = makeServices(provider: provider, manualToken: jwt, autoToken: nil) + #expect(services.hasManualToken) + + await services.refresh(force: false) + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + #expect(snapshot.currentCycleCents == 999) + } + + @Test func loadsModelSharesSortedByShare() async { + let provider = ScriptedDashboardProvider( + .success(summary: .fixture(onDemandCents: 5000)), + events: UsageEventFixture.events(["a": 75, "b": 25]), + ) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + await services.refresh(force: false) + + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + #expect(snapshot.modelShares.map(\.name) == ["a", "b"]) + #expect(snapshot.modelShares.first?.fraction == 0.75) + } + + @Test func stillLoadsWhenPerModelFetchFails() async { + // The per-model breakdown is best-effort: its failure must not blank + // the headline. + let provider = ScriptedDashboardProvider( + .success(summary: .fixture(onDemandCents: 5000)), + eventsFailure: .http(500), + ) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + await services.refresh(force: false) + + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + #expect(snapshot.currentCycleCents == 5000) + #expect(snapshot.modelShares.isEmpty) + } + + @Test func keepsLoadedDataAndFlagsStaleWhenARefreshFails() async { + let provider = MutableDashboardProvider(.success(.fixture(onDemandCents: 5000))) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + + await services.refresh(force: false) + #expect(isLoaded(services.loadState)) + #expect(services.loadError == nil) + + // A later refresh fails (e.g. offline): keep the data, mark it stale. + provider.result = .failure(.network("offline")) + await services.refresh(force: false) + #expect(isLoaded(services.loadState)) + #expect(services.loadError == .network("offline")) + + // Recovering clears the stale flag. + provider.result = .success(.fixture(onDemandCents: 5100)) + await services.refresh(force: false) + #expect(isLoaded(services.loadState)) + #expect(services.loadError == nil) + } + + @Test func firstLoadFailureShowsTheErrorState() async { + let services = makeServices( + provider: ScriptedDashboardProvider(.failure(.network("offline"))), + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + await services.refresh(force: false) + #expect(services.loadState == .failed(.network("offline"))) + #expect(services.loadError == nil) + } + + @Test func mapsNotAuthenticated() async { + let services = makeServices( + provider: ScriptedDashboardProvider(.failure(.notAuthenticated)), + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + await services.refresh(force: false) + #expect(services.loadState == .failed(.notAuthenticated)) + } + + @Test func mapsNetworkErrors() async { + let services = makeServices( + provider: ScriptedDashboardProvider(.failure(.network("offline"))), + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + await services.refresh(force: false) + #expect(services.loadState == .failed(.network("offline"))) + } + + @Test func tracksManualTokenPresence() throws { + let services = makeServices() + #expect(!services.hasManualToken) + + try services.setManualToken(DashboardFixture.jwt(sub: "auth0|user_X")) + #expect(services.hasManualToken) + + try services.clearManualToken() + #expect(!services.hasManualToken) + } + + @Test func reportsAutoTokenAvailability() { + #expect(makeServices(autoToken: nil).autoTokenAvailable == false) + #expect(makeServices(autoToken: SessionToken(cookieValue: "a::b")) + .autoTokenAvailable == true) + } + + @Test func errorMessagesAreActionable() { + #expect(LedgerServices.LoadError.missingCredentials.message.contains("Cursor")) + #expect(LedgerServices.LoadError.notAuthenticated.message.contains("expired")) + } + + @Test func keepsLoadedDataVisibleDuringARefresh() async { + let provider = GatedDashboardProvider(summary: .fixture(onDemandCents: 5000)) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + + // First load completes (the first usage-summary call isn't gated). + await services.refresh(force: false) + #expect(isLoaded(services.loadState)) + #expect(!services.isRefreshing) + + // A second refresh suspends inside usage-summary. + let task = Task { await services.refresh(force: false) } + await waitUntil { services.isRefreshing } + + // The already-loaded data stays on screen — not cleared to `.loading`. + #expect(isLoaded(services.loadState)) + + provider.release() + await task.value + #expect(!services.isRefreshing) + #expect(isLoaded(services.loadState)) + } + + // MARK: - Per-model throttle & pagination + + @Test func throttlesThePerModelFetchAcrossPeriodicRefreshes() async { + let provider = CountingDashboardProvider( + summary: .fixture(onDemandCents: 5000), + events: UsageEventFixture.events(["a": 75, "b": 25]), + ) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + + await services.refresh(force: false) + #expect(provider.eventFetches == 1) + + // A second periodic refresh reuses the cached breakdown — walking every + // event again on each headline refresh is far too expensive. + await services.refresh(force: false) + #expect(provider.eventFetches == 1) + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + #expect(snapshot.modelShares.map(\.name) == ["a", "b"]) + } + + @Test func anExplicitRefreshForcesThePerModelFetch() async { + let provider = CountingDashboardProvider( + summary: .fixture(onDemandCents: 5000), + events: UsageEventFixture.events(["a": 100]), + ) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + + await services.refresh(force: false) + await services.refresh(force: true) + #expect(provider.eventFetches == 2) + } + + @Test func aNewBillingCycleInvalidatesTheCachedBreakdown() async { + let provider = CountingDashboardProvider( + summary: .fixture(onDemandCents: 5000), + events: UsageEventFixture.events(["a": 100]), + ) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + + await services.refresh(force: false) + // The cycle rolls over: the cached breakdown belongs to the old cycle. + provider.summary = .fixture( + onDemandCents: 10, + cycleStart: "2026-08-04T18:16:08.000Z", + cycleEnd: "2026-09-04T18:16:08.000Z", + ) + await services.refresh(force: false) + #expect(provider.eventFetches == 2) + } + + @Test func keepsTheLastBreakdownWhenAPerModelFetchFails() async { + let provider = CountingDashboardProvider( + summary: .fixture(onDemandCents: 5000), + events: UsageEventFixture.events(["a": 100]), + ) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + await services.refresh(force: false) + + provider.eventsFailure = .http(500) + await services.refresh(force: true) + + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + // A transient per-model failure must not blank a good breakdown. + #expect(snapshot.modelShares.map(\.name) == ["a"]) + } + + @Test func paginatesEveryEventInTheCycle() async { + // 600 events over a 250-event page size → three pages. + let events = Array(repeating: UsageEvent(model: "a", chargedCents: 1), count: 400) + + Array(repeating: UsageEvent(model: "b", chargedCents: 1), count: 200) + let provider = CountingDashboardProvider( + summary: .fixture(onDemandCents: 5000), + events: events, + ) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + + await services.refresh(force: false) + + #expect(provider.eventPageRequests == 3) + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + // All 600 events counted: a = 400/600, b = 200/600. + #expect(snapshot.modelShares.map(\.name) == ["a", "b"]) + #expect(abs((snapshot.modelShares.first?.fraction ?? 0) - 2.0 / 3.0) < 0.0001) + } + + // MARK: - Token caching + + @Test func readsTheTokenOnceAcrossRefreshes() async { + let source = CountingTokenSource(token: SessionToken(cookieValue: "auto::jwt")) + let services = makeServices( + provider: ScriptedDashboardProvider(summary: .fixture(onDemandCents: 5000)), + tokenSource: source, + ) + // Resolved once during init… + #expect(source.reads == 1) + + await services.refresh(force: false) + await services.refresh(force: false) + + // …and reused after that: reading it opens Cursor's SQLite store, which + // shouldn't happen on every refresh. + #expect(source.reads == 1) + } + + @Test func reReadsTheTokenAfterItIsRejected() async { + let source = CountingTokenSource(token: SessionToken(cookieValue: "stale::jwt")) + let provider = MutableDashboardProvider(.failure(.notAuthenticated)) + let services = makeServices(provider: provider, tokenSource: source) + #expect(source.reads == 1) + + // A 401 means the cached credential is no good — the user may have + // signed back in to Cursor since. + await services.refresh(force: false) + #expect(services.loadState == .failed(.notAuthenticated)) + + source.token = SessionToken(cookieValue: "fresh::jwt") + provider.result = .success(.fixture(onDemandCents: 4200)) + await services.refresh(force: false) + + #expect(source.reads == 2) + #expect(isLoaded(services.loadState)) + } + + @Test func refreshTokenStatusRereadsTheSources() { + let source = CountingTokenSource(token: nil) + let services = makeServices(tokenSource: source) + #expect(!services.autoTokenAvailable) + + // The user signs in to Cursor while Ledger runs; Settings asks again. + source.token = SessionToken(cookieValue: "auto::jwt") + services.refreshTokenStatus() + + #expect(services.autoTokenAvailable) + #expect(source.reads == 2) + } + + // MARK: - Superseded refreshes + + @Test func aSupersededRefreshDoesNotRecordHistory() async { + let historyStore = makeHistoryStore() + let provider = FirstCallGatedProvider( + gated: .fixture(onDemandCents: 5000), + later: .fixture(onDemandCents: 5100), + ) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + historyStore: historyStore, + ) + + // A slow refresh (reading the older total) suspends mid-flight… + let stale = Task { await services.refresh(force: false) } + await waitUntil { provider.gatedCallStarted } + // …a newer one overtakes and completes… + await services.refresh(force: false) + // …then the stale one lands. + provider.release() + await stale.value + + // Only the winning refresh recorded a sample: appending the older + // reading at a later timestamp would skew future day/week baselines. + let samples = (try? historyStore.load()) ?? [] + #expect(samples.count == 1) + #expect(samples.first?.onDemandCents == 5100) + } + + private func isLoaded(_ state: LedgerServices.LoadState) -> Bool { + if case .loaded = state { true } else { false } + } + + private func waitUntil(_ predicate: () -> Bool) async { + for _ in 0 ..< 1000 { + if predicate() { return } + await Task.yield() + } + } +} + +/// A `SessionTokenSource` that counts reads and can change its token, so a +/// test can prove the credential is cached rather than re-read every refresh. +private final class CountingTokenSource: SessionTokenSource, @unchecked Sendable { + private let lock = NSLock() + private var _token: SessionToken? + private var _reads = 0 + + init(token: SessionToken?) { + _token = token + } + + var reads: Int { + lock.withLock { _reads } + } + + var token: SessionToken? { + get { lock.withLock { _token } } + set { lock.withLock { _token = newValue } } + } + + func currentToken() -> SessionToken? { + lock.withLock { + _reads += 1 + return _token + } + } +} + +/// A `DashboardProvider` that serves a (swappable) summary and a fixed event +/// list sliced into pages, counting how many event pages — and how many +/// distinct fetches — were requested. +private final class CountingDashboardProvider: DashboardProvider, @unchecked Sendable { + private let lock = NSLock() + private var _summary: UsageSummary + private let events: [UsageEvent] + private var _eventPageRequests = 0 + private var _eventFetches = 0 + private var _eventsFailure: DashboardError? + + init(summary: UsageSummary, events: [UsageEvent]) { + _summary = summary + self.events = events + } + + var summary: UsageSummary { + get { lock.withLock { _summary } } + set { lock.withLock { _summary = newValue } } + } + + /// When set, every `usageEvents` call throws it. + var eventsFailure: DashboardError? { + get { lock.withLock { _eventsFailure } } + set { lock.withLock { _eventsFailure = newValue } } + } + + /// Total pages requested (pagination depth across all fetches). + var eventPageRequests: Int { + lock.withLock { _eventPageRequests } + } + + /// Distinct per-model fetches (counted by first-page requests). + var eventFetches: Int { + lock.withLock { _eventFetches } + } + + func usageSummary(token _: SessionToken) async throws -> UsageSummary { + summary + } + + func usageEvents( + startDate _: Date, + endDate _: Date, + page: Int, + pageSize: Int, + token _: SessionToken, + ) async throws -> UsageEventsPage { + let failure: DashboardError? = lock.withLock { + _eventPageRequests += 1 + if page == 1 { _eventFetches += 1 } + return _eventsFailure + } + if let failure { throw failure } + + let start = (page - 1) * pageSize + guard start < events.count else { + return UsageEventsPage(usageEventsDisplay: [], totalUsageEventsCount: events.count) + } + let slice = Array(events[start ..< min(start + pageSize, events.count)]) + return UsageEventsPage(usageEventsDisplay: slice, totalUsageEventsCount: events.count) + } +} + +/// A `DashboardProvider` whose *first* `usageSummary` call blocks until +/// `release()` (later calls return immediately), so a test can start a slow +/// refresh, let a newer one overtake it, and then land the stale response. +private final class FirstCallGatedProvider: DashboardProvider, @unchecked Sendable { + private let lock = NSLock() + private let gatedSummary: UsageSummary + private let laterSummary: UsageSummary + private var callCount = 0 + private var stored: CheckedContinuation? + private var released = false + private var started = false + + init(gated: UsageSummary, later: UsageSummary) { + gatedSummary = gated + laterSummary = later + } + + /// Whether the gated (first) call has been entered. + var gatedCallStarted: Bool { + lock.withLock { started } + } + + func usageSummary(token _: SessionToken) async throws -> UsageSummary { + let isGated = lock.withLock { () -> Bool in + callCount += 1 + if callCount == 1 { + started = true + return true + } + return false + } + guard isGated else { return laterSummary } + + await withCheckedContinuation { (continuation: CheckedContinuation) in + let releaseNow = lock.withLock { () -> Bool in + if released { return true } + stored = continuation + return false + } + if releaseNow { continuation.resume() } + } + return gatedSummary + } + + func usageEvents( + startDate _: Date, + endDate _: Date, + page _: Int, + pageSize _: Int, + token _: SessionToken, + ) async throws -> UsageEventsPage { + UsageEventsPage(usageEventsDisplay: [], totalUsageEventsCount: 0) + } + + func release() { + let continuation = lock.withLock { () -> CheckedContinuation? in + released = true + let stored = stored + self.stored = nil + return stored + } + continuation?.resume() + } +} + +/// A `DashboardProvider` whose usage-summary result can be swapped between +/// calls, so a test can simulate a success followed by a failure. +private final class MutableDashboardProvider: DashboardProvider, @unchecked Sendable { + private let lock = NSLock() + private var _result: Result + + init(_ result: Result) { + _result = result + } + + var result: Result { + get { lock.withLock { _result } } + set { lock.withLock { _result = newValue } } + } + + func usageSummary(token _: SessionToken) async throws -> UsageSummary { + try result.get() + } + + func usageEvents( + startDate _: Date, + endDate _: Date, + page _: Int, + pageSize _: Int, + token _: SessionToken, + ) async throws -> UsageEventsPage { + UsageEventsPage(usageEventsDisplay: [], totalUsageEventsCount: 0) + } +} + +/// A `DashboardProvider` whose *second* `usageSummary` call blocks until +/// `release()`, so a test can observe the in-flight-refresh state. +private final class GatedDashboardProvider: DashboardProvider, @unchecked Sendable { + private let summary: UsageSummary + private let lock = NSLock() + private var callCount = 0 + private var stored: CheckedContinuation? + private var released = false + + init(summary: UsageSummary) { + self.summary = summary + } + + func usageSummary(token _: SessionToken) async throws -> UsageSummary { + let shouldGate = lock.withLock { + callCount += 1 + return callCount >= 2 + } + if shouldGate { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let releaseNow = lock.withLock { () -> Bool in + if released { return true } + stored = continuation + return false + } + if releaseNow { continuation.resume() } + } + } + return summary + } + + func usageEvents( + startDate _: Date, + endDate _: Date, + page _: Int, + pageSize _: Int, + token _: SessionToken, + ) async throws -> UsageEventsPage { + UsageEventsPage(usageEventsDisplay: [], totalUsageEventsCount: 0) + } + + func release() { + let continuation = lock.withLock { () -> CheckedContinuation? in + released = true + let stored = stored + self.stored = nil + return stored + } + continuation?.resume() + } +} diff --git a/Ledger/LedgerCore/Tests/LoginItemControllerTests.swift b/Ledger/LedgerCore/Tests/LoginItemControllerTests.swift new file mode 100644 index 00000000..b020b9f8 --- /dev/null +++ b/Ledger/LedgerCore/Tests/LoginItemControllerTests.swift @@ -0,0 +1,84 @@ +@_spi(Testing) import LedgerCore +import Testing + +@MainActor +struct LoginItemControllerTests { + @Test func seedsStateFromTheBackend() { + let off = LoginItemController(backend: LoginItemRecorder(status: .notRegistered)) + #expect(!off.isEnabled) + #expect(!off.needsApproval) + + let on = LoginItemController(backend: LoginItemRecorder(status: .enabled)) + #expect(on.isEnabled) + #expect(!on.needsApproval) + } + + @Test func requiresApprovalReadsAsEnabledButPending() { + let controller = LoginItemController(backend: LoginItemRecorder(status: .requiresApproval)) + + // Registered-but-pending is "on" for the toggle — not off — with a + // flag the UI can use to nudge the user toward System Settings. + #expect(controller.isEnabled) + #expect(controller.needsApproval) + } + + @Test func enablingRegistersAndDisablingUnregisters() throws { + let recorder = LoginItemRecorder() + let controller = LoginItemController(backend: recorder) + + try controller.setEnabled(true) + #expect(controller.isEnabled) + #expect(recorder.registerCount == 1) + + // Re-enabling is a no-op: the backend isn't touched again. + try controller.setEnabled(true) + #expect(recorder.registerCount == 1) + + try controller.setEnabled(false) + #expect(!controller.isEnabled) + #expect(recorder.unregisterCount == 1) + } + + @Test func disablingAPendingItemStillUnregisters() throws { + let recorder = LoginItemRecorder(status: .requiresApproval) + let controller = LoginItemController(backend: recorder) + + try controller.setEnabled(false) + #expect(recorder.unregisterCount == 1) + #expect(!controller.isEnabled) + } + + @Test func aFailedRegistrationLeavesTheStateHonestlyOff() { + let recorder = LoginItemRecorder(failure: LoginItemTestError()) + let controller = LoginItemController(backend: recorder) + + #expect(throws: LoginItemTestError.self) { + try controller.setEnabled(true) + } + // The register attempt happened, but it failed — the observed value + // must not read as "on". + #expect(recorder.registerCount == 1) + #expect(!controller.isEnabled) + } + + @Test func refreshPicksUpAnOutOfBandChange() { + let recorder = LoginItemRecorder(status: .notRegistered) + let controller = LoginItemController(backend: recorder) + #expect(!controller.isEnabled) + + // Simulate the user enabling the login item in System Settings. + try? recorder.register() + #expect(!controller.isEnabled) + + controller.refresh() + #expect(controller.isEnabled) + } + + @Test func openSystemSettingsForwardsToTheBackend() { + let recorder = LoginItemRecorder() + let controller = LoginItemController(backend: recorder) + + controller.openSystemSettingsLoginItems() + #expect(recorder.openCount == 1) + } +} diff --git a/Ledger/LedgerCore/Tests/ModelNameTests.swift b/Ledger/LedgerCore/Tests/ModelNameTests.swift new file mode 100644 index 00000000..118f6f68 --- /dev/null +++ b/Ledger/LedgerCore/Tests/ModelNameTests.swift @@ -0,0 +1,63 @@ +@_spi(Testing) import LedgerCore +import Testing + +struct ModelNameTests { + @Test func parsesClaudeWithHyphenatedVersionAndEffort() { + let name = ModelName.parse("claude-opus-4-8-thinking-xhigh") + #expect(name.displayName == "Claude Opus 4.8") + #expect(name.badges == ["xhigh"]) + } + + @Test func keepsDottedVersionsAndMapsEffort() { + let name = ModelName.parse("composer-2.5-fast") + #expect(name.displayName == "Composer 2.5") + #expect(name.badges == ["fast"]) + } + + @Test func dropsThinkingFromTheName() { + let name = ModelName.parse("claude-fable-5-thinking-high") + #expect(name.displayName == "Claude Fable 5") + #expect(name.badges == ["high"]) + } + + @Test func handlesEffortBeforeThinking() { + let name = ModelName.parse("claude-4.6-sonnet-high-thinking") + #expect(name.displayName == "Claude 4.6 Sonnet") + #expect(name.badges == ["high"]) + } + + @Test func mapsUnderscoreIdentifiers() { + let name = ModelName.parse("github_bugbot") + #expect(name.displayName == "GitHub Bugbot") + #expect(name.badges.isEmpty) + } + + @Test func extractsNonMaxModeBeforeEffort() { + let name = ModelName.parse("non-max-claude-opus-4-8-thinking-xhigh") + #expect(name.displayName == "Claude Opus 4.8") + #expect(name.badges == ["non-max", "xhigh"]) + } + + @Test func dropsHostingPrefix() { + let name = ModelName.parse("cursor-grok-4.5-high") + #expect(name.displayName == "Grok 4.5") + #expect(name.badges == ["high"]) + } + + @Test func keepsFamilyWordsLikeSol() { + let name = ModelName.parse("gpt-5.6-sol-high") + #expect(name.displayName == "GPT 5.6 Sol") + #expect(name.badges == ["high"]) + } + + @Test func titleCasesUnknownModels() { + let name = ModelName.parse("mistral-large-2") + #expect(name.displayName == "Mistral Large 2") + #expect(name.badges.isEmpty) + } + + @Test func emptyFallsBackToRaw() { + #expect(ModelName.parse("").displayName == "") + #expect(ModelName.parse(" ").displayName == " ") + } +} diff --git a/Ledger/LedgerCore/Tests/SessionTokenSourceTests.swift b/Ledger/LedgerCore/Tests/SessionTokenSourceTests.swift new file mode 100644 index 00000000..c8b0f850 --- /dev/null +++ b/Ledger/LedgerCore/Tests/SessionTokenSourceTests.swift @@ -0,0 +1,52 @@ +import Foundation +@_spi(Testing) import LedgerCore +import SQLite3 +import Testing + +struct SessionTokenSourceTests { + @Test func returnsNilWhenTheDatabaseIsMissing() { + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("nope-\(UUID().uuidString).vscdb") + let source = CursorLocalTokenSource(databaseURL: missing) + #expect(source.currentToken() == nil) + } + + @Test func readsAndDerivesTheTokenFromAStateStore() throws { + let jwt = DashboardFixture.jwt(sub: "auth0|user_STATE") + let url = try makeStateDB(accessToken: jwt) + defer { try? FileManager.default.removeItem(at: url) } + + let source = CursorLocalTokenSource(databaseURL: url) + #expect(source.currentToken()?.cookieValue == "user_STATE::\(jwt)") + } + + @Test func returnsNilWhenTheKeyIsAbsent() throws { + let url = try makeStateDB(accessToken: nil) + defer { try? FileManager.default.removeItem(at: url) } + + let source = CursorLocalTokenSource(databaseURL: url) + #expect(source.currentToken() == nil) + } + + /// Creates a minimal `state.vscdb`-shaped SQLite file with an `ItemTable`, + /// optionally seeding `cursorAuth/accessToken`. + private func makeStateDB(accessToken: String?) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("state-\(UUID().uuidString).vscdb") + var db: OpaquePointer? + #expect(sqlite3_open(url.path, &db) == SQLITE_OK) + defer { sqlite3_close(db) } + #expect(sqlite3_exec( + db, + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)", + nil, + nil, + nil, + ) == SQLITE_OK) + if let accessToken { + let sql = "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', '\(accessToken)')" + #expect(sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK) + } + return url + } +} diff --git a/Ledger/LedgerCore/Tests/SessionTokenTests.swift b/Ledger/LedgerCore/Tests/SessionTokenTests.swift new file mode 100644 index 00000000..0b67f7b9 --- /dev/null +++ b/Ledger/LedgerCore/Tests/SessionTokenTests.swift @@ -0,0 +1,31 @@ +@_spi(Testing) import LedgerCore +import Testing + +struct SessionTokenTests { + @Test func derivesUserIDFromJWTSubAndStripsProvider() { + let jwt = DashboardFixture.jwt(sub: "auth0|user_01ABC") + let token = SessionToken(rawToken: jwt) + #expect(token?.cookieValue == "user_01ABC::\(jwt)") + } + + @Test func keepsAnAlreadyFormedUserIDColonColonJWTVerbatim() { + let jwt = DashboardFixture.jwt(sub: "auth0|user_01ABC") + let combined = "user_01ABC::\(jwt)" + #expect(SessionToken(rawToken: combined)?.cookieValue == combined) + } + + @Test func subWithoutProviderPrefixIsUsedAsIs() { + let jwt = DashboardFixture.jwt(sub: "user_bare") + #expect(SessionToken(rawToken: jwt)?.cookieValue == "user_bare::\(jwt)") + } + + @Test func nonJWTIsKeptVerbatimSoItSurfacesAsAn401() { + // Not parseable as a JWT: kept as-is rather than dropped, so it fails + // honestly at the API instead of silently vanishing. + #expect(SessionToken(rawToken: "not-a-jwt")?.cookieValue == "not-a-jwt") + } + + @Test func emptyIsNil() { + #expect(SessionToken(rawToken: " ") == nil) + } +} diff --git a/Ledger/LedgerCore/Tests/SpendHistoryStoreTests.swift b/Ledger/LedgerCore/Tests/SpendHistoryStoreTests.swift new file mode 100644 index 00000000..e642a1c4 --- /dev/null +++ b/Ledger/LedgerCore/Tests/SpendHistoryStoreTests.swift @@ -0,0 +1,49 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct SpendHistoryStoreTests { + private func makeStore() -> (store: SpendHistoryStore, directory: URL) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendHistoryStoreTests-\(UUID().uuidString)") + return (SpendHistoryStore(directory: directory), directory) + } + + private func sample(_ offset: TimeInterval, _ cents: Int) -> SpendSample { + SpendSample( + timestamp: Date(timeIntervalSince1970: offset), + cycleStart: nil, + onDemandCents: cents, + ) + } + + @Test func missingFileLoadsEmpty() throws { + let (store, _) = makeStore() + #expect(try store.load().isEmpty) + } + + @Test func roundTripsSamples() throws { + let (store, directory) = makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + + let samples = [sample(1000, 100), sample(2000, 200)] + try store.save(samples) + #expect(try store.load() == samples) + } + + @Test func prunesSamplesOlderThanRetention() { + let (store, _) = makeStore() + let now = Date(timeIntervalSince1970: 1_000_000) + let fresh = SpendSample( + timestamp: now.addingTimeInterval(-60), + cycleStart: nil, + onDemandCents: 200, + ) + let stale = SpendSample( + timestamp: now.addingTimeInterval(-SpendHistoryStore.retention - 60), + cycleStart: nil, + onDemandCents: 100, + ) + #expect(store.pruned([stale, fresh], now: now) == [fresh]) + } +} diff --git a/Ledger/LedgerCore/Tests/SpendHistoryTests.swift b/Ledger/LedgerCore/Tests/SpendHistoryTests.swift new file mode 100644 index 00000000..628354d5 --- /dev/null +++ b/Ledger/LedgerCore/Tests/SpendHistoryTests.swift @@ -0,0 +1,106 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct SpendHistoryTests { + private let cycleStart = date(2026, 7, 4, 18, 16) + private let now = date(2026, 7, 15, 12, 0) + + private static func calendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + private static func date(_ y: Int, _ mo: Int, _ d: Int, _ h: Int = 0, _ mi: Int = 0) -> Date { + calendar().date(from: DateComponents(year: y, month: mo, day: d, hour: h, minute: mi))! + } + + private func sample(_ timestamp: Date, _ cents: Int, cycle: Date? = nil) -> SpendSample { + SpendSample(timestamp: timestamp, cycleStart: cycle ?? cycleStart, onDemandCents: cents) + } + + @Test func differencesTodayAndThisWeekFromBaselines() throws { + let calendar = Self.calendar() + let dayStart = calendar.startOfDay(for: now) + let weekStart = try #require(calendar.dateInterval(of: .weekOfYear, for: now)?.start) + + let current = sample(now, 145_000) + let samples = [ + sample(weekStart, 100_000), + sample(dayStart, 130_000), + current, + ] + let deltas = SpendHistory.deltas( + current: current, + samples: samples, + calendar: calendar, + now: now, + ) + + #expect(deltas.todayCents == 15000) // 145000 - 130000 + #expect(deltas.thisWeekCents == 45000) // 145000 - 100000 + } + + @Test func returnsNilWithoutEnoughHistory() { + let calendar = Self.calendar() + let current = sample(now, 145_000) + // Only the current sample — no baseline near the window starts. + let deltas = SpendHistory.deltas( + current: current, + samples: [current], + calendar: calendar, + now: now, + ) + #expect(deltas.todayCents == nil) + #expect(deltas.thisWeekCents == nil) + } + + @Test func countsWholeCycleWhenItBeganInsideTheWindow() { + let calendar = Self.calendar() + // Cycle started today, so today's (and the week's) baseline is 0. + let current = sample( + now, + 4200, + cycle: calendar.startOfDay(for: now).addingTimeInterval(3600), + ) + let deltas = SpendHistory.deltas( + current: current, + samples: [current], + calendar: calendar, + now: now, + ) + #expect(deltas.todayCents == 4200) + #expect(deltas.thisWeekCents == 4200) + } + + @Test func ignoresSamplesFromOtherCycles() throws { + let calendar = Self.calendar() + let weekStart = try #require(calendar.dateInterval(of: .weekOfYear, for: now)?.start) + let current = sample(now, 145_000) + // A sample at week start, but from the previous cycle — must not be a + // baseline for the current cycle. + let previousCycle = sample(weekStart, 100_000, cycle: Self.date(2026, 6, 4)) + let deltas = SpendHistory.deltas( + current: current, + samples: [previousCycle, current], + calendar: calendar, + now: now, + ) + #expect(deltas.thisWeekCents == nil) + } + + @Test func clampsNegativeDeltasToZero() { + let calendar = Self.calendar() + let dayStart = calendar.startOfDay(for: now) + let current = sample(now, 120_000) + // Baseline higher than current (e.g. an adjustment) → not negative. + let deltas = SpendHistory.deltas( + current: current, + samples: [sample(dayStart, 130_000), current], + calendar: calendar, + now: now, + ) + #expect(deltas.todayCents == 0) + } +} diff --git a/Ledger/LedgerCore/Tests/UsageEventsTests.swift b/Ledger/LedgerCore/Tests/UsageEventsTests.swift new file mode 100644 index 00000000..0c44186d --- /dev/null +++ b/Ledger/LedgerCore/Tests/UsageEventsTests.swift @@ -0,0 +1,43 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct UsageEventsTests { + @Test func decodesAPageIgnoringUnknownFields() throws { + let page = try JSONDecoder().decode( + UsageEventsPage.self, + from: Data(DashboardFixture.usageEventsJSON.utf8), + ) + #expect(page.totalUsageEventsCount == 40) + #expect(page.usageEventsDisplay.count == 3) + let first = try #require(page.usageEventsDisplay.first) + #expect(first.model == "claude-opus-5-thinking-high") + #expect(first.cents == 536.9) + } + + @Test func sharesAggregatePerModelHighestFirst() { + let events = [ + UsageEvent(model: "a", chargedCents: 30), + UsageEvent(model: "b", chargedCents: 10), + UsageEvent(model: "a", chargedCents: 10), // a totals 40 + ] + let shares = ModelShare.shares(from: events) + #expect(shares.map(\.name) == ["a", "b"]) + #expect(shares[0].fraction == 0.8) // 40 / 50 + #expect(shares[1].fraction == 0.2) + } + + @Test func sharesIgnoreZeroAndNegativeCosts() { + let events = [ + UsageEvent(model: "a", chargedCents: 100), + UsageEvent(model: "free", chargedCents: 0), + UsageEvent(model: "credit", chargedCents: -50), + ] + #expect(ModelShare.shares(from: events).map(\.name) == ["a"]) + } + + @Test func sharesAreEmptyWithoutChargedUsage() { + #expect(ModelShare.shares(from: []).isEmpty) + #expect(ModelShare.shares(from: [UsageEvent(model: "a", chargedCents: 0)]).isEmpty) + } +} diff --git a/Ledger/LedgerCore/Tests/UsageSummaryTests.swift b/Ledger/LedgerCore/Tests/UsageSummaryTests.swift new file mode 100644 index 00000000..ebe6bcec --- /dev/null +++ b/Ledger/LedgerCore/Tests/UsageSummaryTests.swift @@ -0,0 +1,50 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct UsageSummaryTests { + @Test func decodesTheDashboardBodyIgnoringUnknownFields() throws { + let summary = try JSONDecoder().decode( + UsageSummary.self, + from: Data(DashboardFixture.usageSummaryJSON.utf8), + ) + + #expect(summary.membershipType == "ultra") + #expect(summary.onDemandCents == 315_609) + #expect(summary.individualUsage.plan.used == 40000) + #expect(summary.individualUsage.plan.limit == 40000) + #expect(summary.individualUsage.plan.breakdown?.total == 52158) + #expect(summary.individualUsage.onDemand.limit == nil) + } + + @Test func exposesFirstPartyAndAPIPoolFractions() throws { + let summary = try JSONDecoder().decode( + UsageSummary.self, + from: Data(DashboardFixture.usageSummaryJSON.utf8), + ) + #expect(summary.autoFractionUsed == 0.0069) + #expect(summary.apiFractionUsed == 1.0) + } + + @Test func parsesFractionalSecondISO8601CycleDates() throws { + let summary = try JSONDecoder().decode( + UsageSummary.self, + from: Data(DashboardFixture.usageSummaryJSON.utf8), + ) + let start = try #require(summary.cycleStart) + let end = try #require(summary.cycleEnd) + #expect(end > start) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let expected = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 7, + day: 4, + hour: 18, + minute: 16, + second: 8, + ))) + #expect(start == expected) + } +} diff --git a/Ledger/install b/Ledger/install new file mode 100755 index 00000000..09a7741f --- /dev/null +++ b/Ledger/install @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# Build Ledger in Release and install it to /Applications so it runs standalone +# (no Xcode needed). Usage: +# +# Ledger/install build, install to /Applications, and launch +# Ledger/install --no-open build and install, but don't launch +# Ledger/install --help show this help +# +# The app is ad-hoc code-signed, so it works without an Apple Developer account; +# it's built locally (no quarantine), so Gatekeeper won't block it. + +set -euo pipefail + +APP_NAME="Ledger" +DEST="/Applications/${APP_NAME}.app" +OPEN_AFTER=1 + +for arg in "$@"; do + case "$arg" in + --no-open) OPEN_AFTER=0 ;; + -h | --help) + sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown option: $arg (try --help)" >&2 + exit 2 + ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +DERIVED="$(mktemp -d)" +trap 'rm -rf "$DERIVED"' EXIT + +echo "==> Generating the Xcode project" +mise exec -- tuist generate --no-open >/dev/null + +echo "==> Building ${APP_NAME} (Release)" +# Build straight through xcodebuild (not `tuist build`) so we control the +# DerivedData path and can find the product. Ad-hoc signing keeps it +# account-free while still satisfying SMAppService (launch-at-login) + Keychain. +mise exec -- xcodebuild \ + -workspace "Stuff.xcworkspace" \ + -scheme "${APP_NAME}" \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -derivedDataPath "$DERIVED" \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=YES \ + build + +BUILT="${DERIVED}/Build/Products/Release/${APP_NAME}.app" +if [ ! -d "$BUILT" ]; then + echo "error: build product not found at ${BUILT}" >&2 + exit 1 +fi + +echo "==> Installing to ${DEST}" +# Quit any running copy (an Xcode-launched one, or a previously installed one) +# so the replace is clean. `quit` is asynchronous, so wait for the installed +# binary to actually exit before replacing it, then force-kill as a fallback. +INSTALLED_BIN="${DEST}/Contents/MacOS/${APP_NAME}" +osascript -e "tell application \"${APP_NAME}\" to quit" >/dev/null 2>&1 || true +for _ in $(seq 1 50); do + pgrep -f "$INSTALLED_BIN" >/dev/null 2>&1 || break + sleep 0.1 +done +pkill -f "$INSTALLED_BIN" 2>/dev/null || true + +rm -rf "$DEST" +cp -R "$BUILT" "$DEST" + +echo "==> Installed ${APP_NAME} to ${DEST}" +if [ "$OPEN_AFTER" -eq 1 ]; then + open "$DEST" + echo "==> Launched. Look for the \$ amount in your menu bar." +fi diff --git a/Package.swift b/Package.swift index 076a0858..6f1d9118 100644 --- a/Package.swift +++ b/Package.swift @@ -6,10 +6,12 @@ let package = Package( defaultLocalization: "en", platforms: [ .iOS(.v26), + .macOS(.v26), ], products: [ .library(name: "StuffCore", targets: ["StuffCore"]), .library(name: "CreditKit", targets: ["CreditKit"]), + .library(name: "LedgerCore", targets: ["LedgerCore"]), .library(name: "LifecycleKit", targets: ["LifecycleKit"]), .library(name: "LifecycleKitUI", targets: ["LifecycleKitUI"]), .library(name: "JournalKit", targets: ["JournalKit"]), @@ -48,6 +50,13 @@ let package = Package( name: "CreditKit", path: "Shared/CreditKit/Sources", ), + .target( + name: "LedgerCore", + dependencies: [ + .target(name: "PeriscopeCore"), + ], + path: "Ledger/LedgerCore/Sources", + ), .target( name: "LifecycleKit", path: "Shared/LifecycleKit/Sources", diff --git a/Project.swift b/Project.swift index 7ce73d63..268cc135 100644 --- a/Project.swift +++ b/Project.swift @@ -3,6 +3,10 @@ import ProjectDescription let destinations: Destinations = [.iPhone, .iPad] let deployment: DeploymentTargets = .iOS("26.0") +/// The Ledger menu bar app is the only native-macOS target; everything else +/// stays on the shared iOS destinations above. +let macDeployment: DeploymentTargets = .macOS("26.0") + /// Local Swift package (see root `Package.swift`) for the library products /// (StuffCore, WhereCore, WhereUI, TestHostSupport, the Broadway modules, …). private let stuffPackage = Package.local(path: .relativeToRoot(".")) @@ -241,6 +245,56 @@ let project = Project( "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "", ]), ), + .target( + name: "Ledger", + destinations: [.mac], + product: .app, + bundleId: "com.stuff.ledger", + deploymentTargets: macDeployment, + // Full custom plist (not `.extendingDefault`) so the macOS defaults + // can't sneak in a `NSMainStoryboardFile` — Ledger is a pure + // SwiftUI/AppKit menu-bar app. `LSUIElement` keeps it out of the + // Dock and app switcher; it lives in the menu bar only. + infoPlist: .dictionary([ + "CFBundleDevelopmentRegion": .string("en"), + "CFBundleExecutable": .string("$(EXECUTABLE_NAME)"), + "CFBundleIdentifier": .string("$(PRODUCT_BUNDLE_IDENTIFIER)"), + "CFBundleInfoDictionaryVersion": .string("6.0"), + "CFBundleName": .string("$(PRODUCT_NAME)"), + "CFBundlePackageType": .string("APPL"), + "CFBundleShortVersionString": .string("1.0"), + "CFBundleVersion": .string("1"), + "LSApplicationCategoryType": .string("public.app-category.developer-tools"), + "LSMinimumSystemVersion": .string("$(MACOSX_DEPLOYMENT_TARGET)"), + "LSUIElement": .boolean(true), + "NSPrincipalClass": .string("NSApplication"), + ]), + sources: ["Ledger/Ledger/Sources/**"], + dependencies: [ + .package(product: "LedgerCore"), + ], + // Ledger ships no asset catalog (menu-bar icon is an SF Symbol), so + // clear the asset-catalog name settings the compiler otherwise + // looks for. + settings: .settings(base: [ + "ASSETCATALOG_COMPILER_APPICON_NAME": "", + "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "", + ]), + ), + .target( + name: "LedgerCoreTests", + // Hostless macOS unit tests — the `unitTests` helper above is + // iOS-only (it hosts bundles in StuffTestHost), so this target is + // declared directly. + destinations: [.mac], + product: .unitTests, + bundleId: "com.stuff.ledgercore.tests", + deploymentTargets: macDeployment, + sources: ["Ledger/LedgerCore/Tests/**"], + dependencies: [ + .package(product: "LedgerCore"), + ], + ), .target( name: "WhereTests", destinations: destinations, @@ -542,6 +596,22 @@ let project = Project( buildAction: .buildAction(targets: ["RegionViewer"]), runAction: .runAction(executable: "RegionViewer"), ), + .scheme( + name: "Ledger", + shared: true, + buildAction: .buildAction(targets: ["Ledger"]), + runAction: .runAction(executable: "Ledger"), + ), + // The workspace mixes iOS targets and the macOS-only Ledger targets, so + // CI drives two platform-scoped schemes — no single xcodebuild + // destination can build both. The macOS-only Ledger scheme runs in its + // own `test-macos` CI job (see .github/workflows/ci.yml). + .scheme( + name: "Ledger-macOS-Tests", + shared: true, + buildAction: .buildAction(targets: ["Ledger", "LedgerCoreTests"]), + testAction: .targets(["LedgerCoreTests"]), + ), // CI scheme. Rather than the autogenerated `Stuff-Workspace` scheme, // CI drives this explicit aggregate of every buildable/testable target // (see .github/workflows/ci.yml). @@ -595,6 +665,7 @@ let project = Project( "BroadwayCatalogTests", ]), ), + testScheme(name: "LedgerCoreTests"), testScheme(name: "StuffCoreTests"), testScheme(name: "CreditKitTests"), testScheme(name: "LifecycleKitTests"),