From 75886268f0f5fc83ce946246561f201e34d38015 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 14:42:36 -0700 Subject: [PATCH 1/7] Add Ledger: a menu-bar app for Cursor spend (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What A new native-macOS menu bar app, **Ledger**, that shows your current-cycle Cursor spend at a glance. Building it also **restores the native-macOS build infrastructure** removed with Foreman (#68) — the `.macOS` platform, a `.mac` app target + hostless macOS test bundle, the platform-scoped CI scheme, and the `test-macos` job. The menu-bar item shows the current-cycle amount (cents dropped, digits roll over on change). Clicking opens a popover with: - **This cycle** spend + billing-cycle date range, and the plan tier as a badge. - **Today** and **this week** spend (differenced from locally recorded history — hidden until enough history exists). - **Included usage** as two bars — first-party/Auto vs third-party/API — since a single blended figure hides 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 one multi-colored "Other models" bar with a legend), with model ids parsed into friendly names + badges (e.g. `Claude Opus 5` · `high`). - A **Refresh** button, **Settings**, and **Quit**. ## Data source Works for **individual** accounts by calling the same undocumented endpoints `cursor.com/dashboard` uses (the team Admin API only sees team accounts): - `GET /api/usage-summary` — cycle dates, plan, included-usage pool percentages, and live usage-based spend (`individualUsage.onDemand.used`). - `POST /api/dashboard/get-filtered-usage-events` — individual events (per-event model + `chargedCents`), paginated over the cycle and summed per model for the breakdown. Must be called **without** `teamId` for an individual account, or it 401s. **Auth is zero-config**: it auto-detects your Cursor session read-only from the local app's `state.vscdb` (`cursorAuth/accessToken`) and builds the required `WorkosCursorSessionToken=::` cookie, deriving `userId` from the JWT `sub` (a bare JWT 401s). Settings › Account has an optional paste-a-token override (Keychain-stored) for when auto-detect fails or the session expires. The resolved token is cached and re-read only when it can actually change — the pasted value is edited, the API returns 401, or Settings appears. ### Today / this week `onDemand.used` is a cycle-cumulative running total, so `SpendHistory` records timestamped samples (`SpendHistoryStore`, JSON in Application Support, pruned to ~14 days) and **differences** them — today = since local midnight, this week = since the start of the calendar week. These are real billed dollars, and the diff captures spend even while the app was closed, as long as a sample exists near the window start. Each figure is hidden until there's a baseline. ## Cost & resilience - The headline refreshes every 5 minutes (configurable in Settings); opening the popover does not fetch. The **per-model breakdown is throttled separately** to at most every 15 minutes, since it walks every usage event in the cycle — an explicit Refresh or a cycle rollover bypasses the throttle. - A **failed refresh keeps the last data on screen** and turns the "Updated…" caption into an amber stale warning (reason on hover) rather than blanking. The full error screen shows only before anything has loaded. A per-model failure likewise keeps the last good breakdown. - Only the newest request may mutate state (including recorded history), so a superseded response can't skew day/week baselines. - The API gets a **dedicated ephemeral session with no cookie storage** and a 30s timeout: auth is the `Cookie` header we set explicitly, and URLSession must never substitute a stored one. ## Structure - **`Ledger/LedgerCore`** (macOS-only SPM library) — `LedgerServices` (`@MainActor @Observable` root, single `LoadState`), `SessionToken`/`SessionTokenSource`/`CursorLocalTokenSource`, `KeychainStore`, `DashboardProvider`/`CursorDashboardAPI`, wire/view models (`UsageSummary`, `UsageEvent`/`UsageEventsPage`, `SpendSnapshot`, `ModelShare`, `ModelName`), `SpendHistory`/`SpendHistoryStore`, and `LoginItemController` (launch-at-login, restored from Foreman). - **`Ledger/Ledger`** (`.mac`, `LSUIElement`) — AppKit `NSStatusItem` + `NSPopover` shell hosting a SwiftUI `MenuBarLabel` (bound to the observable session; deliberately not `MenuBarExtra`), a System-Settings-style sidebar, and the thin `LedgerSession` facade. - **`Ledger/install`** — builds a Release, ad-hoc-signs it, and installs to `/Applications` so it runs standalone (no Xcode). - Build wiring: `.macOS(.v26)` + `LedgerCore` in `Package.swift`; `Ledger`/`LedgerCoreTests` targets and `Ledger`/`Ledger-macOS-Tests` schemes in `Project.swift`; the `test-macos` CI job; root `AGENTS.md` + per-module `README.md`/`AGENTS.md`. ## Notes / caveats - The dashboard endpoints are **undocumented** and can change without notice (e.g. `get-aggregated-usage-events` went stale for some accounts — returning days-old data and omitting newly released models — which is why the breakdown uses the per-event endpoint). - Cursor's `state.vscdb` is read **strictly read-only** (`SQLITE_OPEN_READONLY`, one parameterized `SELECT` against `ItemTable`); we never write to it. If the key or path ever moves, auto-detect degrades to "no session found" and the paste fallback takes over. - It rides your Cursor **web session** (re-open Cursor when it expires). - The per-model figures are **usage shares, not spend**: their summed per-event cost is total usage value (included allowance + on-demand), which exceeds the billed on-demand headline. - There is **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 yearly figure. ## Verification - `./swiftformat --lint` — clean - `mise exec -- tuist generate --no-open` — clean - `mise exec -- tuist test Ledger-macOS-Tests -- -destination 'platform=macOS'` — full LedgerCore suite passes: parsing/decoding, JWT→cookie derivation, a real SQLite round-trip for the token reader, history differencing, stale-vs-error state, per-model aggregation and pagination, the throttle, token caching/invalidation, and a `URLProtocol`-stubbed check of the outgoing request shape (no `teamId`, epoch-ms dates, the session cookie) — mutation-checked to confirm it fails when the rule is broken. - `mise exec -- tuist build Stuff-iOS-Tests` — iOS scheme unaffected - The auth-path changes (cookie-less session, token caching) were additionally verified against the live API after installing, since unit tests can't exercise them. --- .agents/skills/running-tests/SKILL.md | 3 + .bumper/Sources/WhereProjectRules.swift | 56 +- .bumper/Tests/WhereArchitectureTests.swift | 40 +- .bumper/Tests/WhereProjectRulesTests.swift | 65 +- .github/workflows/ci.yml | 38 + AGENTS.md | 13 +- Ledger/Ledger/AGENTS.md | 56 ++ Ledger/Ledger/README.md | 95 +++ Ledger/Ledger/Sources/CurrencyFormat.swift | 16 + Ledger/Ledger/Sources/LedgerApp.swift | 109 +++ Ledger/Ledger/Sources/LedgerSession.swift | 138 ++++ Ledger/Ledger/Sources/MenuBarLabel.swift | 28 + Ledger/Ledger/Sources/PreviewSupport.swift | 58 ++ Ledger/Ledger/Sources/SettingsView.swift | 233 +++++++ Ledger/Ledger/Sources/SpendView.swift | 379 ++++++++++ .../Sources/WindowVisibilityReader.swift | 61 ++ Ledger/LedgerCore/AGENTS.md | 99 +++ Ledger/LedgerCore/README.md | 99 +++ .../Sources/DashboardProvider.swift | 156 +++++ Ledger/LedgerCore/Sources/KeychainStore.swift | 110 +++ .../Sources/LedgerConfigStore.swift | 60 ++ Ledger/LedgerCore/Sources/LedgerLog.swift | 47 ++ .../LedgerCore/Sources/LedgerServices.swift | 473 +++++++++++++ .../LedgerCore/Sources/LedgerSettings.swift | 33 + .../Sources/LoginItemController.swift | 126 ++++ Ledger/LedgerCore/Sources/ModelName.swift | 123 ++++ Ledger/LedgerCore/Sources/SessionToken.swift | 64 ++ .../Sources/SessionTokenSource.swift | 84 +++ Ledger/LedgerCore/Sources/SpendHistory.swift | 102 +++ .../Sources/SpendHistoryStore.swift | 50 ++ Ledger/LedgerCore/Sources/SpendSnapshot.swift | 50 ++ Ledger/LedgerCore/Sources/TestDoubles.swift | 143 ++++ Ledger/LedgerCore/Sources/UsageEvents.swift | 66 ++ Ledger/LedgerCore/Sources/UsageSummary.swift | 103 +++ .../Tests/CursorDashboardAPITests.swift | 165 +++++ .../Tests/DashboardProviderTests.swift | 54 ++ .../LedgerCore/Tests/KeychainStoreTests.swift | 36 + .../Tests/LedgerConfigStoreTests.swift | 37 + .../Tests/LedgerCoreTestSupport.swift | 114 +++ .../Tests/LedgerServicesTests.swift | 660 ++++++++++++++++++ .../Tests/LoginItemControllerTests.swift | 84 +++ Ledger/LedgerCore/Tests/ModelNameTests.swift | 63 ++ .../Tests/SessionTokenSourceTests.swift | 52 ++ .../LedgerCore/Tests/SessionTokenTests.swift | 31 + .../Tests/SpendHistoryStoreTests.swift | 49 ++ .../LedgerCore/Tests/SpendHistoryTests.swift | 106 +++ .../LedgerCore/Tests/UsageEventsTests.swift | 43 ++ .../LedgerCore/Tests/UsageSummaryTests.swift | 50 ++ Ledger/install | 82 +++ Package.swift | 9 + Project.swift | 71 ++ 51 files changed, 4996 insertions(+), 86 deletions(-) create mode 100644 Ledger/Ledger/AGENTS.md create mode 100644 Ledger/Ledger/README.md create mode 100644 Ledger/Ledger/Sources/CurrencyFormat.swift create mode 100644 Ledger/Ledger/Sources/LedgerApp.swift create mode 100644 Ledger/Ledger/Sources/LedgerSession.swift create mode 100644 Ledger/Ledger/Sources/MenuBarLabel.swift create mode 100644 Ledger/Ledger/Sources/PreviewSupport.swift create mode 100644 Ledger/Ledger/Sources/SettingsView.swift create mode 100644 Ledger/Ledger/Sources/SpendView.swift create mode 100644 Ledger/Ledger/Sources/WindowVisibilityReader.swift create mode 100644 Ledger/LedgerCore/AGENTS.md create mode 100644 Ledger/LedgerCore/README.md create mode 100644 Ledger/LedgerCore/Sources/DashboardProvider.swift create mode 100644 Ledger/LedgerCore/Sources/KeychainStore.swift create mode 100644 Ledger/LedgerCore/Sources/LedgerConfigStore.swift create mode 100644 Ledger/LedgerCore/Sources/LedgerLog.swift create mode 100644 Ledger/LedgerCore/Sources/LedgerServices.swift create mode 100644 Ledger/LedgerCore/Sources/LedgerSettings.swift create mode 100644 Ledger/LedgerCore/Sources/LoginItemController.swift create mode 100644 Ledger/LedgerCore/Sources/ModelName.swift create mode 100644 Ledger/LedgerCore/Sources/SessionToken.swift create mode 100644 Ledger/LedgerCore/Sources/SessionTokenSource.swift create mode 100644 Ledger/LedgerCore/Sources/SpendHistory.swift create mode 100644 Ledger/LedgerCore/Sources/SpendHistoryStore.swift create mode 100644 Ledger/LedgerCore/Sources/SpendSnapshot.swift create mode 100644 Ledger/LedgerCore/Sources/TestDoubles.swift create mode 100644 Ledger/LedgerCore/Sources/UsageEvents.swift create mode 100644 Ledger/LedgerCore/Sources/UsageSummary.swift create mode 100644 Ledger/LedgerCore/Tests/CursorDashboardAPITests.swift create mode 100644 Ledger/LedgerCore/Tests/DashboardProviderTests.swift create mode 100644 Ledger/LedgerCore/Tests/KeychainStoreTests.swift create mode 100644 Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift create mode 100644 Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift create mode 100644 Ledger/LedgerCore/Tests/LedgerServicesTests.swift create mode 100644 Ledger/LedgerCore/Tests/LoginItemControllerTests.swift create mode 100644 Ledger/LedgerCore/Tests/ModelNameTests.swift create mode 100644 Ledger/LedgerCore/Tests/SessionTokenSourceTests.swift create mode 100644 Ledger/LedgerCore/Tests/SessionTokenTests.swift create mode 100644 Ledger/LedgerCore/Tests/SpendHistoryStoreTests.swift create mode 100644 Ledger/LedgerCore/Tests/SpendHistoryTests.swift create mode 100644 Ledger/LedgerCore/Tests/UsageEventsTests.swift create mode 100644 Ledger/LedgerCore/Tests/UsageSummaryTests.swift create mode 100755 Ledger/install diff --git a/.agents/skills/running-tests/SKILL.md b/.agents/skills/running-tests/SKILL.md index 1842c4b3..24a197a2 100644 --- a/.agents/skills/running-tests/SKILL.md +++ b/.agents/skills/running-tests/SKILL.md @@ -111,4 +111,7 @@ mise install ./ide --no-open ./swiftformat --lint ./test --everything +# The native-macOS Ledger scheme has no simulator and runs in its own CI job: +mise exec -- tuist test Ledger-macOS-Tests --no-selective-testing -- \ + -destination 'platform=macOS' ``` 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 ea9b6fa7..afb56b1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,3 +170,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 866bdfee..c0b6edfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,8 +169,8 @@ triage). **Always-on** rules every edit must honor stay in `AGENTS.md` or 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 owning image references has its own `*SnapshotTests` target over its `SnapshotTests/` folder, all listed in the single shared **StuffSnapshotTests** scheme and its dedicated CI `snapshot` job — slow and LFS-backed, so deliberately **out of** `Stuff-iOS-Tests`. References under any `__Snapshots__/` directory are Git LFS (`.gitattributes`; the CI job checks out with `lfs: true`). Framework halves: `Shared/SnapshotKit` (shippable matrix + previews) and `Shared/SnapshotKitTesting` (test-only pipeline, whose own regression bundle **SnapshotKitTestingTests** pixel-probes without LFS and runs in `Stuff-iOS-Tests`). - **A new image suite gets a target, not a scheme.** Add the `*SnapshotTests` target, list only `SnapshotKitTesting` in `extraPackageProducts`, and add it to the `StuffSnapshotTests` scheme's build and test lists — never a scheme or CI job of its own. An image bundle links only what its module needs (the Periscope and Inspector suites don't build against WhereUI at all); references follow the sources automatically via `#filePath`. - **Separate snapshot bundles are safe because each `.xctest` gets its own `StuffTestHost` process** (measured on Xcode 27 — `ProcessInfo.processIdentifier` probes; details in the snapshot-bundle comment in [`Project.swift`](Project.swift)). Each bundle statically embeds its own copy of `SnapshotKitTesting`'s capture state, and two copies in one process would corrupt each other. Tripwire: if a toolchain ever shares one host process across bundles, re-measure before adding another image bundle. @@ -201,16 +201,19 @@ PR #145. ## 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: 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 7b258b6a..76551446 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"]), @@ -49,6 +51,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 be89a47c..7c99b731 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(".")) @@ -301,6 +305,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, @@ -615,6 +669,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). @@ -673,6 +743,7 @@ let project = Project( arguments: .arguments(environmentVariables: packageResourceEnvironment), ), ), + testScheme(name: "LedgerCoreTests"), testScheme(name: "StuffCoreTests"), testScheme(name: "CreditKitTests"), testScheme(name: "LifecycleKitTests"), From 21474e3eaac90abe1b016e127455741a6498530d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 15:14:55 -0700 Subject: [PATCH 2/7] Fix LedgerCore docs: PeriscopeCore, not LogKit (#186) The Ledger PR (#103) landed LedgerLog on PeriscopeCore, but AGENTS.md and README.md still described LogKit and an OSLog subsystem. Align the module docs with WhereCore's Periscope wording. Validation: documentation-only; no tests run. Co-authored-by: Cursor Agent --- Ledger/LedgerCore/AGENTS.md | 5 +++-- Ledger/LedgerCore/README.md | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Ledger/LedgerCore/AGENTS.md b/Ledger/LedgerCore/AGENTS.md index 85dc3d96..2971ca27 100644 --- a/Ledger/LedgerCore/AGENTS.md +++ b/Ledger/LedgerCore/AGENTS.md @@ -21,8 +21,9 @@ 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 +- **Foundation + Observation + Security + ServiceManagement + SQLite3 + + PeriscopeCore 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 diff --git a/Ledger/LedgerCore/README.md b/Ledger/LedgerCore/README.md index 68aa2811..07ba7200 100644 --- a/Ledger/LedgerCore/README.md +++ b/Ledger/LedgerCore/README.md @@ -3,8 +3,10 @@ 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. +authenticated with your Cursor **session token**. It logs through +[`Periscope`](../../Shared/Periscope) via the `LedgerLog` facade. The +SwiftUI/AppKit shell lives in the [`Ledger`](../Ledger) app target and binds +this tree directly. ## What it does @@ -48,7 +50,8 @@ auto-token", surfaced as `LoadError.missingCredentials`. - `LedgerSettings` / `LedgerConfiguration` / `LedgerConfigStore` — the persisted refresh interval (no secrets). - `LoginItemController` — launch-at-login via `SMAppService`. -- `LedgerLog` — the LogKit logging facade (subsystem `com.stuff.ledger`). +- **`LedgerLog`** — the Periscope logging facade: a `"Ledger"` root scope with + grouping scopes (`services`, `dashboard`), emitted into `Periscope.shared`. ## How the figures are computed From 77a6698380e155a2ef641d418782dc88cbe0d7f4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 19:20:03 -0700 Subject: [PATCH 3/7] Prototype: Services as composable state machines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an exploratory ServicesStateMachine layer under WhereCore/Prototype that models five TLA-aligned protocol slices (tracking, post-write, ingestor quiesce, launch, reset) as pure reducers over a composite ServicesSnapshot. Includes scenario replay tests and a README — not wired into production WhereServices. Co-authored-by: Cursor --- .../Prototype/ServicesStateMachine/README.md | 60 ++++++ .../Machines/IngestorMachine.swift | 58 ++++++ .../Machines/LaunchMachine.swift | 112 ++++++++++++ .../Machines/PostWriteMachine.swift | 116 ++++++++++++ .../Machines/ResetMachine.swift | 42 +++++ .../Machines/TrackingMachine.swift | 171 ++++++++++++++++++ .../ServicesStateMachine/ServiceEffect.swift | 41 +++++ .../ServicesStateMachine/ServicesEvent.swift | 48 +++++ .../ServicesMachine.swift | 77 ++++++++ .../ServicesMachineReplay.swift | 107 +++++++++++ .../ServicesSnapshot.swift | 26 +++ .../Tests/ServicesStateMachineTests.swift | 90 +++++++++ 12 files changed, 948 insertions(+) create mode 100644 Where/WhereCore/Prototype/ServicesStateMachine/README.md create mode 100644 Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/IngestorMachine.swift create mode 100644 Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/LaunchMachine.swift create mode 100644 Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/PostWriteMachine.swift create mode 100644 Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/ResetMachine.swift create mode 100644 Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/TrackingMachine.swift create mode 100644 Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServiceEffect.swift create mode 100644 Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesEvent.swift create mode 100644 Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesMachine.swift create mode 100644 Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesMachineReplay.swift create mode 100644 Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesSnapshot.swift create mode 100644 Where/WhereCore/Tests/ServicesStateMachineTests.swift diff --git a/Where/WhereCore/Prototype/ServicesStateMachine/README.md b/Where/WhereCore/Prototype/ServicesStateMachine/README.md new file mode 100644 index 00000000..ad408999 --- /dev/null +++ b/Where/WhereCore/Prototype/ServicesStateMachine/README.md @@ -0,0 +1,60 @@ +# Services state machine prototype + +Exploratory sketch of **WhereServices as composable state machines** — not wired +into production. Shows what a third architecture direction looks like: every +fragile protocol becomes an explicit `(State, Event) → (State, [Effect])` +reducer; a thin orchestrator merges slices; a runner executes effects against +the real collaborators. + +## Layout + +``` +ServicesSnapshot ← composite frozen state +ServicesEvent ← external commands + effect completions +ServiceEffect ← scheduled side effects +ServicesMachine ← top-level reduce() +ServicesMachineReplay ← scenario tracer for tests +Machines/ + TrackingMachine ← TrackingReconciliation.tla + PostWriteMachine ← PostWriteReconcile.tla + IngestorMachine ← IngestorQuiesce.tla + LaunchMachine ← LaunchLifecycle.tla + ResetMachine ← WhereServices.reset() ordering +``` + +**Out of scope** (stay plain actors in this prototype): `ReportReader`, +`BackupCoordinator` export progress, `RecentActivitySummarizer`, evidence reads, +data-issue detection rules. + +## How to read it + +1. Open `ServicesSnapshot.swift` — the whole system's state at a glance. +2. Open `ServicesMachine.reduce` — one event enters, effects leave. +3. Pick a sub-machine (e.g. `TrackingMachine`) — maps 1:1 to an existing TLA + spec README under `Where/Specifications/`. +4. Run `ServicesStateMachineTests` — replays the same scenarios the TLA configs + name (enable/disable, post-write ordering, reset quiesce). + +## Production mapping (if this were real) + +| Prototype | Today | +| --- | --- | +| `ServicesMachine.reduce` | `WhereSession` + `DayJournal` + `WhereServices.init` closures | +| `ServiceEffect` cases | `await services..…` | +| Effect completion events | async task resume / actor callback | +| `ServicesMachineReplay` | Swift Testing scenario tests + TLA model | + +A production runner would live in WhereUI (session) and WhereCore (journal), +replacing ad-hoc `Task` loops incrementally — one protocol at a time. + +## Related work + +- TLA specs: `./tla-check` (8 specs on `cursor/tla-protocol-expansion`) +- Pure-core prototypes: #188 `TrackingReconcile`, #189 `PostWriteReconcilePlan` +- This branch stacks both ideas: **pure cores become sub-machine reducers** inside + one composable snapshot. + +## Status + +Draft / look-only. Delete or promote individual machines after review — the +point is to *see* the shape, not migrate the app in one pass. diff --git a/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/IngestorMachine.swift b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/IngestorMachine.swift new file mode 100644 index 00000000..5d1822f1 --- /dev/null +++ b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/IngestorMachine.swift @@ -0,0 +1,58 @@ +import Foundation + +/// GPS ingestor accept/monitor/quiesce slice (`IngestorQuiesce.tla`). +enum IngestorMachine { + enum QuiescePhase: String, Hashable, CaseIterable { + case idle + case begin + case awaiting + case done + } + + struct State: Equatable { + var acceptsSamples: Bool + var isMonitoring: Bool + var inFlightPersist: Bool + var quiescePhase: QuiescePhase + + static let initial = State( + acceptsSamples: true, + isMonitoring: false, + inFlightPersist: false, + quiescePhase: .idle, + ) + } + + static func reduce( + _ state: State, + event: ServicesEvent, + ) -> (State, [ServiceEffect])? { + switch event { + case .ingestorStartFinished: + var next = state + next.isMonitoring = true + return (next, []) + case .ingestorStopFinished: + var next = state + next.isMonitoring = false + return (next, []) + case .ingestorQuiesceRequested: + guard state.quiescePhase == .idle else { return nil } + var next = state + next.acceptsSamples = false + next.isMonitoring = false + next.quiescePhase = .begin + return (next, []) + case .ingestorQuiesceFinished: + guard state.quiescePhase == .begin || state.quiescePhase == .awaiting else { + return nil + } + var next = state + next.isMonitoring = false + next.quiescePhase = .done + return (next, []) + default: + return nil + } + } +} diff --git a/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/LaunchMachine.swift b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/LaunchMachine.swift new file mode 100644 index 00000000..cdb86c9a --- /dev/null +++ b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/LaunchMachine.swift @@ -0,0 +1,112 @@ +import Foundation + +/// Foreground / launch drive (`LaunchLifecycle.tla`). +enum LaunchMachine { + enum Phase: String, Hashable, CaseIterable { + case notStarted + case driving + case ready + } + + struct State: Equatable { + var phase: Phase + var nextStepIndex: Int + var syncAuthRuns: Int + var reconcileRuns: Int + + static let initial = State( + phase: .notStarted, + nextStepIndex: 0, + syncAuthRuns: 0, + reconcileRuns: 0, + ) + + static let launchSteps: [LaunchStep] = [ + .syncAuthorization, + .reconcileTracking, + .captureTodayIfNeeded, + .applyReminderConfiguration, + .applySummaryConfiguration, + .applyIssueAlertConfiguration, + .refreshWidgetSnapshot, + ] + } + + static func reduce( + _ state: State, + event: ServicesEvent, + ) -> (State, [ServiceEffect])? { + switch event { + case .launchDriveStarted: + guard state.phase == .notStarted else { return nil } + var next = state + next.phase = .driving + next.nextStepIndex = 0 + return (next, effectsForCurrentStep(next)) + case .appForegrounded: + guard state.phase == .ready else { return nil } + var next = state + next.phase = .driving + next.nextStepIndex = 0 + return (next, effectsForCurrentStep(next)) + case let .launchStepFinished(step): + return advance(state, finished: step) + default: + return nil + } + } + + private static func advance( + _ state: State, + finished step: LaunchStep, + ) -> (State, [ServiceEffect])? { + guard state.phase == .driving, + state.nextStepIndex < State.launchSteps.count, + State.launchSteps[state.nextStepIndex] == step + else { return nil } + + var next = state + switch step { + case .syncAuthorization: + next.syncAuthRuns += 1 + case .reconcileTracking: + next.reconcileRuns += 1 + case .captureTodayIfNeeded, + .applyReminderConfiguration, + .applySummaryConfiguration, + .applyIssueAlertConfiguration, + .refreshWidgetSnapshot: + break + } + next.nextStepIndex += 1 + if next.nextStepIndex >= State.launchSteps.count { + next.phase = .ready + return (next, []) + } + return (next, effectsForCurrentStep(next)) + } + + private static func effectsForCurrentStep(_ state: State) -> [ServiceEffect] { + guard state.nextStepIndex < State.launchSteps.count else { return [] } + return [effect(for: State.launchSteps[state.nextStepIndex])] + } + + private static func effect(for step: LaunchStep) -> ServiceEffect { + switch step { + case .syncAuthorization: + .syncAuthorization + case .reconcileTracking: + .reconcileTracking + case .captureTodayIfNeeded: + .captureTodayIfNeeded + case .applyReminderConfiguration: + .applyReminderConfiguration + case .applySummaryConfiguration: + .applySummaryConfiguration + case .applyIssueAlertConfiguration: + .applyIssueAlertConfiguration + case .refreshWidgetSnapshot: + .refreshWidgetSnapshot + } + } +} diff --git a/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/PostWriteMachine.swift b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/PostWriteMachine.swift new file mode 100644 index 00000000..cdeaa15d --- /dev/null +++ b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/PostWriteMachine.swift @@ -0,0 +1,116 @@ +import Foundation + +/// Sequential post-write fan-out (`PostWriteReconcile.tla`). +enum PostWriteMachine { + enum WritePhase: String, Hashable, CaseIterable { + case idle + case inPerform + case committed + } + + enum ReconcilePhase: String, Hashable, CaseIterable { + case none + case running + case done + } + + struct State: Equatable { + var writePhase: WritePhase + var reconcilePhase: ReconcilePhase + var pendingPlan: PostWriteReconcilePlan? + var nextStepIndex: Int + var changesPinged: Bool + var sideEffectsApplied: Bool + var readerSawPing: Bool + + static let initial = State( + writePhase: .idle, + reconcilePhase: .none, + pendingPlan: nil, + nextStepIndex: 0, + changesPinged: false, + sideEffectsApplied: false, + readerSawPing: false, + ) + } + + static func reduce( + _ state: State, + event: ServicesEvent, + ) -> (State, [ServiceEffect])? { + switch event { + case .beginWrite: + guard state.writePhase == .idle else { return nil } + var next = state + next.writePhase = .inPerform + return (next, [.beginStorePerform]) + case let .writeCommitted(outcome): + guard state.writePhase == .inPerform else { return nil } + var next = state + next.writePhase = .committed + next.reconcilePhase = .running + next.pendingPlan = PostWriteReconcilePlan.forOutcome(outcome) + next.nextStepIndex = 0 + return (next, [.commitStoreWrite] + effectsForNextStep(next)) + case .storePerformCompleted: + return nil + case let .reconcileStepFinished(step): + return advanceReconcile(state, finished: step) + case .storeChangesPinged: + guard state.writePhase == .committed else { return nil } + var next = state + next.changesPinged = true + return (next, []) + case .readerRefreshed: + guard state.changesPinged else { return nil } + var next = state + next.readerSawPing = true + return (next, []) + default: + return nil + } + } + + private static func advanceReconcile( + _ state: State, + finished step: ReconcileStep, + ) -> (State, [ServiceEffect])? { + guard state.reconcilePhase == .running, + let plan = state.pendingPlan, + state.nextStepIndex < plan.steps.count, + plan.steps[state.nextStepIndex] == step + else { return nil } + + var next = state + next.nextStepIndex += 1 + if next.nextStepIndex >= plan.steps.count { + next.reconcilePhase = .done + next.sideEffectsApplied = true + next.pendingPlan = nil + return (next, [.pingStoreChanges]) + } + return (next, effectsForNextStep(next)) + } + + private static func effectsForNextStep(_ state: State) -> [ServiceEffect] { + guard let plan = state.pendingPlan, + state.nextStepIndex < plan.steps.count + else { return [] } + return [effect(for: plan.steps[state.nextStepIndex])] + } + + private static func effect(for step: ReconcileStep) -> ServiceEffect { + switch step { + case .invalidateIssues: + .invalidateIssueScanner + case .reconcileReminders: + .reconcileReminders + case .reconcileIssueAlerts: + .reconcileIssueAlerts + case .publishWidgets: + .publishWidgets + case let .publishWidgetsAfterIngest(sample): + .publishWidgetsAfterIngest(sample) + } + } +} diff --git a/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/ResetMachine.swift b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/ResetMachine.swift new file mode 100644 index 00000000..51f2f39c --- /dev/null +++ b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/ResetMachine.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Cross-collaborator erase path (`WhereServices.reset()` ordering). +enum ResetMachine { + enum Phase: String, Hashable, CaseIterable { + case idle + case quiescing + case erasing + case done + } + + struct State: Equatable { + var phase: Phase + + static let initial = State(phase: .idle) + } + + static func reduce( + _ state: State, + event: ServicesEvent, + ) -> (State, [ServiceEffect])? { + switch event { + case .resetRequested: + guard state.phase == .idle else { return nil } + var next = state + next.phase = .quiescing + return (next, [.beginIngestorQuiesce]) + case .ingestorQuiesceFinished: + guard state.phase == .quiescing else { return nil } + var next = state + next.phase = .erasing + return (next, [.eraseAllData]) + case .eraseAllDataFinished: + guard state.phase == .erasing else { return nil } + var next = state + next.phase = .done + return (next, [.invalidateIssueScannerAfterReset]) + default: + return nil + } + } +} diff --git a/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/TrackingMachine.swift b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/TrackingMachine.swift new file mode 100644 index 00000000..093dcdbd --- /dev/null +++ b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/Machines/TrackingMachine.swift @@ -0,0 +1,171 @@ +import Foundation + +/// Coalesced tracking worker (`TrackingReconciliation.tla`, `Coalesced.cfg`). +enum TrackingMachine { + enum WorkerPhase: String, Hashable, CaseIterable { + case idle + case ready + case starting + case stopping + } + + struct State: Equatable { + var desired: Bool + var persisted: Bool + var ingestorActive: Bool + var published: Bool + var worker: WorkerPhase + var targetEffective: Bool + var authorizationAllowsBackground: Bool + var reconcilePending: Bool + + static let initial = State( + desired: false, + persisted: false, + ingestorActive: false, + published: false, + worker: .idle, + targetEffective: false, + authorizationAllowsBackground: true, + reconcilePending: false, + ) + + func effectiveTracking() -> Bool { + TrackingReconcile.effectiveTracking( + desired: desired, + authorizationAllowsBackground: authorizationAllowsBackground, + ) + } + } + + static func reduce( + _ state: State, + event: ServicesEvent, + ) -> (State, [ServiceEffect])? { + switch event { + case let .setTrackingDesired(value): + handleDesiredChange(state, value: value) + case let .authorizationChanged(allowsBackground): + handleAuthorizationChange(state, allowsBackground: allowsBackground) + case .reconcileTrackingRequested, .launchStepFinished(.reconcileTracking): + enqueueReconcile(state) + case .ingestorStartFinished: + handleStartFinished(state) + case .ingestorStopFinished: + handleStopFinished(state) + default: + nil + } + } + + private static func handleDesiredChange( + _ state: State, + value: Bool, + ) -> (State, [ServiceEffect]) { + var next = state + next.desired = value + next.persisted = value + var effects: [ServiceEffect] = [.persistTrackingDesired(value)] + let (updated, enqueueEffects) = enqueueWorker(next) + next = updated + effects.append(contentsOf: enqueueEffects) + return (next, effects) + } + + private static func handleAuthorizationChange( + _ state: State, + allowsBackground: Bool, + ) -> (State, [ServiceEffect]) { + var next = state + next.authorizationAllowsBackground = allowsBackground + return enqueueWorker(next) + } + + private static func enqueueReconcile(_ state: State) -> (State, [ServiceEffect]) { + var next = state + if next.worker == .starting || next.worker == .stopping { + next.reconcilePending = true + let effective = next.effectiveTracking() + if TrackingReconcile.shouldPreemptInFlightStop(targetEffective: effective) { + next.ingestorActive = false + return (next, [.stopIngestor]) + } + return (next, []) + } + return enqueueWorker(next) + } + + private static func enqueueWorker(_ state: State) -> (State, [ServiceEffect]) { + var next = state + let effective = next.effectiveTracking() + switch next.worker { + case .idle: + next.worker = .ready + next.targetEffective = effective + return (next, effectForTarget(effective)) + case .ready, .starting, .stopping: + next.reconcilePending = true + if next.worker == .starting, + TrackingReconcile.shouldPreemptInFlightStop(targetEffective: effective) + { + next.worker = .stopping + next.targetEffective = effective + next.ingestorActive = false + return (next, [.stopIngestor]) + } + return (next, []) + } + } + + private static func effectForTarget(_ effective: Bool) -> [ServiceEffect] { + effective ? [.startIngestor] : [.stopIngestor] + } + + private static func handleStartFinished(_ state: State) -> (State, [ServiceEffect]) { + guard state.worker == .ready || state.worker == .starting else { + return (state, []) + } + var next = state + next.worker = .starting + next.ingestorActive = true + return finishWorkerCycle(next) + } + + private static func handleStopFinished(_ state: State) -> (State, [ServiceEffect]) { + guard state.worker == .ready || state.worker == .starting || state.worker == .stopping + else { + return (state, []) + } + var next = state + next.worker = .stopping + next.ingestorActive = false + return finishWorkerCycle(next) + } + + private static func finishWorkerCycle(_ state: State) -> (State, [ServiceEffect]) { + var next = state + let currentEffective = next.effectiveTracking() + guard currentEffective == next.targetEffective, !next.reconcilePending else { + next.reconcilePending = false + let (updated, effects) = enqueueWorker(next) + return (updated, effects) + } + next.worker = .idle + next.published = currentEffective + return (next, [.publishTracking(currentEffective)]) + } +} + +/// Pure helpers shared with production (`cursor/prototype-tracking-reconcile-pure-core`). +enum TrackingReconcile { + static func effectiveTracking( + desired: Bool, + authorizationAllowsBackground: Bool, + ) -> Bool { + desired && authorizationAllowsBackground + } + + static func shouldPreemptInFlightStop(targetEffective: Bool) -> Bool { + !targetEffective + } +} diff --git a/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServiceEffect.swift b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServiceEffect.swift new file mode 100644 index 00000000..c7644eb3 --- /dev/null +++ b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServiceEffect.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Side effects the orchestrator schedules; a production runner would map each +/// case to `await services..…`. +/// +/// Prototype-only — not wired into `WhereServices`. See +/// ``ServicesMachine/README.md``. +enum ServiceEffect: Equatable { + // Tracking (`TrackingReconciliation`) + case persistTrackingDesired(Bool) + case startIngestor + case stopIngestor + case publishTracking(Bool) + + // Post-write (`PostWriteReconcile`) + case beginStorePerform + case commitStoreWrite + case invalidateIssueScanner + case reconcileReminders + case reconcileIssueAlerts + case publishWidgets + case publishWidgetsAfterIngest(LocationSample) + case pingStoreChanges + + // Ingestor quiesce (`IngestorQuiesce`) + case beginIngestorQuiesce + case completeIngestorQuiesce + + // Launch (`LaunchLifecycle`) + case syncAuthorization + case reconcileTracking + case captureTodayIfNeeded + case applyReminderConfiguration + case applySummaryConfiguration + case applyIssueAlertConfiguration + case refreshWidgetSnapshot + + // Reset (cross-machine) + case eraseAllData + case invalidateIssueScannerAfterReset +} diff --git a/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesEvent.swift b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesEvent.swift new file mode 100644 index 00000000..df73dcad --- /dev/null +++ b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesEvent.swift @@ -0,0 +1,48 @@ +import Foundation + +/// External and internal events fed to ``ServicesMachine/reduce(_:_:)``. +/// +/// Names mirror the TLA+ specs under `Where/Specifications/` where possible. +enum ServicesEvent: Equatable { + // User / UI + case setTrackingDesired(Bool) + case resetRequested + + // Store writes + case beginWrite + case writeCommitted(PostWriteOutcome) + + // Effect completions (what a runner reports back) + case storePerformCompleted + case reconcileStepFinished(ReconcileStep) + case storeChangesPinged + case readerRefreshed + + case ingestorStartFinished + case ingestorStopFinished + case ingestorQuiesceRequested + case ingestorQuiesceFinished + + /// Coalesced worker rerun (foreground, auth change, launch step). + case reconcileTrackingRequested + + case launchDriveStarted + case launchStepFinished(LaunchStep) + + case eraseAllDataFinished + + // Lifecycle + case appForegrounded + case authorizationChanged(allowsBackground: Bool) +} + +/// Launch steps modeled in ``LaunchMachine`` (maps to `LaunchLifecycle.tla`). +enum LaunchStep: String, Hashable, CaseIterable { + case syncAuthorization + case reconcileTracking + case captureTodayIfNeeded + case applyReminderConfiguration + case applySummaryConfiguration + case applyIssueAlertConfiguration + case refreshWidgetSnapshot +} diff --git a/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesMachine.swift b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesMachine.swift new file mode 100644 index 00000000..43c4618d --- /dev/null +++ b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesMachine.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Top-level reducer: one snapshot in, updated snapshot + scheduled effects out. +/// +/// Each sub-machine owns a slice of ``ServicesSnapshot`` and ignores events +/// outside its domain. The orchestrator merges partial updates and concatenates +/// effects. Cross-machine glue (reset → quiesce → erase, launch → tracking) +/// lives here. +/// +/// Prototype-only — production still uses actors + ad-hoc tasks. +enum ServicesMachine { + struct StepResult: Equatable { + let snapshot: ServicesSnapshot + let effects: [ServiceEffect] + + init(snapshot: ServicesSnapshot, effects: [ServiceEffect]) { + self.snapshot = snapshot + self.effects = effects + } + } + + static func reduce( + _ snapshot: ServicesSnapshot, + _ event: ServicesEvent, + ) -> StepResult { + var next = snapshot + var effects: [ServiceEffect] = [] + + if let reset = ResetMachine.reduce(next.reset, event: event) { + next.reset = reset.0 + effects.append(contentsOf: reset.1) + if case .resetRequested = event { + if let ingestor = IngestorMachine.reduce( + next.ingestor, + event: .ingestorQuiesceRequested, + ) { + next.ingestor = ingestor.0 + } + } + } + + let sessionActive = next.reset.phase == .idle || next.reset.phase == .done + if sessionActive { + if let tracking = TrackingMachine.reduce(next.tracking, event: event) { + next.tracking = tracking.0 + effects.append(contentsOf: tracking.1) + } + if let postWrite = PostWriteMachine.reduce(next.postWrite, event: event) { + next.postWrite = postWrite.0 + effects.append(contentsOf: postWrite.1) + } + if let launch = LaunchMachine.reduce(next.launch, event: event) { + next.launch = launch.0 + effects.append(contentsOf: launch.1) + } + } + + if let ingestor = IngestorMachine.reduce(next.ingestor, event: event) { + next.ingestor = ingestor.0 + effects.append(contentsOf: ingestor.1) + } + + effects.append(contentsOf: bridgeTracking(from: next, effects: effects)) + + return StepResult(snapshot: next, effects: effects) + } + + /// Launch emits `.reconcileTracking`; expand it into tracking-lane effects. + private static func bridgeTracking( + from snapshot: ServicesSnapshot, + effects: [ServiceEffect], + ) -> [ServiceEffect] { + guard effects.contains(.reconcileTracking) else { return [] } + return TrackingMachine.reduce(snapshot.tracking, event: .reconcileTrackingRequested)? + .1 ?? [] + } +} diff --git a/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesMachineReplay.swift b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesMachineReplay.swift new file mode 100644 index 00000000..1b27f7fa --- /dev/null +++ b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesMachineReplay.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Replays event sequences and optionally auto-completes effects for scenario tests. +enum ServicesMachineReplay { + struct Trace: Equatable { + var snapshots: [ServicesSnapshot] + var effects: [ServiceEffect] + + init(snapshots: [ServicesSnapshot], effects: [ServiceEffect]) { + self.snapshots = snapshots + self.effects = effects + } + + var final: ServicesSnapshot { + snapshots.last ?? .initial + } + } + + /// Feed external events only; effects accumulate but do not auto-complete. + static func trace(events: [ServicesEvent]) -> Trace { + var snapshot = ServicesSnapshot.initial + var snapshots: [ServicesSnapshot] = [snapshot] + var allEffects: [ServiceEffect] = [] + for event in events { + let step = ServicesMachine.reduce(snapshot, event) + snapshot = step.snapshot + snapshots.append(snapshot) + allEffects.append(contentsOf: step.effects) + } + return Trace(snapshots: snapshots, effects: allEffects) + } + + /// Alternate external events with synthetic effect completions until quiescent. + static func runToQuiescence(events: [ServicesEvent]) -> Trace { + var snapshot = ServicesSnapshot.initial + var snapshots: [ServicesSnapshot] = [snapshot] + var allEffects: [ServiceEffect] = [] + var queue = events + + while !queue.isEmpty { + let event = queue.removeFirst() + let step = ServicesMachine.reduce(snapshot, event) + snapshot = step.snapshot + snapshots.append(snapshot) + allEffects.append(contentsOf: step.effects) + queue.insert(contentsOf: completions(for: step.effects, snapshot: snapshot), at: 0) + } + return Trace(snapshots: snapshots, effects: allEffects) + } + + private static func completions( + for effects: [ServiceEffect], + snapshot _: ServicesSnapshot, + ) -> [ServicesEvent] { + effects.flatMap { effect -> [ServicesEvent] in + switch effect { + case .startIngestor: + [.ingestorStartFinished] + case .stopIngestor: + [.ingestorStopFinished] + case .beginIngestorQuiesce: + [.ingestorQuiesceFinished] + case .beginStorePerform: + [] + case .commitStoreWrite: + [] + case let .publishWidgetsAfterIngest(sample): + [ + .reconcileStepFinished(.invalidateIssues), + .reconcileStepFinished(.reconcileReminders), + .reconcileStepFinished(.reconcileIssueAlerts), + .reconcileStepFinished(.publishWidgetsAfterIngest(sample)), + .storeChangesPinged, + ] + case .invalidateIssueScanner: + [.reconcileStepFinished(.invalidateIssues)] + case .reconcileReminders: + [.reconcileStepFinished(.reconcileReminders)] + case .reconcileIssueAlerts: + [.reconcileStepFinished(.reconcileIssueAlerts)] + case .publishWidgets: + [.reconcileStepFinished(.publishWidgets), .storeChangesPinged] + case .pingStoreChanges: + [.storeChangesPinged] + case .eraseAllData: + [.eraseAllDataFinished] + case .syncAuthorization: + [.launchStepFinished(.syncAuthorization)] + case .reconcileTracking: + [.launchStepFinished(.reconcileTracking)] + case .captureTodayIfNeeded: + [.launchStepFinished(.captureTodayIfNeeded)] + case .applyReminderConfiguration: + [.launchStepFinished(.applyReminderConfiguration)] + case .applySummaryConfiguration: + [.launchStepFinished(.applySummaryConfiguration)] + case .applyIssueAlertConfiguration: + [.launchStepFinished(.applyIssueAlertConfiguration)] + case .refreshWidgetSnapshot: + [.launchStepFinished(.refreshWidgetSnapshot)] + case .persistTrackingDesired, .publishTracking, + .completeIngestorQuiesce, .invalidateIssueScannerAfterReset: + [] + } + } + } +} diff --git a/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesSnapshot.swift b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesSnapshot.swift new file mode 100644 index 00000000..9d33d198 --- /dev/null +++ b/Where/WhereCore/Sources/Prototype/ServicesStateMachine/ServicesSnapshot.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Frozen composite state of every protocol machine in the prototype stack. +struct ServicesSnapshot: Equatable { + var tracking: TrackingMachine.State + var postWrite: PostWriteMachine.State + var ingestor: IngestorMachine.State + var launch: LaunchMachine.State + var reset: ResetMachine.State + + init( + tracking: TrackingMachine.State = .initial, + postWrite: PostWriteMachine.State = .initial, + ingestor: IngestorMachine.State = .initial, + launch: LaunchMachine.State = .initial, + reset: ResetMachine.State = .initial, + ) { + self.tracking = tracking + self.postWrite = postWrite + self.ingestor = ingestor + self.launch = launch + self.reset = reset + } + + static let initial = ServicesSnapshot() +} diff --git a/Where/WhereCore/Tests/ServicesStateMachineTests.swift b/Where/WhereCore/Tests/ServicesStateMachineTests.swift new file mode 100644 index 00000000..eea0bf7f --- /dev/null +++ b/Where/WhereCore/Tests/ServicesStateMachineTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +@testable import WhereCore + +struct ServicesStateMachineTests { + @Test("Tracking enable then disable settles at quiescence (Coalesced.cfg)") + func trackingEnableDisableCoalesced() { + let result = ServicesMachineReplay.runToQuiescence(events: [ + .setTrackingDesired(true), + .setTrackingDesired(false), + ]) + + let final = result.final.tracking + #expect(final.desired == false) + #expect(final.persisted == false) + #expect(final.ingestorActive == false) + #expect(final.published == false) + #expect(final.worker == .idle) + } + + @Test("Tracking enable settles published true when authorized") + func trackingEnablePublished() { + let result = ServicesMachineReplay.runToQuiescence(events: [ + .setTrackingDesired(true), + ]) + + let final = result.final.tracking + #expect(final.published == true) + #expect(final.ingestorActive == true) + } + + @Test("Post-write manual day pings changes only after fan-out (Current.cfg)") + func postWriteManualDayOrdering() { + var snapshot = ServicesSnapshot.initial + + let begin = ServicesMachine.reduce(snapshot, .beginWrite) + snapshot = begin.snapshot + + let commit = ServicesMachine.reduce(snapshot, .writeCommitted(.dayDataChanged)) + snapshot = commit.snapshot + #expect(snapshot.postWrite.writePhase == .committed) + #expect(snapshot.postWrite.reconcilePhase == .running) + #expect(snapshot.postWrite.changesPinged == false) + + let quiescent = ServicesMachineReplay.runToQuiescence(events: [ + .beginWrite, + .writeCommitted(.dayDataChanged), + ]) + let final = quiescent.final.postWrite + #expect(final.reconcilePhase == .done) + #expect(final.changesPinged == true) + #expect(final.sideEffectsApplied == true) + } + + @Test("Reset quiesces ingestor before erase") + func resetQuiesceOrdering() { + let result = ServicesMachineReplay.runToQuiescence(events: [ + .resetRequested, + ]) + + let final = result.final + #expect(final.reset.phase == .done) + #expect(final.ingestor.quiescePhase == .done) + #expect(final.ingestor.acceptsSamples == false) + #expect(result.effects.contains(.beginIngestorQuiesce)) + #expect(result.effects.contains(.eraseAllData)) + } + + @Test("Launch drive runs foreground steps in order") + func launchDriveSequence() { + let result = ServicesMachineReplay.runToQuiescence(events: [ + .launchDriveStarted, + ]) + + #expect(result.final.launch.phase == .ready) + #expect(result.final.launch.syncAuthRuns == 1) + #expect(result.final.launch.reconcileRuns == 1) + #expect(result.effects.first == .syncAuthorization) + } + + @Test("Composite snapshot exposes every lane at once") + func compositeSnapshotShape() { + let snapshot = ServicesSnapshot.initial + #expect(snapshot.tracking.worker == .idle) + #expect(snapshot.postWrite.writePhase == .idle) + #expect(snapshot.ingestor.quiescePhase == .idle) + #expect(snapshot.launch.phase == .notStarted) + #expect(snapshot.reset.phase == .idle) + } +} From 98e30de9d3f1c6f9b8eade8538854bbe292a74e4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 14:21:23 -0700 Subject: [PATCH 4/7] Animate and haptically signal updated location counts (#192) > _Posted by an AI agent on kve's behalf._ ## Summary - persist the primary Location-card counts last presented for each year - hold saved counts until the card surface is visible and unobscured, then animate to the current report - emit one light haptic when one or more visible counts increased; keep decreases, first visits, and newly appearing cards silent - clear presentation history on reset and cover the behavior with focused persistence and presentation-model tests ## Design notes The reconciliation task is attached to the card surface and keyed by its counts, year, and explicit visibility. Switching tabs, pushing a destination, or presenting the Resolve sheet leaves the saved baseline untouched; returning to unobscured cards reconciles against the latest report. Multiple increased cards intentionally produce one coordinated haptic event. ## Testing - `./swiftformat --lint` - `./test --all` (1,629 tests passed before the focused review commits) - `./test WhereUITests` (350 tests passed after review fixes) - `./test --snapshots` (33 tests passed after review fixes; references unchanged) - `git diff --check` --- Where/TODOs.md | 3 +- Where/WhereCore/AGENTS.md | 3 + Where/WhereCore/README.md | 3 +- .../Preferences/WherePreferences.swift | 39 +++++- .../Tests/WherePreferencesTests.swift | 60 ++++++++ Where/WhereUI/AGENTS.md | 4 + Where/WhereUI/README.md | 9 +- .../LocationDayCountPresentationModel.swift | 79 +++++++++++ .../Sources/Primary/LocationsView.swift | 40 +++++- ...cationDayCountPresentationModelTests.swift | 128 ++++++++++++++++++ 10 files changed, 360 insertions(+), 8 deletions(-) create mode 100644 Where/WhereCore/Tests/WherePreferencesTests.swift create mode 100644 Where/WhereUI/Sources/Primary/LocationDayCountPresentationModel.swift create mode 100644 Where/WhereUI/Tests/LocationDayCountPresentationModelTests.swift diff --git a/Where/TODOs.md b/Where/TODOs.md index 7c612564..39317e3c 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -46,7 +46,6 @@ The item format and the placement rule live in the root - test(WhereIntents) [quick-win]: The per-intent `perform()` glue — guards, snippet wiring, error→dialog mapping — is untested, because `@Dependency` traps outside the perform flow. Either extract a thin testable seam or say so in `README.md`; the reader/writer seams themselves are now well covered. (audit 2026-07-26) - refactor(WhereShareExtension) [needs-design]: Consolidate the share/add evidence form. `ShareEvidenceView.swift:68` and `AddEvidenceView.swift:37` are parallel implementations over parallel catalog namespaces (`share.form.*` / `evidence.form.*`). (audit 2026-07-26) - perf(WhereCore) [needs-design]: Consider incremental year-report reads or memoization for the widget/reminder/summary hot paths — `ReportReader.yearReport:27` and `WidgetDataReader.snapshot:85` re-aggregate a full year each time. (audit 2026-07-26) -- test(WhereCore) [quick-win]: Add `WherePreferencesTests` over `InMemoryKeyValueStore`. (audit 2026-07-26) - refactor(WhereUI): What's with all the `.accessibilityIdentifier(…)` modifiers, do we need them? (human) - feat(WhereUI): Add a UI that represents where you currently are — maybe a border on the current location card? (human) - refactor(WhereCore) [needs-design]: Per-entity schema versioning + lazy upcasting for CloudKit sync drift. There is intentionally **no** boot-time data migration or on-read legacy recovery (removed pre-release as over-built for a single dev's data). Today a data-shape change relies solely on a one-time manual backup **export → transform (`Tools/upgrade-backup.rb`) → replace-import** to rewrite rows into the current shape; `SD….toValue()` reads only the current shape and drops (fault-logs) a row it can't place (e.g. an `SDManualDay` with no `dayKey`). Gaps this leaves, which a general mechanism should close: an old-build device can sync in an old-shaped entity at any time (not just at launch), and until it's re-imported such a row is dropped on read rather than upcast. Replace with: (agent) @@ -104,6 +103,8 @@ re-recording: # Completed issues +- test(WhereCore) [quick-win]: Add `WherePreferencesTests` over `InMemoryKeyValueStore`. (Resolved 2026-08-05: `WherePreferencesTests` now pins every first-install default, year-isolated Location-card snapshot persistence, and reset clearing both the existing settings and the new presentation history.) + - fix(WhereUI) [quick-win]: `resolution.Empty_iPhone` and `..._dark` baked in the **real-world date** and drifted every day — the reference read "Jan 1 – Jul 25 / 206 days" because that is when it was recorded, and it had been silently wrong every day since, passing only because two digit glyphs are 0.046% of the image. (Resolved: `PreviewSupport.previewServices()` now passes `now: { referenceNow }`, which `WhereServices` already threads into every collaborator including the `DataIssueScanner` that computes the missing-days range. `referenceNow`'s own doc comment names "missing-day math" as a reason it exists, so this was a fixture bug against a documented intent rather than a new pin. The two references were re-recorded once and now read "Jan 1 – Jul 14 / 195 days", derived from the pinned instant. Surfaced by `./test --review`, which reported it at max channel delta 255 while the suite still reported green.) - fix(WhereUI): `resolution.Empty` never rendered the empty state, and its capture raced a live store scan — which turned `main` red (run 30402846712) the first time CI lost that race, baking the `AppIconLoadingView` placeholder over 91.7% of `Empty_iPhone`. `PreviewSupport.resolveModel(seededWithIssues: false)` skipped `setDataIssues` entirely, so the fixture came back with `hasLoaded == false` — which `ResolutionView` can't distinguish from "the first scan hasn't landed" — and the view showed the placeholder until its `.task(id:)` scan of the empty in-memory store returned the whole year as missing days. So the *reference* was that scan's output (a populated list titled "Missing days"), not the all-clear state the case names, and every capture was a race the settle loop can't see: a pixel-stable placeholder settles clean, exactly as in the `root.LoggedIn` entry below. Previously masked by the ~1s that `drainInFlightAnimations` wasted per capture; removing that waste (#151) exposed it. (Resolved: both fixture modes now seed — `setDataIssues([])` for the empty one, which is what marks it loaded *and* `isSeeded`, so the view's `load(...)` is a no-op and the first rendered frame is final. The case is now fully synchronous, independent of the store and of `now`, and the two references were re-recorded once to the "All clear" state — coverage the suite never had, since `WithIssues` already pins the populated list. `ResolveModelTests` gained two guards: the fixture is loaded up front in both modes, and `load(...)` leaves a seeded fixture alone against a store whose scan does find issues.) ## Deferred snapshot-test flakiness diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 0357a0d3..8384d350 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -96,6 +96,9 @@ internal shape. from them and rebuilds on `changes()`; assemble via the async `WhereServices.make(...)` / `forIntents()` so both attribute against the same synced set. `distanceToBoundary` is `nil` outside the tracked set. +- **Location-card history is non-authoritative preference state.** Keep its + snapshots year-keyed by stable `Region` id, and clear them through + `WherePreferences.reset()`; current report totals remain the source of truth. - **`DemoDataBuilder` seeds through the ordinary write paths** (`DayJournal`, `setPrimaryRegions`) — no private door into the store, so a demo exercises the code a real user does. Its data is sized against the *elapsed* year, not diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index a49033d6..74ab3856 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -108,7 +108,8 @@ one it belongs to rather than to a god-object: - **`RecentActivitySummarizer`** — an on-device Foundation Models narrative over a selectable look-back `RecentActivityWindow`. - **`WherePreferences`** — persisted user intent (onboarding, tracking intent, - reminder / summary schedules) behind a `KeyValueStore`. The store has no + reminder / summary schedules) plus the year-keyed Location-card counts used + for presentation continuity, behind a `KeyValueStore`. The store has no default: production names `UserDefaults.standard` and everything else names `InMemoryKeyValueStore()`, so no test or preview can reach the host's real defaults by saying nothing. diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index ddaf769b..82745805 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -1,8 +1,10 @@ import Foundation +import RegionKit /// The app's persisted user intent — onboarding completion, background-tracking -/// intent, and the reminder / daily-summary schedules — behind a `KeyValueStore` -/// so production uses `UserDefaults` and tests use an in-memory double. +/// intent, and the reminder / daily-summary schedules — plus small pieces of UI +/// continuity state, behind a `KeyValueStore` so production uses `UserDefaults` +/// and tests use an in-memory double. /// /// `store` is deliberately not defaulted: defaulting it to /// `UserDefaults.standard` made the real, process-wide defaults the thing you @@ -99,9 +101,39 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.driftThresholdMeters.rawValue) } } + /// The primary Location-card counts last presented for `year`, or `nil` + /// when that year has no baseline yet. This is non-authoritative UI + /// continuity state: the current report remains the source of truth. + public func lastSeenLocationDayCounts(in year: Int) -> [Region: Int]? { + guard + let snapshots = store.object(forKey: Keys.lastSeenLocationDayCounts.rawValue) + as? [String: [String: Int]], + let rawCounts = snapshots[String(year)] + else { + return nil + } + + return rawCounts.reduce(into: [:]) { counts, entry in + guard let region = Region(rawValue: entry.key) else { return } + counts[region] = entry.value + } + } + + /// Replaces the primary Location-card baseline for `year`, retaining the + /// other years the user has viewed. + public func setLastSeenLocationDayCounts(_ counts: [Region: Int], in year: Int) { + var snapshots = store.object(forKey: Keys.lastSeenLocationDayCounts.rawValue) + as? [String: [String: Int]] ?? [:] + snapshots[String(year)] = Dictionary(uniqueKeysWithValues: counts.map { region, days in + (region.rawValue, days) + }) + store.set(snapshots, forKey: Keys.lastSeenLocationDayCounts.rawValue) + } + /// Clear every persisted preference so the next launch behaves like a fresh /// install: onboarding shows again, background tracking returns to its - /// default intent, and the reminder/summary schedules revert to defaults. + /// default intent, reminder/summary schedules revert to defaults, and UI + /// continuity snapshots are forgotten. /// Removing the keys (rather than writing `false`/`0`) lets the /// default-valued getters report first-install state again. public func reset() { @@ -124,5 +156,6 @@ public final class WherePreferences { case summaryMinute = "where.summaryMinute" case issueAlertsEnabled = "where.issueAlertsEnabled" case driftThresholdMeters = "where.driftThresholdMeters" + case lastSeenLocationDayCounts = "where.lastSeenLocationDayCounts" } } diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift new file mode 100644 index 00000000..71df3e7b --- /dev/null +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -0,0 +1,60 @@ +import RegionKit +import Testing +@testable import WhereCore + +struct WherePreferencesTests { + private func preferences() -> WherePreferences { + WherePreferences(store: InMemoryKeyValueStore()) + } + + @Test func firstInstallDefaults() { + let preferences = preferences() + + #expect(preferences.hasOnboarded == false) + #expect(preferences.wantsTracking) + #expect(preferences.remindersEnabled) + #expect(preferences.reminderTime == .defaultEvening) + #expect(preferences.summaryEnabled) + #expect(preferences.summaryTime == .defaultMorning) + #expect(preferences.issueAlertsEnabled) + #expect(preferences.driftThresholdMeters == DriftThreshold.default.rawValue) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == nil) + } + + @Test func locationDayCountsRoundTripIndependentlyByYear() { + let preferences = preferences() + let counts2025: [Region: Int] = [.california: 42, .other: 3] + let counts2026: [Region: Int] = [.newYork: 81, .europeanUnion: 7] + + preferences.setLastSeenLocationDayCounts(counts2025, in: 2025) + preferences.setLastSeenLocationDayCounts(counts2026, in: 2026) + + #expect(preferences.lastSeenLocationDayCounts(in: 2025) == counts2025) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == counts2026) + } + + @Test func resetRestoresEveryDefaultAndClearsLocationCounts() { + let preferences = preferences() + preferences.hasOnboarded = true + preferences.wantsTracking = false + preferences.remindersEnabled = false + preferences.reminderTime = ReminderTime(hour: 9, minute: 15) + preferences.summaryEnabled = false + preferences.summaryTime = ReminderTime(hour: 17, minute: 45) + preferences.issueAlertsEnabled = false + preferences.driftThresholdMeters = 25000 + preferences.setLastSeenLocationDayCounts([.california: 100], in: 2026) + + preferences.reset() + + #expect(preferences.hasOnboarded == false) + #expect(preferences.wantsTracking) + #expect(preferences.remindersEnabled) + #expect(preferences.reminderTime == .defaultEvening) + #expect(preferences.summaryEnabled) + #expect(preferences.summaryTime == .defaultMorning) + #expect(preferences.issueAlertsEnabled) + #expect(preferences.driftThresholdMeters == DriftThreshold.default.rawValue) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == nil) + } +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 1dec361e..a696c691 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -67,6 +67,10 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) renders relative to *today*, so no reference containing one is stable across days. Views don't read `\.isCapturingSnapshot` to branch themselves; capture handling stays inside the shared component. +- Reconcile `LocationDayCountPresentationModel` only from the visible primary + card surface; another tab, covering sheet, or pushed destination must leave + its persisted baseline untouched so returning can animate and haptically + signal the change. ## Design system — `WhereStylesheet` diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index d9ccdb36..e79647d1 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -75,8 +75,13 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's - **Scope-tiered models** — scene-scoped **`YearReportModel`** (the selected year's `YearReport`, its `LoadState`, and the manual-day edit intents), plus view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** - (export/import), and **`RemindersSettingsModel`** (notification prefs). Each - orchestrates `WhereServices`; none reimplements Core rules. + (export/import), **`RemindersSettingsModel`** (notification prefs), and + **`LocationDayCountPresentationModel`** (the last primary-card counts the + user saw). The Location model holds saved values until the card surface is + visible and unobscured, then advances every changed number in one animated + beat, adding one light haptic when any count increased; decreases, first + visits, and newly appearing cards stay silent. Each model keeps its behavior + off the view; none reimplements Core rules. ### Reusable views & styling diff --git a/Where/WhereUI/Sources/Primary/LocationDayCountPresentationModel.swift b/Where/WhereUI/Sources/Primary/LocationDayCountPresentationModel.swift new file mode 100644 index 00000000..9657b4bc --- /dev/null +++ b/Where/WhereUI/Sources/Primary/LocationDayCountPresentationModel.swift @@ -0,0 +1,79 @@ +import Observation +import RegionKit +import WhereCore + +/// Holds the Location cards at the counts the user last saw until their visible +/// card surface can reconcile to the current report and signal the change once. +@MainActor +@Observable +final class LocationDayCountPresentationModel { + /// Everything that decides whether SwiftUI should restart the card-surface + /// reconciliation task. + struct ReconciliationID: Hashable { + let counts: [RegionDays] + let year: Int + let isVisible: Bool + } + + private let preferences: WherePreferences + private var lastSeenCounts: [Region: Int]? + private var displayedCounts: [Region: Int] + private(set) var year: Int + private(set) var feedbackTrigger = 0 + + init(preferences: WherePreferences, year: Int) { + self.preferences = preferences + self.year = year + let savedCounts = preferences.lastSeenLocationDayCounts(in: year) + lastSeenCounts = savedCounts + displayedCounts = savedCounts ?? [:] + } + + /// Load another year's baseline without marking its current report as seen. + /// `LocationsView` calls this while a year change is happening on another + /// tab, so the saved values are ready before the cards become visible. + func prepare(for year: Int) { + guard year != self.year else { return } + self.year = year + let savedCounts = preferences.lastSeenLocationDayCounts(in: year) + lastSeenCounts = savedCounts + displayedCounts = savedCounts ?? [:] + } + + /// The card value to render before the visible surface reconciles. A card + /// absent from the previous snapshot starts at its current value rather than + /// inventing a zero the user never saw. + func presented(_ current: RegionDays) -> RegionDays { + RegionDays( + region: current.region, + days: displayedCounts[current.region] ?? current.days, + ) + } + + /// When the cards are visible, advance them to the report, persist that + /// presentation, and emit one feedback trigger if any comparable saved value + /// increased. Hidden or obscured surfaces leave their baseline untouched. + func reconcile(_ current: [RegionDays], in year: Int, isVisible: Bool) { + guard isVisible else { return } + prepare(for: year) + + let currentCounts = Dictionary(uniqueKeysWithValues: current.map { item in + (item.region, item.days) + }) + let shouldProvideFeedback = current.contains { item in + guard let previousDays = lastSeenCounts?[item.region] else { return false } + return previousDays < item.days + } + + if displayedCounts != currentCounts { + displayedCounts = currentCounts + } + if lastSeenCounts != currentCounts { + preferences.setLastSeenLocationDayCounts(currentCounts, in: year) + lastSeenCounts = currentCounts + } + if shouldProvideFeedback { + feedbackTrigger += 1 + } + } +} diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index 35a13b5a..d7e7dd7c 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -12,6 +12,8 @@ struct LocationsView: View { let report: YearReportModel @State private var showingResolution = false + @State private var isCardSurfaceVisible = false + @State private var dayCountPresentation: LocationDayCountPresentationModel /// Drives the region cards' tilt-reactive light sheen. Started/stopped /// with the view's lifecycle; a no-op on hardware without device motion. @@ -25,6 +27,22 @@ struct LocationsView: View { @Environment(\.stylesheet) private var stylesheet @Environment(\.regionStyles) private var regionStyles + private var dayCountReconciliationID: LocationDayCountPresentationModel.ReconciliationID { + LocationDayCountPresentationModel.ReconciliationID( + counts: report.ranking.primary, + year: report.selectedYear, + isVisible: isCardSurfaceVisible && !showingResolution, + ) + } + + init(report: YearReportModel) { + self.report = report + _dayCountPresentation = State(initialValue: LocationDayCountPresentationModel( + preferences: report.preferences, + year: report.selectedYear, + )) + } + var body: some View { NavigationStack { screen @@ -47,6 +65,9 @@ struct LocationsView: View { } .onAppear { tilt.start() } .onDisappear { tilt.stop() } + .onChange(of: report.selectedYear) { _, year in + dayCountPresentation.prepare(for: year) + } .sheet(isPresented: $showingResolution) { ResolutionView(report: report) } @@ -93,11 +114,12 @@ struct LocationsView: View { GlassEffectContainer(spacing: stylesheet.spacing.xxLarge) { VStack(spacing: stylesheet.spacing.xxLarge) { ForEach(report.ranking.primary) { item in + let presentedItem = dayCountPresentation.presented(item) NavigationLink { calendarDestination(item.region) } label: { RegionSummaryCard( - regionDays: item, + regionDays: presentedItem, interactive: true, yearLength: report.daysInSelectedYear, year: report.selectedYear, @@ -157,6 +179,22 @@ struct LocationsView: View { .defaultScrollAnchor(.center) .scrollBounceBehavior(.basedOnSize) .accessibilityIdentifier("where_root_title") + .onAppear { isCardSurfaceVisible = true } + .onDisappear { isCardSurfaceVisible = false } + // The task belongs to the cards, and its ID includes explicit visibility + // so a covering sheet cannot consume their baseline behind itself. + .task(id: dayCountReconciliationID) { + let reconciliation = dayCountReconciliationID + dayCountPresentation.reconcile( + reconciliation.counts, + in: reconciliation.year, + isVisible: reconciliation.isVisible, + ) + } + .sensoryFeedback( + .impact(weight: .light), + trigger: dayCountPresentation.feedbackTrigger, + ) } /// The region's calendar, pushed as a nested view. It's the zoom diff --git a/Where/WhereUI/Tests/LocationDayCountPresentationModelTests.swift b/Where/WhereUI/Tests/LocationDayCountPresentationModelTests.swift new file mode 100644 index 00000000..0d049eb1 --- /dev/null +++ b/Where/WhereUI/Tests/LocationDayCountPresentationModelTests.swift @@ -0,0 +1,128 @@ +import RegionKit +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct LocationDayCountPresentationModelTests { + private func preferences() -> WherePreferences { + WherePreferences(store: InMemoryKeyValueStore()) + } + + private func item(_ region: Region, _ days: Int) -> RegionDays { + RegionDays(region: region, days: days) + } + + @Test func firstRevealEstablishesBaselineSilently() { + let preferences = preferences() + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + let current = [item(.california, 148), item(.newYork, 37)] + + #expect(model.presented(current[0]).days == 148) + model.reconcile(current, in: 2026, isVisible: true) + + #expect(model.feedbackTrigger == 0) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [ + .california: 148, + .newYork: 37, + ]) + } + + @Test func increasedCountRevealsFromSavedValueAndTriggersOnce() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([.california: 148], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + let current = item(.california, 149) + + #expect(model.presented(current).days == 148) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [.california: 148]) + + model.reconcile([current], in: 2026, isVisible: true) + + #expect(model.presented(current).days == 149) + #expect(model.feedbackTrigger == 1) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [.california: 149]) + + model.reconcile([current], in: 2026, isVisible: true) + + #expect(model.feedbackTrigger == 1) + } + + @Test func decreasedCountRevealsWithoutFeedback() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([.california: 148], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + let current = item(.california, 147) + + #expect(model.presented(current).days == 148) + + model.reconcile([current], in: 2026, isVisible: true) + + #expect(model.presented(current).days == 147) + #expect(model.feedbackTrigger == 0) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [.california: 147]) + } + + @Test func multipleChangedCardsProduceOneFeedbackEvent() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([ + .california: 148, + .newYork: 37, + ], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + + model.reconcile([ + item(.california, 149), + item(.newYork, 38), + ], in: 2026, isVisible: true) + + #expect(model.feedbackTrigger == 1) + } + + @Test func unchangedAndNewCardsStaySilent() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([.california: 148], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + let current = [item(.california, 148), item(.newYork, 12)] + + #expect(model.presented(current[1]).days == 12) + model.reconcile(current, in: 2026, isVisible: true) + + #expect(model.feedbackTrigger == 0) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [ + .california: 148, + .newYork: 12, + ]) + } + + @Test func hiddenReconciliationLeavesCurrentCountsPending() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([.california: 148], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + let current = item(.california, 151) + + model.reconcile([current], in: 2026, isVisible: false) + + #expect(model.presented(current).days == 148) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [.california: 148]) + #expect(model.feedbackTrigger == 0) + + model.reconcile([current], in: 2026, isVisible: true) + + #expect(model.presented(current).days == 151) + #expect(preferences.lastSeenLocationDayCounts(in: 2026) == [.california: 151]) + } + + @Test func preparingAnotherYearUsesOnlyThatYearsBaseline() { + let preferences = preferences() + preferences.setLastSeenLocationDayCounts([.california: 25], in: 2025) + preferences.setLastSeenLocationDayCounts([.california: 148], in: 2026) + let model = LocationDayCountPresentationModel(preferences: preferences, year: 2026) + + model.prepare(for: 2025) + + #expect(model.year == 2025) + #expect(model.presented(item(.california, 30)).days == 25) + #expect(model.feedbackTrigger == 0) + } +} From 63e71a9740acedc8fbddc7476cbcbef8047598d5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 14:40:51 -0700 Subject: [PATCH 5/7] Add an open source passport footer to About (#193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > _Posted by an AI agent on kve’s behalf._ ## Summary - add a compact passport-style GitHub link with a subtle accent glow at the bottom of Settings > About - share the guilloché rosette renderer with the existing Locations cards while keeping footer styling independently tokenized - add in-file previews required by the architecture lint - capture every About snapshot at intrinsic full-content height across the existing iPhone and iPad matrices - teach SnapshotKitTesting to measure UIKit-backed SwiftUI forms from their root scroll content, with a regression test - localize the new copy, extend Settings search terms, and refresh About documentation ## Testing - ./swiftformat --lint - ./xcstrings --lint - swift run bumper lint . --timings - ./test WhereUITests (345 tests passed) - ./test SnapshotKitTestingTests (41 tests passed) - focused About snapshot matrix (16 configurations passed after recording and visual review) - focused Locations snapshot matrix passed --- Shared/SnapshotKit/README.md | 5 +- .../SnapshotConfiguration+Combinations.swift | 6 + .../Sources/SnapshotConfiguration.swift | 18 ++- .../Tests/SnapshotConfigurationTests.swift | 12 ++ Shared/SnapshotKitTesting/AGENTS.md | 4 + Shared/SnapshotKitTesting/README.md | 4 +- .../Sources/SnapshotImageRendering.swift | 37 +++++- .../Tests/LargeViewCaptureTests.swift | 36 +++++- Where/AGENTS.md | 3 +- Where/WhereUI/README.md | 3 +- .../about.Default_iPad.png | 4 +- .../about.Default_iPad_accessibility.png | 4 +- .../about.Default_iPad_ax5.png | 4 +- .../about.Default_iPad_contrast.png | 4 +- .../about.Default_iPad_dark.png | 4 +- .../about.Default_iPhone.png | 4 +- .../about.Default_iPhone_accessibility.png | 4 +- .../about.Default_iPhone_ax5.png | 4 +- .../about.Default_iPhone_contrast.png | 4 +- .../about.Default_iPhone_dark.png | 4 +- .../about.DirtyTree_iPhone.png | 4 +- .../about.DirtyTree_iPhone_dark.png | 4 +- .../about.LibrariesOnly_iPhone.png | 4 +- .../about.LibrariesOnly_iPhone_dark.png | 4 +- .../about.Unattributed_iPhone.png | 4 +- .../about.Unattributed_iPhone_dark.png | 4 +- .../Sources/Preview/WhereSnapshot.swift | 9 ++ .../Sources/Primary/RegionSummaryCard.swift | 45 ++------ .../Sources/Resources/Localizable.xcstrings | 26 ++++- .../Settings/AboutOpenSourceFooter.swift | 107 ++++++++++++++++++ .../Sources/Settings/AboutSettingsView.swift | 59 +++++----- .../Sources/Shared/SecurityPrintRosette.swift | 79 +++++++++++++ .../Sources/Shared/WhereStylesheet.swift | 76 +++++++++++++ .../Tests/AboutSettingsViewTests.swift | 7 +- .../WhereUI/Tests/WhereStylesheetTests.swift | 30 +++++ 35 files changed, 518 insertions(+), 112 deletions(-) create mode 100644 Where/WhereUI/Sources/Settings/AboutOpenSourceFooter.swift create mode 100644 Where/WhereUI/Sources/Shared/SecurityPrintRosette.swift diff --git a/Shared/SnapshotKit/README.md b/Shared/SnapshotKit/README.md index 46abbe2c..24ebe1ad 100644 --- a/Shared/SnapshotKit/README.md +++ b/Shared/SnapshotKit/README.md @@ -28,8 +28,9 @@ capture + comparison pipeline lives in the sibling (default zero, keeping images device-independent); the `.iPhoneNotched` preset simulates real device chrome (Dynamic Island top 47pt, home-indicator bottom 34pt) for cases that must prove layout under it. -- **`combinations(...)` + presets** (`.componentDefaults`, `.screenDefaults`) — - expand a terse declaration into the full matrix. +- **`combinations(...)` + presets** (`.componentDefaults`, `.screenDefaults`, + `.fullContentScreenDefaults`) — expand a terse declaration into the full + matrix. - **`SnapshotProviding`** — a type declares its variants via `static var snapshots: [SnapshotCase]`. - **`SnapshotCase`** — a named group of configurations plus a lazy content diff --git a/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift b/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift index 19fac3af..9d573155 100644 --- a/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift +++ b/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift @@ -70,6 +70,12 @@ extension [SnapshotConfiguration] { defaults(devices: [.iPhone, .iPad]) } + /// The default full-screen trait matrix at iPhone and iPad widths, with each + /// frame measured to the settled content's intrinsic height. + public static var fullContentScreenDefaults: Self { + defaults(devices: [.iPhoneFullContent, .iPadFullContent]) + } + private static func defaults(devices: [SnapshotConfiguration.Frame]) -> Self { SnapshotConfiguration.combinations(devices: devices) + SnapshotConfiguration.combinations(devices: devices, colorSchemes: [.dark]) diff --git a/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift b/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift index 39ba8b5b..29fd78d6 100644 --- a/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift +++ b/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift @@ -83,6 +83,9 @@ extension SnapshotConfiguration { /// How a variant is sized, plus an optional short name that stands in for the /// size in identifiers (e.g. `iPhone` instead of `402x874`). public struct Frame: Hashable, Sendable { + private static let iPhoneWidth: CGFloat = 402 + private static let iPadWidth: CGFloat = 834 + /// The identifier token for this frame (`""` for the unnamed component /// frame, `iPhone`/`iPad` for device frames). public var name: String @@ -139,19 +142,28 @@ extension SnapshotConfiguration { Frame(name: name, size: .fullContent(width: width)) } + /// The iPhone frame width with height measured from the settled content. + public static let iPhoneFullContent = fullContent(name: "iPhone", width: iPhoneWidth) + + /// The iPad frame width with height measured from the settled content. + public static let iPadFullContent = fullContent(name: "iPad", width: iPadWidth) + /// A phone screen frame (iPhone 17 point size). public static let iPhone = Frame( name: "iPhone", - size: .fixed(CGSize(width: 402, height: 874)), + size: .fixed(CGSize(width: iPhoneWidth, height: 874)), ) /// A tablet screen frame (iPad Pro 11" portrait point size). - public static let iPad = Frame(name: "iPad", size: .fixed(CGSize(width: 834, height: 1194))) + public static let iPad = Frame( + name: "iPad", + size: .fixed(CGSize(width: iPadWidth, height: 1194)), + ) /// The iPhone frame with simulated device insets (Dynamic Island top, /// home-indicator bottom), for cases that must prove layout under real /// device chrome rather than the inset-free default. public static let iPhoneNotched = Frame( name: "iPhoneNotched", - size: .fixed(CGSize(width: 402, height: 874)), + size: .fixed(CGSize(width: iPhoneWidth, height: 874)), safeAreaInsets: Insets(top: 47, leading: 0, bottom: 34, trailing: 0), ) } diff --git a/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift b/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift index 2a344f56..08aeb4bc 100644 --- a/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift +++ b/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift @@ -36,6 +36,18 @@ struct SnapshotConfigurationTests { #expect([SnapshotConfiguration].screenDefaults.count == 10) } + @Test func fullContentScreenDefaultsCoverBothDeviceWidths() { + let configs = [SnapshotConfiguration].fullContentScreenDefaults + #expect(configs.count == 10) + #expect(Set(configs.map(\.device.name)) == ["iPhone", "iPad"]) + #expect(configs.allSatisfy { configuration in + switch configuration.device.size { + case .fullContent: true + case .fixed, .intrinsic: false + } + }) + } + @Test func baselineIdentifierIsEmpty() { #expect(SnapshotConfiguration().identifier.isEmpty) } diff --git a/Shared/SnapshotKitTesting/AGENTS.md b/Shared/SnapshotKitTesting/AGENTS.md index 14f0e9aa..f84eb738 100644 --- a/Shared/SnapshotKitTesting/AGENTS.md +++ b/Shared/SnapshotKitTesting/AGENTS.md @@ -111,6 +111,10 @@ Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first. image for views past ~2000pt on iOS 27.0; don't remove the tiling without re-running the probe. Guard: `SnapshotKitTestingTests.LargeViewCaptureTests`. +- **Full-content sizing includes UIKit-backed SwiftUI containers.** When a + root-filling scroll view such as `Form` reports only its viewport through + `sizeThatFits`, use its content size. Guard: + `SnapshotKitTestingTests.LargeViewCaptureTests`. - **A settle phase costs its floor, not its passes.** Measured over all 260 references with `SNAPSHOT_TIMING=1`: 192 captures sit at 0.25-0.35s, the `minDuration` floor plus a pass or two, and the floor accounts for ~70s of diff --git a/Shared/SnapshotKitTesting/README.md b/Shared/SnapshotKitTesting/README.md index 9a2e125a..7d77cfa0 100644 --- a/Shared/SnapshotKitTesting/README.md +++ b/Shared/SnapshotKitTesting/README.md @@ -27,7 +27,9 @@ re-exports `SnapshotKit` and `SnapshotTesting`, so a test author needs a single any view at any size on a single fixed simulator: safe-area-inset overriding (zero by default; a frame's `safeAreaInsets`, e.g. `.iPhoneNotched`, simulates device chrome), animation quiescing, text-cursor hiding, and a - size-stabilization pass for SwiftUI hosting controllers. Captures serialize + size-stabilization pass for SwiftUI hosting controllers. Full-content captures + use the root scroll view's content size when UIKit-backed SwiftUI containers + such as `Form` report only their viewport through `sizeThatFits`. Captures serialize process-wide through an internal FIFO mutex — the pipeline holds process-global state (the safe-area swizzle, the animations flag, the one host window) across its suspensions, so a concurrent call queues behind the diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift b/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift index de1da00a..8c7c4d8e 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift @@ -311,7 +311,10 @@ private func removeChildAfterCapture(_ child: UIViewController) { /// appearance lifecycle driven (so SwiftUI `.task` loads and finite time-based /// reveals run), lets it settle, then measures `sizeThatFits` and pins the frame — /// so a content-loading component is sized to its loaded content rather than an -/// empty placeholder. `.fixed` sizing leaves the frame untouched. +/// empty placeholder. UIKit-backed SwiftUI containers such as `Form` report +/// their viewport rather than their ideal height, so a root-filling scroll view's +/// content size is used when it is taller. `.fixed` sizing leaves the frame +/// untouched. /// /// The measurement iterates to a fixed point: a lazy container (`LazyVStack` in /// a `ScrollView`) reports an *estimated* content height until its rows @@ -359,6 +362,11 @@ private func resolveContentSize( if !measured.height.isFinite || measured.height <= 0 { measured.height = 1 } + if let scrollContentHeight = viewController.view.rootScrollContentHeight, + scrollContentHeight > measured.height + { + measured.height = scrollContentHeight + } return measured } @@ -381,3 +389,30 @@ private func resolveContentSize( viewController.view.frame = CGRect(origin: .zero, size: measured) CATransaction.performWithoutAnimation(viewController.view.layoutIfNeeded) } + +extension UIView { + /// The content height of a scroll view that fills this view, if one exists. + /// + /// `Form` and `List` are backed by UIKit scroll views whose hosting view + /// answers `sizeThatFits` with only the current viewport. Restricting the + /// fallback to a root-filling scroll view avoids expanding an intentionally + /// fixed-height nested scroller inside an otherwise intrinsic component. + fileprivate var rootScrollContentHeight: CGFloat? { + descendants + .compactMap { view -> CGFloat? in + guard let scrollView = view as? UIScrollView else { return nil } + let visibleFrame = scrollView.convert(scrollView.bounds, to: self) + guard visibleFrame.width >= bounds.width - 1, + visibleFrame.height >= bounds.height - 1, + scrollView.contentSize.height.isFinite, + scrollView.contentSize.height > 0 + else { return nil } + return scrollView.contentSize.height + } + .max() + } + + private var descendants: [UIView] { + subviews + subviews.flatMap(\.descendants) + } +} diff --git a/Shared/SnapshotKitTesting/Tests/LargeViewCaptureTests.swift b/Shared/SnapshotKitTesting/Tests/LargeViewCaptureTests.swift index 73965a94..dcadb504 100644 --- a/Shared/SnapshotKitTesting/Tests/LargeViewCaptureTests.swift +++ b/Shared/SnapshotKitTesting/Tests/LargeViewCaptureTests.swift @@ -13,7 +13,9 @@ import UIKit /// `ScrollView` measured under the pipeline's unbounded `sizeThatFits` proposal /// must report its *content* height (verified on this toolchain — no /// `.fixedSize` shim is needed), so the whole scrollable content renders with -/// nothing scrolled out of frame. +/// nothing scrolled out of frame. UIKit-backed SwiftUI containers such as +/// `Form` need the pipeline's root-scroll fallback because they report only +/// their viewport through `sizeThatFits`. @MainActor struct LargeViewCaptureTests { @Test func capturesAShortViewInOneTile() async throws { @@ -59,6 +61,38 @@ struct LargeViewCaptureTests { #expect(sample.bottom.blue > 0.5) } + @Test func capturesAFormsFullContentUnderContentMeasuredSizing() async throws { + try waitFor { hostKeyWindow() != nil } + let form = Form { + Section { + ForEach(0 ..< 20, id: \.self) { _ in + Color.red + .frame(height: 80) + .listRowInsets(EdgeInsets()) + } + Color.blue + .frame(height: 500) + .listRowInsets(EdgeInsets()) + } + } + let host = UIHostingController(rootView: form) + host.view.frame = CGRect(x: 0, y: 0, width: 402, height: 1) + let image = await renderSnapshotImage( + of: host, + named: "full-content-form-probe", + sizing: .intrinsic(width: 402), + safeAreaInsets: .zero, + ) + let sample = TwoToneSample( + top: image.probePixel(atUnitPoint: CGPoint(x: 0.5, y: 0.1)), + bottom: image.probePixel(atUnitPoint: CGPoint(x: 0.5, y: 0.9)), + size: image.size, + ) + #expect(sample.size.height > 2000) + #expect(sample.top.red > 0.5) + #expect(sample.bottom.blue > 0.5) + } + private func renderTwoTone(height: CGFloat) async throws -> TwoToneSample { try waitFor { hostKeyWindow() != nil } let half = height / 2 diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 97106ed7..12634a64 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -192,7 +192,8 @@ The About screen renders three live sources — the generated attribution report (`WhereCore.AppAttribution`), `RegionDataSource`, and `BuildInfo` — never a list hard-coded in the view. A missing report or unstamped build renders an honest empty state, and shipped libraries stay a separate section -from development tools. Design and rationale: PR #140. +from development tools; keep its final passport sign-off linked to the public +project repository. Design and rationale: PR #140. ## Localization diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index e79647d1..3ec7dbb3 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -29,7 +29,8 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's identity, the app's generated attribution report (linked libraries and development tools as separate sections), and bundled-data provenance, each vended by whoever owns it rather than listed in the view; it renders an - explicit "no report" state, since only the app bundle carries one. `MainTabs` + explicit "no report" state, since only the app bundle carries one, and ends + with a passport-style link to the project's public source on GitHub. `MainTabs` is built from the `WhereSession` the launch's `.ready` carries. The app injects the launch-built model + runner (`init(model:launcher:)`); a no-arg `init()` builds its own for previews and diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png index ae7bcb34..c983c0a7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d16877a26de7cc1262f0c5e081b3a7bffe748922fbe876c03be50a14ce9e3519 -size 406231 +oid sha256:fb8a7c160f46977063087ea2a4780897217a6381ebe2ba389527c83b4c9657e1 +size 955834 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png index e01a0af4..7ae807fb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e6c79b9d0b9429ad023e5774952453db4a32fe6c31cd6ee0d7b2b9252c5d08a -size 796723 +oid sha256:45aaee169a32568a152d89eb3a4f34e9c5327b50a6b68ed52a471dd5ce7ce7cd +size 1205369 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png index edde49c5..22ca2a43 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d5f0bd8128cf203d7509350955212b9f2b80ee35b85b24e8330dc29c706863bd -size 427898 +oid sha256:67b086c9e78a44247c5ea51c08518af16c751f51b8c4e8795e48e2c300994724 +size 2401961 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png index 529034cd..1b042b4d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f15676ecafb05821a1cacc4dba3017586856b903b5727cd3cdb8a680de26c6e7 -size 411907 +oid sha256:c64b11a4ad5c000d742913fa014ce28bd074210187fec6d6b97724caa0e71b6f +size 951556 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png index f821ec39..ab2cd603 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22e2a092e740e002c382e1c6ca9eb20e967f336a9755a061474b4b028fc3f215 -size 414527 +oid sha256:cf280dabdee082665d729789a839aaebc07f964f252d1769e527ec5ef846cda3 +size 773604 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png index aaf1a582..154927e9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f36b11999560df92ae16d3040cccc46e6a33411b9ce05f05aa8187a431b81ed6 -size 251612 +oid sha256:6955660bdde045d218df5c1875dcf3d1d9455b287af102c84b47278b213c77a5 +size 647309 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png index bb6c5c15..0b8df0a3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9446a5b49591cee473e80b85bed80ef191b1043acb88ff47e5cc06ce58eef5f4 -size 583176 +oid sha256:7da9756f248c88d888bcbe57ba6f974113bab971356885519ad29e8ea65f28e5 +size 1006933 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png index b4d60a53..05535e7e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a630c2183190275da6e6104af26c35cbc722809bcabea9a29489f80733cacfd8 -size 180823 +oid sha256:188aed1fdeb08c4abfc3c366f7ac949e314425da75468f398c764f978debd1d0 +size 2692088 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png index 4aa51153..dee8e044 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68cbdf83ad7bc523135f27b820cafda7ca1296660c905217f6c5c7f6078487a0 -size 254956 +oid sha256:158600d82f01b22c6d80c175faf5af6782621c220ac5c5c3126b95f4bbb4dede +size 683421 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png index d73721f1..f8d0d768 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3c8e5b983839c6e005574edcd28880443a5b440cbbbd5da123aa0fb9fe130aa1 -size 258418 +oid sha256:f6214be2024edb4437dc59b1aeca3adaee65b7224a5820c6f28fb87fe29d5aa7 +size 553998 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png index 95341c36..c10af511 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67723aed0d350c0d3291ee576f868d2df963d7f788c716c8420ac41ccf930191 -size 254847 +oid sha256:f4dd8fb90e736c8e6c68355315418cff393c3deba2e76112c498cd2380cbae3d +size 650980 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png index 9472c6ac..60c02a57 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6fff7e9d2db0e717f123d507b633762340ba108cbb1821a4ca69de902519b865 -size 261816 +oid sha256:4f548328017d832afe70ed1a6d9bcf8a7a23aa2e83897900f431c1b57f88dbf5 +size 557873 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png index 73748ddc..73ef8c33 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe2e51ca3cfeb4581bc42d1d4307aaecf71af43af39586afdd9a6d542ee03cb6 -size 247914 +oid sha256:98b1186bd54de38df4832b199b182b5f00369400b646bf50870d8a69b0e21e6b +size 644510 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png index 1d756567..a393b146 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:015abffbcf61d81ca485768b6c624bde3afc86c92e557a7b5c0afe748ad5ab7e -size 254744 +oid sha256:ef8f25f0a7f403e1ababdda990451568449b9a7474bd303ed39bf9239f7d093c +size 550854 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png index f95fcf82..1655e28f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:34210c696d0ef302e9e3c0d309645084825ba73b0a484579b2c5f66018bb1b93 -size 250420 +oid sha256:2e0a89b2c88eb0d0a9686bee78a10495a87d97af84f13ac792c04d5adc5102bc +size 655379 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png index 26776462..145a7672 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:64c3cd1528ac0b2ffd7c4ea72894f9e6adf7d7ca9321238592841da11d52c5f1 -size 257061 +oid sha256:c33be6a0c3b0d6449733f4309b825a486f58687b3bcfa84a97ce1ea5b962ce90 +size 554945 diff --git a/Where/WhereUI/Sources/Preview/WhereSnapshot.swift b/Where/WhereUI/Sources/Preview/WhereSnapshot.swift index 10af4dc1..8f4a5e70 100644 --- a/Where/WhereUI/Sources/Preview/WhereSnapshot.swift +++ b/Where/WhereUI/Sources/Preview/WhereSnapshot.swift @@ -38,6 +38,15 @@ SnapshotConfiguration.combinations(devices: [.iPhone], colorSchemes: [.light, .dark]) } + /// Light + dark at an intrinsic-height iPhone-width frame — the compact + /// matrix for extra states on a full-content screen. + static var fullContentPhoneLightDark: Self { + SnapshotConfiguration.combinations( + devices: [.iPhoneFullContent], + colorSchemes: [.light, .dark], + ) + } + /// Light + dark at the component frame — the compact matrix for the extra /// states in a sheet/component/widget loop. static var componentLightDark: Self { diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index a4d9566e..d935f3a0 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -138,42 +138,15 @@ struct RegionSummaryCard: View { let rosette = card.rosette let rosetteFill = cardStyles.rosetteFill return ZStack { - Canvas { context, size in - func drawRosette(center: CGPoint, spacing: CGFloat, opacity: Double) { - let ringCount = Int(max(size.width, size.height) / spacing) - for ring in 1 ... max(1, ringCount) { - let angle = Double(ring) * 0.55 - let ringCenter = CGPoint( - x: center.x + CGFloat(cos(angle)) * rosette.wobble, - y: center.y + CGFloat(sin(angle)) * rosette.wobble, - ) - let radius = CGFloat(ring) * spacing - let rect = CGRect( - x: ringCenter.x - radius, - y: ringCenter.y - radius, - width: radius * 2, - height: radius * 2, - ) - context.stroke( - Path(ellipseIn: rect), - with: .color(tint.opacity(opacity)), - lineWidth: rosette.lineWidth, - ) - } - } - // A bold rosette behind the stamp, plus a smaller, fainter one - // in the opposite corner for denser, layered security print. - drawRosette( - center: CGPoint(x: size.width * 0.8, y: size.height * 0.5), - spacing: rosette.primaryRingSpacing, - opacity: rosetteFill.primary, - ) - drawRosette( - center: CGPoint(x: size.width * 0.12, y: size.height * 0.22), - spacing: rosette.secondaryRingSpacing, - opacity: rosetteFill.secondary, - ) - } + SecurityPrintRosette( + tint: tint, + wobble: rosette.wobble, + lineWidth: rosette.lineWidth, + primaryRingSpacing: rosette.primaryRingSpacing, + secondaryRingSpacing: rosette.secondaryRingSpacing, + primaryOpacity: rosetteFill.primary, + secondaryOpacity: rosetteFill.secondary, + ) if let regionShape = card.regionShape, diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index eaf07531..8aef4f16 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -5194,6 +5194,30 @@ } } }, + "settings.about.source.action" : { + "comment" : "Action text in the About screen's open-source footer.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Explore the project on GitHub." + } + } + } + }, + "settings.about.source.title" : { + "comment" : "Headline in the About screen's open-source footer.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where is open source." + } + } + } + }, "settings.about.value.unknown" : { "extractionState" : "manual", "localizations" : { @@ -5712,7 +5736,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "license, licenses, open source, credits, acknowledgements, libraries, dependencies, third party" + "value" : "license, licenses, open source, credits, acknowledgements, libraries, dependencies, third party, github, source code, repository" } } } diff --git a/Where/WhereUI/Sources/Settings/AboutOpenSourceFooter.swift b/Where/WhereUI/Sources/Settings/AboutOpenSourceFooter.swift new file mode 100644 index 00000000..9a36359d --- /dev/null +++ b/Where/WhereUI/Sources/Settings/AboutOpenSourceFooter.swift @@ -0,0 +1,107 @@ +import Foundation +import SwiftUI + +/// A compact passport-style sign-off linking the About screen to Where's source. +struct AboutOpenSourceFooter: View { + static let projectURL = URL(string: "https://github.com/kyleve/Stuff")! + + @Environment(\.stylesheet) private var stylesheet + + private var style: WhereStylesheet.AboutOpenSourceStyle { + stylesheet.aboutOpenSource + } + + private var shape: RoundedRectangle { + RoundedRectangle(cornerRadius: style.cornerRadius) + } + + var body: some View { + Link(destination: Self.projectURL) { + HStack(spacing: style.contentSpacing) { + sourceSeal + + VStack(alignment: .leading, spacing: stylesheet.spacing.xxSmall) { + Text(String(localized: .settingsAboutSourceTitle)) + .font(style.titleFont) + .foregroundStyle(.primary) + Text(String(localized: .settingsAboutSourceAction)) + .font(style.actionFont) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + Image(systemName: "arrow.up.right") + .foregroundStyle(.tint) + .accessibilityHidden(true) + } + .padding(style.padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + let rosette = style.rosette + SecurityPrintRosette( + tint: .accentColor, + wobble: rosette.wobble, + lineWidth: rosette.lineWidth, + primaryRingSpacing: rosette.primaryRingSpacing, + secondaryRingSpacing: rosette.secondaryRingSpacing, + primaryOpacity: rosette.primaryOpacity, + secondaryOpacity: rosette.secondaryOpacity, + ) + } + .glassEffect( + .regular.tint(Color.accentColor.opacity(style.glassTintOpacity)) + .interactive(), + in: shape, + ) + .clipShape(shape) + .contentShape(shape) + .shadow( + color: Color.accentColor.opacity(style.accentGlow.opacity), + radius: style.accentGlow.radius, + y: style.accentGlow.offsetY, + ) + .shadow( + color: Color.black.opacity(style.liftShadow.opacity), + radius: style.liftShadow.radius, + y: style.liftShadow.offsetY, + ) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + } + + private var sourceSeal: some View { + let seal = style.seal + return ZStack { + Circle() + .strokeBorder(.tint, lineWidth: seal.outerLineWidth) + Circle() + .strokeBorder( + .tint.opacity(0.65), + style: StrokeStyle( + lineWidth: seal.innerLineWidth, + dash: [seal.dashLength, seal.dashSpacing], + ), + ) + .padding(seal.innerInset) + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(seal.symbolFont) + .foregroundStyle(.tint) + } + .frame(width: seal.size, height: seal.size) + .rotationEffect(.degrees(seal.rotationDegrees)) + .accessibilityHidden(true) + } +} + +#if DEBUG + #Preview { + Form { + AboutOpenSourceFooter() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift index a32e6b02..7c87ac08 100644 --- a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift @@ -48,6 +48,9 @@ struct AboutSettingsView: View { dependenciesSection developmentToolsSection dataSourcesSection + AboutOpenSourceFooter() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } } .navigationTitle(String(localized: .settingsAboutHeader)) @@ -230,47 +233,39 @@ extension AboutSettingsView: SettingsSection { /// and attributed, so the interesting cases are what each missing piece /// renders as. static var snapshots: [SnapshotCase] { - whereSnapshot(name: "Default", configurations: .screenDefaults) { - NavigationStack { - AboutSettingsView( - focus: nil, - buildInfo: PreviewSupport.stampedBuildInfo(), - attribution: PreviewSupport.sampleAttribution(), - ) - } + whereSnapshot(name: "Default", configurations: .fullContentScreenDefaults) { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: PreviewSupport.sampleAttribution(), + ) } - whereSnapshot(name: "DirtyTree", configurations: .phoneLightDark) { - NavigationStack { - AboutSettingsView( - focus: nil, - buildInfo: PreviewSupport.stampedBuildInfo(isDirty: true), - attribution: PreviewSupport.sampleAttribution(), - ) - } + whereSnapshot(name: "DirtyTree", configurations: .fullContentPhoneLightDark) { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(isDirty: true), + attribution: PreviewSupport.sampleAttribution(), + ) } - whereSnapshot(name: "Unattributed", configurations: .phoneLightDark) { + whereSnapshot(name: "Unattributed", configurations: .fullContentPhoneLightDark) { // What a bundle outside the app target shows: honest unknowns and // an explicit "no report" rather than blank rows and empty sections. - NavigationStack { - AboutSettingsView( - focus: nil, - buildInfo: PreviewSupport.unstampedBuildInfo(), - attribution: nil, - ) - } + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: nil, + ) } - whereSnapshot(name: "LibrariesOnly", configurations: .phoneLightDark) { + whereSnapshot(name: "LibrariesOnly", configurations: .fullContentPhoneLightDark) { // A real report that credits nothing of one kind. Pinned as an // image because the failure mode is purely visual: a header and // footer over no rows, promising a list that isn't there. let libraries = PreviewSupport.sampleAttribution().credits(ofKind: .library) - NavigationStack { - AboutSettingsView( - focus: nil, - buildInfo: PreviewSupport.stampedBuildInfo(), - attribution: AttributionManifest(credits: libraries), - ) - } + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: AttributionManifest(credits: libraries), + ) } } } diff --git a/Where/WhereUI/Sources/Shared/SecurityPrintRosette.swift b/Where/WhereUI/Sources/Shared/SecurityPrintRosette.swift new file mode 100644 index 00000000..9b2ccb03 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/SecurityPrintRosette.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// Draws the layered concentric-ring security print shared by Where's passport +/// surfaces. Callers own the appearance values so each component can tune its +/// density without duplicating the guilloché renderer. +struct SecurityPrintRosette: View { + let tint: Color + let wobble: CGFloat + let lineWidth: CGFloat + let primaryRingSpacing: CGFloat + let secondaryRingSpacing: CGFloat + let primaryOpacity: Double + let secondaryOpacity: Double + + var body: some View { + Canvas { context, size in + drawRosette( + in: &context, + size: size, + center: CGPoint(x: size.width * 0.8, y: size.height * 0.5), + spacing: primaryRingSpacing, + opacity: primaryOpacity, + ) + drawRosette( + in: &context, + size: size, + center: CGPoint(x: size.width * 0.12, y: size.height * 0.22), + spacing: secondaryRingSpacing, + opacity: secondaryOpacity, + ) + } + .accessibilityHidden(true) + } + + private func drawRosette( + in context: inout GraphicsContext, + size: CGSize, + center: CGPoint, + spacing: CGFloat, + opacity: Double, + ) { + let ringCount = Int(max(size.width, size.height) / spacing) + for ring in 1 ... max(1, ringCount) { + let angle = Double(ring) * 0.55 + let ringCenter = CGPoint( + x: center.x + CGFloat(cos(angle)) * wobble, + y: center.y + CGFloat(sin(angle)) * wobble, + ) + let radius = CGFloat(ring) * spacing + let rect = CGRect( + x: ringCenter.x - radius, + y: ringCenter.y - radius, + width: radius * 2, + height: radius * 2, + ) + context.stroke( + Path(ellipseIn: rect), + with: .color(tint.opacity(opacity)), + lineWidth: lineWidth, + ) + } + } +} + +#if DEBUG + #Preview { + let rosette = WhereStylesheet.default.aboutOpenSource.rosette + SecurityPrintRosette( + tint: .accentColor, + wobble: rosette.wobble, + lineWidth: rosette.lineWidth, + primaryRingSpacing: rosette.primaryRingSpacing, + secondaryRingSpacing: rosette.secondaryRingSpacing, + primaryOpacity: rosette.primaryOpacity, + secondaryOpacity: rosette.secondaryOpacity, + ) + .frame(width: 360, height: 120) + } +#endif diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 173d57dc..bfb2f4ff 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -27,6 +27,7 @@ struct WhereStylesheet: BStylesheet { var launch = LaunchStyle.standard var typography = Typography.standard var settings = SettingsStyle.standard + var aboutOpenSource = AboutOpenSourceStyle.standard var developerOverlay = DeveloperOverlayStyle.standard init() {} @@ -1256,6 +1257,81 @@ extension WhereStylesheet { } } +// MARK: - About open source + +extension WhereStylesheet { + /// Appearance for the compact open-source sign-off at the bottom of About. + /// It echoes the Locations cards' security print without borrowing their + /// region-specific card spec. + struct AboutOpenSourceStyle: Equatable { + var cornerRadius: CGFloat + var padding: CGFloat + var contentSpacing: CGFloat + var titleFont: Font + var actionFont: Font + var seal: Seal + var rosette: Rosette + var glassTintOpacity: Double + var accentGlow: Shadow + var liftShadow: Shadow + + struct Seal: Equatable { + var size: CGFloat + var rotationDegrees: Double + var outerLineWidth: CGFloat + var innerLineWidth: CGFloat + var innerInset: CGFloat + var dashLength: CGFloat + var dashSpacing: CGFloat + var symbolFont: Font + } + + struct Rosette: Equatable { + var wobble: CGFloat + var lineWidth: CGFloat + var primaryRingSpacing: CGFloat + var secondaryRingSpacing: CGFloat + var primaryOpacity: Double + var secondaryOpacity: Double + } + + struct Shadow: Equatable { + var opacity: Double + var radius: CGFloat + var offsetY: CGFloat = 0 + } + + static let standard = AboutOpenSourceStyle( + cornerRadius: 20, + padding: 16, + contentSpacing: 12, + titleFont: .headline, + actionFont: .subheadline, + seal: Seal( + size: 52, + rotationDegrees: -8, + outerLineWidth: 2, + innerLineWidth: 1, + innerInset: 7, + dashLength: 3, + dashSpacing: 3, + symbolFont: .title3, + ), + rosette: Rosette( + wobble: 5, + lineWidth: 0.75, + primaryRingSpacing: 10, + secondaryRingSpacing: 16, + primaryOpacity: 0.1, + secondaryOpacity: 0.06, + ), + glassTintOpacity: 0.06, + accentGlow: Shadow(opacity: 0.18, radius: 7), + liftShadow: Shadow(opacity: 0.08, radius: 5, offsetY: 2), + ) + } +} + // MARK: - Palette extension WhereStylesheet { diff --git a/Where/WhereUI/Tests/AboutSettingsViewTests.swift b/Where/WhereUI/Tests/AboutSettingsViewTests.swift index 80add30c..6f9f0129 100644 --- a/Where/WhereUI/Tests/AboutSettingsViewTests.swift +++ b/Where/WhereUI/Tests/AboutSettingsViewTests.swift @@ -33,12 +33,17 @@ struct AboutSettingsViewTests { @Test func searchFindsTheAboutSettingsByKeyword() { // None of these words appear in the section titles, so a match proves the // keyword lists are wired rather than the titles happening to overlap. - for query in ["licenses", "sha", "geojson", "skills"] { + for query in ["licenses", "sha", "geojson", "skills", "github", "repository"] { let destinations = Set(SettingsCatalog.results(matching: query).map(\.destination)) #expect(destinations.contains(.about), "no About result for \"\(query)\"") } } + @Test func linksToTheCanonicalProjectRepository() { + #expect(AboutOpenSourceFooter.projectURL + .absoluteString == "https://github.com/kyleve/Stuff") + } + // MARK: Rendering @Test func hostsAStampedBuild() throws { diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 32fe6573..603e0e0e 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -359,6 +359,36 @@ struct WhereStylesheetTests { #expect(settings.scrollSettleDelay == .milliseconds(350)) } + @Test func aboutOpenSourceStyle() { + let source = style.aboutOpenSource + #expect(source.cornerRadius == 20) + #expect(source.padding == 16) + #expect(source.contentSpacing == 12) + #expect(source.titleFont == .headline) + #expect(source.actionFont == .subheadline) + #expect(source.seal == .init( + size: 52, + rotationDegrees: -8, + outerLineWidth: 2, + innerLineWidth: 1, + innerInset: 7, + dashLength: 3, + dashSpacing: 3, + symbolFont: .title3, + )) + #expect(source.rosette == .init( + wobble: 5, + lineWidth: 0.75, + primaryRingSpacing: 10, + secondaryRingSpacing: 16, + primaryOpacity: 0.1, + secondaryOpacity: 0.06, + )) + #expect(source.glassTintOpacity == 0.06) + #expect(source.accentGlow == .init(opacity: 0.18, radius: 7)) + #expect(source.liftShadow == .init(opacity: 0.08, radius: 5, offsetY: 2)) + } + @Test func developerOverlayStyle() { let overlay = style.developerOverlay #expect(overlay.edgeInset == 16) From ea075f63d39d76311ddb3a7bbd40006a266a947c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 15:30:07 -0700 Subject: [PATCH 6/7] Make run-loop observer restart test deterministic (#194) --- .../Sources/SnapshotQuiescence.swift | 5 +++++ .../Tests/SnapshotQuiescenceTests.swift | 22 +++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotQuiescence.swift b/Shared/SnapshotKitTesting/Sources/SnapshotQuiescence.swift index 93b4378f..6d394bd6 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotQuiescence.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotQuiescence.swift @@ -54,6 +54,11 @@ import UIKit private var observer: CFRunLoopObserver? public private(set) var idleCount = 0 + /// The observer whose run-loop registration the regression tests inspect. + public var registeredObserver: CFRunLoopObserver? { + observer + } + public init() {} public func start() { diff --git a/Shared/SnapshotKitTesting/Tests/SnapshotQuiescenceTests.swift b/Shared/SnapshotKitTesting/Tests/SnapshotQuiescenceTests.swift index cd628e14..8d525c74 100644 --- a/Shared/SnapshotKitTesting/Tests/SnapshotQuiescenceTests.swift +++ b/Shared/SnapshotKitTesting/Tests/SnapshotQuiescenceTests.swift @@ -55,22 +55,20 @@ struct SnapshotQuiescenceTests { #expect(counter.idleCount == afterStop) } - @Test func restartingReplacesRatherThanDoublesTheObserver() async { + @Test func restartingReplacesRatherThanDoublesTheObserver() throws { let counter = RunLoopIdleCounter() counter.start() + let firstObserver = try #require(counter.registeredObserver) + let runLoop = CFRunLoopGetMain() + #expect(CFRunLoopContainsObserver(runLoop, firstObserver, .commonModes)) + counter.start() + let replacementObserver = try #require(counter.registeredObserver) defer { counter.stop() } - try? await Task.sleep(for: .milliseconds(50)) - let single = RunLoopIdleCounter() - single.start() - defer { single.stop() } - try? await Task.sleep(for: .milliseconds(50)) - // A doubled observer would count each idle twice. Compare growth rates - // over the same window rather than absolute counts, which depend on how - // many times the loop happened to sleep. - let restarted = counter.idleCount - let baseline = single.idleCount - #expect(restarted < baseline * 3) + + #expect(firstObserver !== replacementObserver) + #expect(CFRunLoopContainsObserver(runLoop, firstObserver, .commonModes) == false) + #expect(CFRunLoopContainsObserver(runLoop, replacementObserver, .commonModes)) } @Test func pendingLayoutIsSeenAnywhereInTheSubtree() { From c3df78657f960d079fb78ae315c71615e847dded Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 18:04:21 -0700 Subject: [PATCH 7/7] Add privacy passport cards to Data and About (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add an inset “Private by design” passport card at the top of Data and About - state the privacy model directly: location history stays on the user’s devices and in their private iCloud account, and Where sends no data to anyone - render the privacy card as a passport-navy, gold-accented reflective surface using the location cards’ snapshot- and Reduce Motion-aware tilt treatment - keep the security-print rosette and Liquid Glass treatment independent from motion availability, so the complete surface remains visible before or without a tilt sample - share the passport-card structure with the GitHub link while retaining standard Form insets and the native two-shadow treatment from main; the privacy variant uses a quieter dark-navy glow - cover the complete Data and About pages with intrinsic-size iPhone and iPad snapshots across appearance, contrast, and accessibility variants ## Testing - ./test WhereUITests (354 tests passed before the visual follow-ups) - focused PassportCardSurfaceKindTests, PassportCardSurfaceTests, PassportCardTests, PrivacyPassportCardTests, and passportCardStyle tests - focused AboutSettingsViewSnapshotTests and DataSettingsViewSnapshotTests - ./swiftformat --lint - ./xcstrings --lint - swift run bumper lint . --timings --- Where/AGENTS.md | 1 + Where/WhereUI/README.md | 7 +- .../DataSettingsViewSnapshotTests.swift | 10 ++ .../about.Default_iPad.png | 4 +- .../about.Default_iPad_accessibility.png | 4 +- .../about.Default_iPad_ax5.png | 4 +- .../about.Default_iPad_contrast.png | 4 +- .../about.Default_iPad_dark.png | 4 +- .../about.Default_iPhone.png | 4 +- .../about.Default_iPhone_accessibility.png | 4 +- .../about.Default_iPhone_ax5.png | 4 +- .../about.Default_iPhone_contrast.png | 4 +- .../about.Default_iPhone_dark.png | 4 +- .../about.DirtyTree_iPhone.png | 4 +- .../about.DirtyTree_iPhone_dark.png | 4 +- .../about.LibrariesOnly_iPhone.png | 4 +- .../about.LibrariesOnly_iPhone_dark.png | 4 +- .../about.Unattributed_iPhone.png | 4 +- .../about.Unattributed_iPhone_dark.png | 4 +- .../data.Default_iPad.png | 3 + .../data.Default_iPad_accessibility.png | 3 + .../data.Default_iPad_ax5.png | 3 + .../data.Default_iPad_contrast.png | 3 + .../data.Default_iPad_dark.png | 3 + .../data.Default_iPhone.png | 3 + .../data.Default_iPhone_accessibility.png | 3 + .../data.Default_iPhone_ax5.png | 3 + .../data.Default_iPhone_contrast.png | 3 + .../data.Default_iPhone_dark.png | 3 + .../Sources/Resources/Localizable.xcstrings | 24 ++++ .../Settings/AboutOpenSourceFooter.swift | 89 ++------------- .../Sources/Settings/AboutSettingsView.swift | 9 +- .../Sources/Settings/DataSettingsView.swift | 31 +++-- .../Settings/PrivacyPassportCard.swift | 31 +++++ .../WhereUI/Sources/Shared/PassportCard.swift | 108 ++++++++++++++++++ .../Sources/Shared/PassportCardSurface.swift | 82 +++++++++++++ .../Shared/PassportCardSurfaceKind.swift | 20 ++++ .../Sources/Shared/SecurityPrintRosette.swift | 2 +- .../Sources/Shared/WhereStylesheet.swift | 41 +++++-- .../Tests/PassportCardSurfaceKindTests.swift | 14 +++ .../Tests/PassportCardSurfaceTests.swift | 29 +++++ Where/WhereUI/Tests/PassportCardTests.swift | 22 ++++ .../Tests/PrivacyPassportCardTests.swift | 15 +++ .../WhereUI/Tests/WhereStylesheetTests.swift | 15 ++- 44 files changed, 501 insertions(+), 143 deletions(-) create mode 100644 Where/WhereUI/SnapshotTests/DataSettingsViewSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_dark.png create mode 100644 Where/WhereUI/Sources/Settings/PrivacyPassportCard.swift create mode 100644 Where/WhereUI/Sources/Shared/PassportCard.swift create mode 100644 Where/WhereUI/Sources/Shared/PassportCardSurface.swift create mode 100644 Where/WhereUI/Sources/Shared/PassportCardSurfaceKind.swift create mode 100644 Where/WhereUI/Tests/PassportCardSurfaceKindTests.swift create mode 100644 Where/WhereUI/Tests/PassportCardSurfaceTests.swift create mode 100644 Where/WhereUI/Tests/PassportCardTests.swift create mode 100644 Where/WhereUI/Tests/PrivacyPassportCardTests.swift diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 12634a64..81c14445 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -188,6 +188,7 @@ typed-route list (`SettingsSearch.swift`; every switch is exhaustive), so a new drill-in is a set of compile errors to fill in; About stays the last block and the demo-mode exit the first. +The Data and About screens lead with the shared privacy passport statement. The About screen renders three live sources — the generated attribution report (`WhereCore.AppAttribution`), `RegionDataSource`, and `BuildInfo` — never a list hard-coded in the view. A missing report or unstamped build diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 3ec7dbb3..34d2b3cb 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -25,8 +25,11 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's Settings. Elsewhere is an entry card on Locations, Resolve a Locations toolbar button, and the data screens (attachments, logged days, regions) sit in the Settings "Data" group. Backup and destructive data management share one Data - drill-in. `AboutSettingsView` is the last Settings block — build - identity, the app's generated attribution report (linked libraries and + drill-in. Both Data and About lead with the same full-width passport-style + privacy statement on a passport-navy, tilt-reflective surface: location + history stays on the user's devices and in their private iCloud account, + never on Where-operated servers. `AboutSettingsView` is the last Settings block — + build identity, the app's generated attribution report (linked libraries and development tools as separate sections), and bundled-data provenance, each vended by whoever owns it rather than listed in the view; it renders an explicit "no report" state, since only the app bundle carries one, and ends diff --git a/Where/WhereUI/SnapshotTests/DataSettingsViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/DataSettingsViewSnapshotTests.swift new file mode 100644 index 00000000..a8b3084d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/DataSettingsViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct DataSettingsViewSnapshotTests { + @Test func data() async { + await assertSnapshots(of: DataSettingsView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png index c983c0a7..7525bec2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb8a7c160f46977063087ea2a4780897217a6381ebe2ba389527c83b4c9657e1 -size 955834 +oid sha256:d8196f7ec00c516d5a39fde9fbef3628af3cc9209b7accd41d923310046a2c23 +size 1864117 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png index 7ae807fb..d152642b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:45aaee169a32568a152d89eb3a4f34e9c5327b50a6b68ed52a471dd5ce7ce7cd -size 1205369 +oid sha256:b8fa628dad7a706c87b5572eaedcc291b0e30a9498055591fc616c0794b9d929 +size 1626944 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png index 22ca2a43..191dccf9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67b086c9e78a44247c5ea51c08518af16c751f51b8c4e8795e48e2c300994724 -size 2401961 +oid sha256:c176007f9e5d0b57c9c651a6abc6770e75991d40ba6cda5a14db6884427619a3 +size 5531642 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png index 1b042b4d..af237cfc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c64b11a4ad5c000d742913fa014ce28bd074210187fec6d6b97724caa0e71b6f -size 951556 +oid sha256:dc8b8c0306f341e1b6886e1be090049cbbe0c04bf65861374476a5d626a58aee +size 1867488 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png index ab2cd603..84b39381 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf280dabdee082665d729789a839aaebc07f964f252d1769e527ec5ef846cda3 -size 773604 +oid sha256:f96454b716c35d0d4d6ca47bd1bea047d5c5215b3cd78394bcb729542d1cf731 +size 1663390 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png index 154927e9..c79352c4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6955660bdde045d218df5c1875dcf3d1d9455b287af102c84b47278b213c77a5 -size 647309 +oid sha256:abe5b498eeef8f9d96805cd4728abe619721908df8be3282cedef79eb82b621d +size 1322197 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png index 0b8df0a3..93a5aaa9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7da9756f248c88d888bcbe57ba6f974113bab971356885519ad29e8ea65f28e5 -size 1006933 +oid sha256:3f8adc4ac9bf45d4b79e794d8cdd64f5d5549e0aca2d114fdda754a0461a2d54 +size 1291389 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png index 05535e7e..d47f764a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:188aed1fdeb08c4abfc3c366f7ac949e314425da75468f398c764f978debd1d0 -size 2692088 +oid sha256:6e106aafdd2e247304d5571f28e8267ee65f6419725ad8e6f2a075a77ee107d0 +size 6115163 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png index dee8e044..36b8457f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:158600d82f01b22c6d80c175faf5af6782621c220ac5c5c3126b95f4bbb4dede -size 683421 +oid sha256:b126a44a7b4b825c1d11eb44acf375d35f258f65dfddf035dfce1482448ec5da +size 1339837 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png index f8d0d768..52a183b9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6214be2024edb4437dc59b1aeca3adaee65b7224a5820c6f28fb87fe29d5aa7 -size 553998 +oid sha256:6bcc91163b432e072890a0e09b2a41ab700406c16b1b81ecab88b5aa38ac483b +size 1176792 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png index c10af511..3b7a6877 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4dd8fb90e736c8e6c68355315418cff393c3deba2e76112c498cd2380cbae3d -size 650980 +oid sha256:52473ec2b07d0718bce7a4f5cf9d73d275c7d637c66bd52c26c27f8ec596d0fe +size 1325350 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png index 60c02a57..2b46ab28 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f548328017d832afe70ed1a6d9bcf8a7a23aa2e83897900f431c1b57f88dbf5 -size 557873 +oid sha256:2f4ff49b0f8bd4cf54108d0284e72ffc48929900b31fc8d0540b79d2dff5c37f +size 1179730 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png index 73ef8c33..3aa5a7a1 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:98b1186bd54de38df4832b199b182b5f00369400b646bf50870d8a69b0e21e6b -size 644510 +oid sha256:70de0257fcd9ebe21ec1255820b852ca8b8a0892b862fa6f046941ee0fe7c440 +size 1319658 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png index a393b146..3faf816f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef8f25f0a7f403e1ababdda990451568449b9a7474bd303ed39bf9239f7d093c -size 550854 +oid sha256:62e55dd3be700b006a8b4e8d6188d14e9ab57a4ce72f323e61df1e4873b9ae40 +size 1174104 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png index 1655e28f..f43b7ac7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e0a89b2c88eb0d0a9686bee78a10495a87d97af84f13ac792c04d5adc5102bc -size 655379 +oid sha256:20c23c925aa1f8b38e2f2cdd99b7c751db888c156dd991dc6db305a85241eed9 +size 1323127 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png index 145a7672..9b51e23d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c33be6a0c3b0d6449733f4309b825a486f58687b3bcfa84a97ce1ea5b962ce90 -size 554945 +oid sha256:45b4b0b618dc9ed6ea135ad161edb3f3a16e7a4378e53db5cb8dde3fe34e3de5 +size 1178000 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad.png new file mode 100644 index 00000000..ee991738 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d559c85d2a4a289734e0a61effe37da91a7ed1e07a14398f9b72ac12a301c10 +size 1406460 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_accessibility.png new file mode 100644 index 00000000..34f84df8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf228910a10ecf529907b717e31df5e67274513567b282ed7fad4a9083d7df04 +size 925953 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_ax5.png new file mode 100644 index 00000000..2e41ea49 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:592410c7f92ed83d3e46ddd9575b03228faf3a2e5f16e4d7f1aae38ae9e77150 +size 4154460 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_contrast.png new file mode 100644 index 00000000..dce9be11 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89541b3e6b764bd63bb55e380928ce51387367a6e96262337831b5134727e0d5 +size 1399613 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_dark.png new file mode 100644 index 00000000..2f9cc591 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:589b322009ae58db64adfa0af2d00babbc6732c4f848c2fa3da75ff1c88a2bd9 +size 1189688 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone.png new file mode 100644 index 00000000..1d5e9d6b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddb453875207b5a5758d046a5a6a85b802b0e149800492c0d03e823f73f76449 +size 999052 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_accessibility.png new file mode 100644 index 00000000..5e6f7db4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d230ec79c3f86f9a65eafcbfe6cb6e15acd370c4b20dcb8bd79e105f47f602e6 +size 746614 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_ax5.png new file mode 100644 index 00000000..ec30f5b8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b183eddd1cc61fe439c3a77c92865cd60a46a8ebe9103eebc8cff14d7f5e6a7 +size 4589088 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_contrast.png new file mode 100644 index 00000000..9ea97765 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47786f0b9cfc98c55603494b3a3111e8a3c89d28a5c185c76ef294318ab30b67 +size 1006506 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_dark.png new file mode 100644 index 00000000..9334ccc7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DataSettingsViewSnapshotTests/data.Default_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc3fd4ddd22348e26b07599d1ae27c49fe8d9bd831483832da391409f7e1ac43 +size 844112 diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 8aef4f16..642051e9 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -6028,6 +6028,30 @@ } } }, + "settings.privacy.detail" : { + "comment" : "Detail in the privacy passport card shown at the top of Data and About settings.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your location history stays on your devices and in your private iCloud account. Where sends no data to anyone." + } + } + } + }, + "settings.privacy.title" : { + "comment" : "Headline in the privacy passport card shown at the top of Data and About settings.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Private by design" + } + } + } + }, "settings.regions.empty" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/AboutOpenSourceFooter.swift b/Where/WhereUI/Sources/Settings/AboutOpenSourceFooter.swift index 9a36359d..1b25de44 100644 --- a/Where/WhereUI/Sources/Settings/AboutOpenSourceFooter.swift +++ b/Where/WhereUI/Sources/Settings/AboutOpenSourceFooter.swift @@ -5,93 +5,18 @@ import SwiftUI struct AboutOpenSourceFooter: View { static let projectURL = URL(string: "https://github.com/kyleve/Stuff")! - @Environment(\.stylesheet) private var stylesheet - - private var style: WhereStylesheet.AboutOpenSourceStyle { - stylesheet.aboutOpenSource - } - - private var shape: RoundedRectangle { - RoundedRectangle(cornerRadius: style.cornerRadius) - } - var body: some View { Link(destination: Self.projectURL) { - HStack(spacing: style.contentSpacing) { - sourceSeal - - VStack(alignment: .leading, spacing: stylesheet.spacing.xxSmall) { - Text(String(localized: .settingsAboutSourceTitle)) - .font(style.titleFont) - .foregroundStyle(.primary) - Text(String(localized: .settingsAboutSourceAction)) - .font(style.actionFont) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 0) - - Image(systemName: "arrow.up.right") - .foregroundStyle(.tint) - .accessibilityHidden(true) - } - .padding(style.padding) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - let rosette = style.rosette - SecurityPrintRosette( - tint: .accentColor, - wobble: rosette.wobble, - lineWidth: rosette.lineWidth, - primaryRingSpacing: rosette.primaryRingSpacing, - secondaryRingSpacing: rosette.secondaryRingSpacing, - primaryOpacity: rosette.primaryOpacity, - secondaryOpacity: rosette.secondaryOpacity, - ) - } - .glassEffect( - .regular.tint(Color.accentColor.opacity(style.glassTintOpacity)) - .interactive(), - in: shape, - ) - .clipShape(shape) - .contentShape(shape) - .shadow( - color: Color.accentColor.opacity(style.accentGlow.opacity), - radius: style.accentGlow.radius, - y: style.accentGlow.offsetY, - ) - .shadow( - color: Color.black.opacity(style.liftShadow.opacity), - radius: style.liftShadow.radius, - y: style.liftShadow.offsetY, + PassportCard( + title: .settingsAboutSourceTitle, + detail: .settingsAboutSourceAction, + sealSystemImage: "chevron.left.forwardslash.chevron.right", + accessorySystemImage: "arrow.up.right", + isInteractive: true, + surface: .securityPrint, ) } .buttonStyle(.plain) - .accessibilityElement(children: .combine) - } - - private var sourceSeal: some View { - let seal = style.seal - return ZStack { - Circle() - .strokeBorder(.tint, lineWidth: seal.outerLineWidth) - Circle() - .strokeBorder( - .tint.opacity(0.65), - style: StrokeStyle( - lineWidth: seal.innerLineWidth, - dash: [seal.dashLength, seal.dashSpacing], - ), - ) - .padding(seal.innerInset) - Image(systemName: "chevron.left.forwardslash.chevron.right") - .font(seal.symbolFont) - .foregroundStyle(.tint) - } - .frame(width: seal.size, height: seal.size) - .rotationEffect(.degrees(seal.rotationDegrees)) - .accessibilityHidden(true) } } diff --git a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift index 7c87ac08..80440e31 100644 --- a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift @@ -4,9 +4,9 @@ import SnapshotKit import SwiftUI import WhereCore -/// Settings drill-in for what the app *is* rather than what it does: which build -/// is running, the third-party work it is built with, and where its bundled -/// region boundaries came from. +/// Settings drill-in for what the app *is* rather than what it does: its privacy +/// promise, which build is running, the third-party work it is built with, and +/// where its bundled region boundaries came from. /// /// Every fact here is vended by whoever owns it — `BuildInfo` and the generated /// attribution report from `WhereCore`, `RegionDataSource` from `RegionKit` — so @@ -44,6 +44,9 @@ struct AboutSettingsView: View { var body: some View { SettingsFocusScope(focus: focus) { Form { + PrivacyPassportCard() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) versionSection dependenciesSection developmentToolsSection diff --git a/Where/WhereUI/Sources/Settings/DataSettingsView.swift b/Where/WhereUI/Sources/Settings/DataSettingsView.swift index fc4f1f76..3a2dcc61 100644 --- a/Where/WhereUI/Sources/Settings/DataSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/DataSettingsView.swift @@ -1,9 +1,10 @@ import LifecycleKitUI +import SnapshotKit import SwiftUI import WhereCore -/// Settings drill-in for backup and data management: export or restore the -/// database, erase the selected year's data, or reset the entire app. +/// Settings drill-in for Where's privacy promise and data management: export or +/// restore the database, erase the selected year's data, or reset the entire app. struct DataSettingsView: View { let report: YearReportModel let backup: BackupModel @@ -22,6 +23,9 @@ struct DataSettingsView: View { var body: some View { SettingsFocusScope(focus: focus) { Form { + PrivacyPassportCard() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) BackupSettingsSection(backup: backup) dataSection resetSection @@ -128,16 +132,21 @@ extension DataSettingsView: SettingsSection { } #if DEBUG - #Preview { - NavigationStack { - DataSettingsView( - report: PreviewSupport.loadedYearReportModel(), - backup: PreviewSupport.backupModel(), - ) - .environment(PreviewSupport.loadedModel()) - .environment(PreviewSupport.loadedSession()) + extension DataSettingsView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Default", configurations: .fullContentScreenDefaults) { + DataSettingsView( + report: PreviewSupport.loadedYearReportModel(), + backup: PreviewSupport.backupModel(), + ) + .environment(PreviewSupport.loadedModel()) + .environment(PreviewSupport.loadedSession()) + } } - .whereBroadwayRoot() + } + + #Preview { + DataSettingsView.snapshotPreviews } #endif diff --git a/Where/WhereUI/Sources/Settings/PrivacyPassportCard.swift b/Where/WhereUI/Sources/Settings/PrivacyPassportCard.swift new file mode 100644 index 00000000..8af4b46d --- /dev/null +++ b/Where/WhereUI/Sources/Settings/PrivacyPassportCard.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// Reassures the reader that Where's location history stays in their Apple storage. +struct PrivacyPassportCard: View { + @State private var tilt = TiltProvider() + + var body: some View { + PassportCard( + title: .settingsPrivacyTitle, + detail: .settingsPrivacyDetail, + sealSystemImage: "lock.shield.fill", + accessorySystemImage: nil, + isInteractive: false, + surface: .reflective(tilt: tilt), + ) + .onAppear { tilt.start() } + .onDisappear { tilt.stop() } + } +} + +#if DEBUG + #Preview { + Form { + PrivacyPassportCard() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + } + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Shared/PassportCard.swift b/Where/WhereUI/Sources/Shared/PassportCard.swift new file mode 100644 index 00000000..238e02e7 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/PassportCard.swift @@ -0,0 +1,108 @@ +import SwiftUI + +/// The shared card content and chrome used for Where's passport statements. +struct PassportCard: View { + let title: LocalizedStringResource + let detail: LocalizedStringResource + let sealSystemImage: String + let accessorySystemImage: String? + let isInteractive: Bool + let surface: PassportCardSurfaceKind + + @Environment(\.stylesheet) private var stylesheet + + private var style: WhereStylesheet.PassportCardStyle { + stylesheet.passportCard + } + + private var shape: RoundedRectangle { + RoundedRectangle(cornerRadius: style.cornerRadius) + } + + private var glowColor: Color { + surface.isReflective ? style.reflectiveSurface.backgroundTop : .accentColor + } + + private var glowOpacity: Double { + surface.isReflective + ? style.reflectiveSurface.glowOpacity + : style.accentGlow.opacity + } + + var body: some View { + PassportCardSurface( + surface: surface, + isInteractive: isInteractive, + shape: shape, + ) { + HStack(spacing: style.contentSpacing) { + ZStack { + Circle() + .strokeBorder(.tint, lineWidth: style.seal.outerLineWidth) + Circle() + .strokeBorder( + .tint.opacity(0.65), + style: StrokeStyle( + lineWidth: style.seal.innerLineWidth, + dash: [style.seal.dashLength, style.seal.dashSpacing], + ), + ) + .padding(style.seal.innerInset) + Image(systemName: sealSystemImage) + .font(style.seal.symbolFont) + .foregroundStyle(.tint) + } + .frame(width: style.seal.size, height: style.seal.size) + .rotationEffect(.degrees(style.seal.rotationDegrees)) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: stylesheet.spacing.xxSmall) { + Text(title) + .font(style.titleFont) + .foregroundStyle(.primary) + Text(detail) + .font(style.detailFont) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + if let accessorySystemImage { + Image(systemName: accessorySystemImage) + .foregroundStyle(.tint) + .accessibilityHidden(true) + } + } + .padding(style.padding) + .frame(maxWidth: .infinity, alignment: .leading) + } + .clipShape(shape) + .contentShape(shape) + .shadow( + color: glowColor.opacity(glowOpacity), + radius: style.accentGlow.radius, + y: style.accentGlow.offsetY, + ) + .shadow( + color: Color.black.opacity(style.liftShadow.opacity), + radius: style.liftShadow.radius, + y: style.liftShadow.offsetY, + ) + .accessibilityElement(children: .combine) + } +} + +#if DEBUG + #Preview { + PassportCard( + title: .settingsPrivacyTitle, + detail: .settingsPrivacyDetail, + sealSystemImage: "lock.shield.fill", + accessorySystemImage: nil, + isInteractive: false, + surface: .reflective(tilt: .preview), + ) + .padding() + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Shared/PassportCardSurface.swift b/Where/WhereUI/Sources/Shared/PassportCardSurface.swift new file mode 100644 index 00000000..a5b4f872 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/PassportCardSurface.swift @@ -0,0 +1,82 @@ +import SwiftUI + +/// Applies either the security-print glass or reflective privacy surface to a +/// passport card without making the card's content observe device motion. +struct PassportCardSurface: View { + let surface: PassportCardSurfaceKind + let isInteractive: Bool + let shape: RoundedRectangle + @ViewBuilder let content: Content + + @Environment(\.stylesheet) private var stylesheet + @Environment(\.colorScheme) private var colorScheme + + private var style: WhereStylesheet.PassportCardStyle { + stylesheet.passportCard + } + + private var surfaceTint: Color { + surface.isReflective ? style.reflectiveSurface.accent : .accentColor + } + + var body: some View { + let reflection = style.reflectiveSurface + content + .background { + ZStack { + if surface.isReflective { + LinearGradient( + colors: [reflection.backgroundTop, reflection.backgroundBottom], + startPoint: .topLeading, + endPoint: .bottomTrailing, + ) + } + + SecurityPrintRosette( + tint: surfaceTint, + wobble: style.rosette.wobble, + lineWidth: style.rosette.lineWidth, + primaryRingSpacing: style.rosette.primaryRingSpacing, + secondaryRingSpacing: style.rosette.secondaryRingSpacing, + primaryOpacity: style.rosette.primaryOpacity, + secondaryOpacity: style.rosette.secondaryOpacity, + ) + } + } + .glassEffect( + .regular.tint(surfaceTint.opacity(style.glassTintOpacity)) + .interactive(isInteractive), + in: shape, + ) + .tiltSheen( + tilt: surface.tilt, + staticRoll: reflection.staticPose.roll, + staticPitch: reflection.staticPose.pitch, + in: shape, + intensity: surface.isReflective ? reflection.intensity : 0, + staticGlintIntensity: surface.isReflective + ? reflection.staticGlintIntensity + : 0, + ) + .tint(surfaceTint) + .environment(\.colorScheme, surface.isReflective ? .dark : colorScheme) + } +} + +#if DEBUG + #Preview { + PassportCardSurface( + surface: .reflective(tilt: .preview), + isInteractive: false, + shape: RoundedRectangle( + cornerRadius: WhereStylesheet.default.passportCard.cornerRadius, + ), + ) { + Text("Reflective passport surface") + .padding() + .frame(maxWidth: .infinity) + } + .padding() + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Shared/PassportCardSurfaceKind.swift b/Where/WhereUI/Sources/Shared/PassportCardSurfaceKind.swift new file mode 100644 index 00000000..f6582b7d --- /dev/null +++ b/Where/WhereUI/Sources/Shared/PassportCardSurfaceKind.swift @@ -0,0 +1,20 @@ +/// Selects a passport card's visual surface independently from whether device +/// motion has delivered a sample. +enum PassportCardSurfaceKind { + case securityPrint + case reflective(tilt: TiltProvider) + + var tilt: TiltProvider? { + switch self { + case .securityPrint: nil + case let .reflective(tilt): tilt + } + } + + var isReflective: Bool { + switch self { + case .securityPrint: false + case .reflective: true + } + } +} diff --git a/Where/WhereUI/Sources/Shared/SecurityPrintRosette.swift b/Where/WhereUI/Sources/Shared/SecurityPrintRosette.swift index 9b2ccb03..3edd6cef 100644 --- a/Where/WhereUI/Sources/Shared/SecurityPrintRosette.swift +++ b/Where/WhereUI/Sources/Shared/SecurityPrintRosette.swift @@ -64,7 +64,7 @@ struct SecurityPrintRosette: View { #if DEBUG #Preview { - let rosette = WhereStylesheet.default.aboutOpenSource.rosette + let rosette = WhereStylesheet.default.passportCard.rosette SecurityPrintRosette( tint: .accentColor, wobble: rosette.wobble, diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index bfb2f4ff..5490eeba 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -27,7 +27,7 @@ struct WhereStylesheet: BStylesheet { var launch = LaunchStyle.standard var typography = Typography.standard var settings = SettingsStyle.standard - var aboutOpenSource = AboutOpenSourceStyle.standard + var passportCard = PassportCardStyle.standard var developerOverlay = DeveloperOverlayStyle.standard init() {} @@ -1257,20 +1257,19 @@ extension WhereStylesheet { } } -// MARK: - About open source +// MARK: - Passport card extension WhereStylesheet { - /// Appearance for the compact open-source sign-off at the bottom of About. - /// It echoes the Locations cards' security print without borrowing their - /// region-specific card spec. - struct AboutOpenSourceStyle: Equatable { + /// Appearance for compact passport statements in Settings. + struct PassportCardStyle: Equatable { var cornerRadius: CGFloat var padding: CGFloat var contentSpacing: CGFloat var titleFont: Font - var actionFont: Font + var detailFont: Font var seal: Seal var rosette: Rosette + var reflectiveSurface: ReflectiveSurface var glassTintOpacity: Double var accentGlow: Shadow var liftShadow: Shadow @@ -1295,18 +1294,33 @@ extension WhereStylesheet { var secondaryOpacity: Double } + struct ReflectiveSurface: Equatable { + var backgroundTop: Color + var backgroundBottom: Color + var accent: Color + var glowOpacity: Double + var intensity: Double + var staticGlintIntensity: Double + var staticPose: Pose + + struct Pose: Equatable { + var roll: Double + var pitch: Double + } + } + struct Shadow: Equatable { var opacity: Double var radius: CGFloat var offsetY: CGFloat = 0 } - static let standard = AboutOpenSourceStyle( + static let standard = PassportCardStyle( cornerRadius: 20, padding: 16, contentSpacing: 12, titleFont: .headline, - actionFont: .subheadline, + detailFont: .subheadline, seal: Seal( size: 52, rotationDegrees: -8, @@ -1325,6 +1339,15 @@ extension WhereStylesheet { primaryOpacity: 0.1, secondaryOpacity: 0.06, ), + reflectiveSurface: ReflectiveSurface( + backgroundTop: Color(red: 0.08, green: 0.18, blue: 0.34), + backgroundBottom: Color(red: 0.02, green: 0.07, blue: 0.16), + accent: Color(red: 0.88, green: 0.72, blue: 0.32), + glowOpacity: 0.12, + intensity: 0.28, + staticGlintIntensity: 0.28, + staticPose: .init(roll: 0.3, pitch: -0.15), + ), glassTintOpacity: 0.06, accentGlow: Shadow(opacity: 0.18, radius: 7), liftShadow: Shadow(opacity: 0.08, radius: 5, offsetY: 2), diff --git a/Where/WhereUI/Tests/PassportCardSurfaceKindTests.swift b/Where/WhereUI/Tests/PassportCardSurfaceKindTests.swift new file mode 100644 index 00000000..05c856f2 --- /dev/null +++ b/Where/WhereUI/Tests/PassportCardSurfaceKindTests.swift @@ -0,0 +1,14 @@ +import Testing +@testable import WhereUI + +@MainActor +struct PassportCardSurfaceKindTests { + @Test func surfaceIdentityDoesNotDependOnMotionAvailability() { + let tilt = TiltProvider.preview + + #expect(PassportCardSurfaceKind.securityPrint.tilt == nil) + #expect(PassportCardSurfaceKind.securityPrint.isReflective == false) + #expect(PassportCardSurfaceKind.reflective(tilt: tilt).tilt === tilt) + #expect(PassportCardSurfaceKind.reflective(tilt: tilt).isReflective) + } +} diff --git a/Where/WhereUI/Tests/PassportCardSurfaceTests.swift b/Where/WhereUI/Tests/PassportCardSurfaceTests.swift new file mode 100644 index 00000000..b537bdfb --- /dev/null +++ b/Where/WhereUI/Tests/PassportCardSurfaceTests.swift @@ -0,0 +1,29 @@ +import SwiftUI +import TestHostSupport +import Testing +@testable import WhereUI + +@MainActor +struct PassportCardSurfaceTests { + @Test func hostsSecurityPrintAndReflectiveSurfaces() throws { + let shape = RoundedRectangle( + cornerRadius: WhereStylesheet.default.passportCard.cornerRadius, + ) + let rootView = VStack { + PassportCardSurface(surface: .securityPrint, isInteractive: true, shape: shape) { + Color.clear.frame(height: 80) + } + PassportCardSurface( + surface: .reflective(tilt: .preview), + isInteractive: false, + shape: shape, + ) { + Color.clear.frame(height: 80) + } + } + .whereBroadwayRoot() + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } +} diff --git a/Where/WhereUI/Tests/PassportCardTests.swift b/Where/WhereUI/Tests/PassportCardTests.swift new file mode 100644 index 00000000..b0b588a3 --- /dev/null +++ b/Where/WhereUI/Tests/PassportCardTests.swift @@ -0,0 +1,22 @@ +import SwiftUI +import TestHostSupport +import Testing +@testable import WhereUI + +@MainActor +struct PassportCardTests { + @Test func hostsInformationalContent() throws { + let rootView = PassportCard( + title: .settingsPrivacyTitle, + detail: .settingsPrivacyDetail, + sealSystemImage: "lock.shield.fill", + accessorySystemImage: nil, + isInteractive: false, + surface: .reflective(tilt: .preview), + ) + .whereBroadwayRoot() + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } +} diff --git a/Where/WhereUI/Tests/PrivacyPassportCardTests.swift b/Where/WhereUI/Tests/PrivacyPassportCardTests.swift new file mode 100644 index 00000000..8f619f2b --- /dev/null +++ b/Where/WhereUI/Tests/PrivacyPassportCardTests.swift @@ -0,0 +1,15 @@ +import SwiftUI +import TestHostSupport +import Testing +@testable import WhereUI + +@MainActor +struct PrivacyPassportCardTests { + @Test func hosts() throws { + let rootView = PrivacyPassportCard() + .whereBroadwayRoot() + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } +} diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 603e0e0e..6f99483a 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -359,13 +359,13 @@ struct WhereStylesheetTests { #expect(settings.scrollSettleDelay == .milliseconds(350)) } - @Test func aboutOpenSourceStyle() { - let source = style.aboutOpenSource + @Test func passportCardStyle() { + let source = style.passportCard #expect(source.cornerRadius == 20) #expect(source.padding == 16) #expect(source.contentSpacing == 12) #expect(source.titleFont == .headline) - #expect(source.actionFont == .subheadline) + #expect(source.detailFont == .subheadline) #expect(source.seal == .init( size: 52, rotationDegrees: -8, @@ -384,6 +384,15 @@ struct WhereStylesheetTests { primaryOpacity: 0.1, secondaryOpacity: 0.06, )) + #expect(source.reflectiveSurface == .init( + backgroundTop: Color(red: 0.08, green: 0.18, blue: 0.34), + backgroundBottom: Color(red: 0.02, green: 0.07, blue: 0.16), + accent: Color(red: 0.88, green: 0.72, blue: 0.32), + glowOpacity: 0.12, + intensity: 0.28, + staticGlintIntensity: 0.28, + staticPose: .init(roll: 0.3, pitch: -0.15), + )) #expect(source.glassTintOpacity == 0.06) #expect(source.accentGlow == .init(opacity: 0.18, radius: 7)) #expect(source.liftShadow == .init(opacity: 0.08, radius: 5, offsetY: 2))