Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .bumper/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ These use Bumper's standard `constructionOwnership` shaper. TheButtonHeist's
as the analogous lower-level ownership check and retained; the standard shaper
fully expresses Where's constructor facts.

## Protected installation context

`where.installation_context_ownership` keeps sidecar construction in
`RegularApplicationRuntime`. `where.installation_context_preparation` keeps
`prepareAfterFirstUnlock()` calls there too. The runtime injects one instance
and prepares it through the shared launch plan before onboarding or store access.

Repair a violation by using the injected context or shared launch barrier.
The rules constrain ownership, not temporal ordering. `FirstUnlockAvailabilityTests`
and the backup lifecycle model check the wait and preparation protocol.
The `.bumper/Tests` mutations reject competing construction and preparation owners.
Change these rules only when first-unlock ownership changes in `Where/Where/AGENTS.md`.

## Gregorian calendar

`where.gregorian_calendar` rejects `Calendar.current` throughout Where's
Expand Down
29 changes: 29 additions & 0 deletions .bumper/Sources/WhereProjectRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ let whereProjectRules = RuleSet {
allowed: .files(["Where/WhereUI/Sources/Launch/WhereLaunch.swift"]),
id: "where.live_location_source_ownership",
)
Rules.constructionOwnership(
"FileInstallationRecordingContextStore",
allowed: .files(["Where/Where/Sources/RegularApplicationRuntime.swift"]),
id: "where.installation_context_ownership",
)
installationContextPreparationRule
Rules.singleNominalSpelling(
suffix: "Log",
owner: whereLoggingScope,
Expand All @@ -26,6 +32,29 @@ let whereProjectRules = RuleSet {
previewCoverageRule
}

private let installationContextPreparationRule = Rules.files(
"where.installation_context_preparation",
severity: .error,
summary: "Only the regular runtime prepares the protected installation sidecar.",
) { file in
functionCalls()
.filter { match in
match.node.calledExpression.as(MemberAccessExprSyntax.self)?.declName.baseName.text
== "prepareAfterFirstUnlock"
&& file.path != "Where/Where/Sources/RegularApplicationRuntime.swift"
}
.matches(in: file)
.map { match in
match.failure(
message: "Installation context preparation is outside the app's first-unlock owner.",
evidence: ViolationEvidence(
observed: "prepareAfterFirstUnlock in \(file.path.rawValue)",
expectation: "prepare the sidecar through RegularApplicationRuntime's shared launch barrier",
),
)
}
}

private let whereServicesConstructionScope = RuleScope
.component(WhereComponent.whereCore)
.union(.files(["Where/WhereUI/Sources/Preview/PreviewSupport.swift"]))
Expand Down
36 changes: 36 additions & 0 deletions .bumper/Tests/WhereProjectRulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,42 @@ import BumperBowlingTestSupport
import Testing

struct WhereProjectRulesTests {
@Test
func `only the regular runtime constructs the installation sidecar`() throws {
let source = "let store = FileInstallationRecordingContextStore()"
let allowed = try evaluate(
path: "Where/Where/Sources/RegularApplicationRuntime.swift",
component: .app,
source: source,
)
let rejected = try evaluate(
path: "Where/WhereUI/Sources/Launch/CompetingContext.swift",
component: .whereUI,
source: source,
)
#expect(allowed.violations.isEmpty)
#expect(rejected.violations.count == 1)
#expect(rejected.violations.first?.rule.id == "where.installation_context_ownership")
}

@Test
func `sidecar preparation stays at the first unlock owner`() throws {
let source = "func prepare() throws { try store.prepareAfterFirstUnlock() }"
let allowed = try evaluate(
path: "Where/Where/Sources/RegularApplicationRuntime.swift",
component: .app,
source: source,
)
let rejected = try evaluate(
path: "Where/WhereUI/Sources/Launch/CompetingContext.swift",
component: .whereUI,
source: source,
)
#expect(allowed.violations.isEmpty)
#expect(rejected.violations.count == 1)
#expect(rejected.violations.first?.rule.id == "where.installation_context_preparation")
}

@Test
func `production store opens at process composition roots`() throws {
let allowed = try evaluate(
Expand Down
12 changes: 8 additions & 4 deletions Ledger/LedgerCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,14 @@ A missing file/key is simply "no auto-token", surfaced as `LoadError.missingCred
- `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).
- `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 thin string adapter over shared
`KeychainKit` for a pasted token's storage.
- `LedgerSettings` / `LedgerConfiguration` / `LedgerConfigStore` — the persisted
refresh interval (no secrets).
- `LoginItemController` — launch-at-login via `SMAppService`.
- **`LedgerLog`** — the Periscope logging facade: a `"Ledger"` root scope with grouping scopes (`services`, `dashboard`), emitted into `Periscope.shared`.

Expand Down
76 changes: 12 additions & 64 deletions Ledger/LedgerCore/Sources/KeychainStore.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
import Foundation
import Security
import KeychainKit

/// 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))"
}
}
public typealias KeychainError = KeychainKit.KeychainError

/// 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
Expand All @@ -35,44 +25,21 @@ public protocol KeychainStore: Sendable {
/// 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
private let backing: KeychainKit.SystemKeychainStore

/// 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,
]
backing = KeychainKit.SystemKeychainStore(
service: service,
account: account,
accessibility: .whenUnlocked,
synchronizesThroughICloud: false,
)
}

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)
}
try backing.readString()
}

public func write(_ secret: String) throws {
Expand All @@ -82,29 +49,10 @@ public struct SystemKeychainStore: KeychainStore {
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)
}
try backing.write(trimmed)
}

public func remove() throws {
let status = SecItemDelete(baseQuery as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError(status: status)
}
try backing.remove()
}
}
7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ let package = Package(
.library(name: "LifecycleKit", targets: ["LifecycleKit"]),
.library(name: "LifecycleKitUI", targets: ["LifecycleKitUI"]),
.library(name: "JournalKit", targets: ["JournalKit"]),
.library(name: "KeychainKit", targets: ["KeychainKit"]),
.library(name: "PeriscopeCore", targets: ["PeriscopeCore"]),
.library(name: "PeriscopeUI", targets: ["PeriscopeUI"]),
.library(name: "PeriscopeTools", targets: ["PeriscopeTools"]),
Expand Down Expand Up @@ -52,6 +53,7 @@ let package = Package(
.target(
name: "LedgerCore",
dependencies: [
.target(name: "KeychainKit"),
.target(name: "PeriscopeCore"),
],
path: "Ledger/LedgerCore/Sources",
Expand All @@ -75,6 +77,10 @@ let package = Package(
name: "JournalKit",
path: "Shared/JournalKit/Sources",
),
.target(
name: "KeychainKit",
path: "Shared/KeychainKit/Sources",
),
.target(
name: "PeriscopeCore",
dependencies: [
Expand Down Expand Up @@ -162,6 +168,7 @@ let package = Package(
dependencies: [
.target(name: "CreditKit"),
.target(name: "JournalKit"),
.target(name: "KeychainKit"),
.target(name: "PeriscopeCore"),
.target(name: "RegionKit"),
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
Expand Down
42 changes: 40 additions & 2 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ let whereAppEntitlements: Entitlements = .dictionary([
"com.apple.developer.icloud-container-identifiers": .array([
.string("iCloud.com.stuff.where"),
]),
"com.apple.developer.icloud-services": .array([.string("CloudKit")]),
"com.apple.developer.ubiquity-container-identifiers": .array([
.string("iCloud.com.stuff.where"),
]),
"com.apple.developer.icloud-services": .array([
.string("CloudKit"),
.string("CloudDocuments"),
]),
"com.apple.developer.ubiquity-kvstore-identifier": .string(
"$(TeamIdentifierPrefix)com.stuff.where",
),
Expand Down Expand Up @@ -193,7 +199,30 @@ let project = Project(
infoPlist: .extendingDefault(with: [
"UILaunchScreen": .dictionary([:]),
"UIApplicationSupportsIndirectInputEvents": .boolean(true),
"UIBackgroundModes": .array([.string("remote-notification")]),
"UIBackgroundModes": .array([
.string("remote-notification"),
.string("processing"),
]),
"BGTaskSchedulerPermittedIdentifiers": .array([
.string("com.stuff.where.automatic-backup"),
]),
"NSUbiquitousContainers": .dictionary([
"iCloud.com.stuff.where": .dictionary([
"NSUbiquitousContainerIsDocumentScopePublic": .boolean(true),
"NSUbiquitousContainerName": .string("Where"),
"NSUbiquitousContainerSupportedFolderLevels": .string("Any"),
]),
]),
"UTExportedTypeDeclarations": .array([
.dictionary([
"UTTypeConformsTo": .array([.string("public.zip-archive")]),
"UTTypeDescription": .string("Where Encrypted Backup"),
"UTTypeIdentifier": .string("com.stuff.where.encrypted-backup"),
"UTTypeTagSpecification": .dictionary([
"public.filename-extension": .array([.string("wherebackup")]),
]),
]),
]),
// Stated explicitly rather than left to Tuist's `1.0` / `1`
// defaults, because Settings > About shows them: the version a
// user reads off the screen should be one this manifest chose.
Expand Down Expand Up @@ -471,6 +500,12 @@ let project = Project(
productDependency: "JournalKit",
sources: ["Shared/JournalKit/Tests/**"],
),
unitTests(
name: "KeychainKitTests",
bundleIdSuffix: "keychainkit",
productDependency: "KeychainKit",
sources: ["Shared/KeychainKit/Tests/**"],
),
unitTests(
name: "PeriscopeCoreTests",
bundleIdSuffix: "periscopecore",
Expand Down Expand Up @@ -730,6 +765,7 @@ let project = Project(
"LifecycleKitTests",
"LifecycleKitUITests",
"JournalKitTests",
"KeychainKitTests",
"PeriscopeCoreTests",
"PeriscopeUITests",
"PeriscopeToolsTests",
Expand All @@ -754,6 +790,7 @@ let project = Project(
"LifecycleKitTests",
"LifecycleKitUITests",
"JournalKitTests",
"KeychainKitTests",
"PeriscopeCoreTests",
"PeriscopeUITests",
"PeriscopeToolsTests",
Expand All @@ -779,6 +816,7 @@ let project = Project(
testScheme(name: "LifecycleKitTests"),
testScheme(name: "LifecycleKitUITests"),
testScheme(name: "JournalKitTests"),
testScheme(name: "KeychainKitTests"),
testScheme(name: "PeriscopeCoreTests"),
testScheme(name: "PeriscopeUITests"),
testScheme(name: "PeriscopeToolsTests"),
Expand Down
15 changes: 15 additions & 0 deletions Shared/KeychainKit/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# KeychainKit – Module Shape

KeychainKit is the small cross-app Keychain boundary: generic-password storage
for opaque `Data`, with typed accessibility and iCloud-synchronization policy.
It depends only on Foundation and Security and never assigns product meaning to
service/account identifiers. See [`README.md`](README.md) and the root
[`AGENTS.md`](../../AGENTS.md).

Keep the protocol injectable, keep raw `OSStatus` failures observable, and do
not turn an inaccessible item into `notFound`; callers decide whether absence
permits creating a new secret. Tests use the in-memory store SPI, never a
user's Keychain. Run `./test KeychainKitTests`.

Keep collection entries create-only. Do not treat a successful local insert
as a cross-device uniqueness guarantee (`KeychainCollectionTests`).
19 changes: 19 additions & 0 deletions Shared/KeychainKit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# KeychainKit

KeychainKit provides a focused, injectable wrapper around generic-password
items in Apple Keychain Services. `SystemKeychainStore` stores opaque `Data`
under an explicit service/account pair and supports typed accessibility and
iCloud Keychain synchronization policy; `KeychainStore` lets consumers use an
in-memory implementation in tests. `create(_:)` inserts without replacing an
existing local item. It is not a distributed lock between devices.
`write(_:)` inserts or updates an item.

`KeychainCollection` and `SystemKeychainCollection` provide append-only storage
under typed `KeychainAccount` identifiers. Give independent secrets different
accounts so eventual iCloud synchronization can retain all of them. Consumers
can use `InMemoryKeychainCollection` through the testing SPI.

The module deliberately does not generate, parse, or rotate secrets. Product
modules own those rules and must distinguish `nil` (the item does not exist)
from a thrown `KeychainError`, including `errSecInteractionNotAllowed` while
protected data is unavailable.
Loading