diff --git a/.bumper/RULES.md b/.bumper/RULES.md
index d4e60c624..98c466bf2 100644
--- a/.bumper/RULES.md
+++ b/.bumper/RULES.md
@@ -9,12 +9,14 @@ tests and generated files are outside the architecture graph.
| Component | Allowed Where dependencies | Framework capabilities |
| --- | --- | --- |
| `RegionKit` | none | Foundation |
-| `WhereCore` | `RegionKit` | Foundation, persistence |
+| `WhereSurface` | none | Foundation |
+| `WhereCore` | `RegionKit`, `WhereSurface` | Foundation, persistence |
| `WhereUI` | `RegionKit`, `WhereCore` | Foundation, SwiftUI, UIKit |
| `WhereIntents` | `RegionKit`, `WhereCore`, `WhereUI` | Foundation, SwiftUI, UIKit |
| `Where` app | `RegionKit`, `WhereCore`, `WhereUI`, `WhereIntents` | Foundation, SwiftUI, UIKit |
| `WhereWidgets` | `RegionKit`, `WhereCore`, `WhereUI` | Foundation, SwiftUI, UIKit |
| `WhereShareExtension` | `WhereCore`, `WhereUI` | Foundation, SwiftUI, UIKit |
+| `WhereMenuBar` | `WhereSurface` | Foundation, SwiftUI, AppKit |
| `RegionViewer` | `RegionKit`, `WhereCore`, `WhereUI` | Foundation, SwiftUI, UIKit |
An import of a declared Where module outside these edges is a
diff --git a/.bumper/Sources/WhereArchitecture.swift b/.bumper/Sources/WhereArchitecture.swift
index cd54cc507..655d3c5bf 100644
--- a/.bumper/Sources/WhereArchitecture.swift
+++ b/.bumper/Sources/WhereArchitecture.swift
@@ -20,6 +20,13 @@ extension ComponentShape {
static let whereHostLayer = ComponentShape {
MayUse(.foundation, .swiftUI, .uiKit)
}
+
+ static let whereMacHostLayer = ComponentShape {
+ // Bumper Bowling has no AppKit capability yet; AppKit is the native
+ // host framework and the component's explicit dependency rules still
+ // forbid CoreLocation and SwiftData.
+ MayUse(.foundation, .swiftUI)
+ }
}
extension AssertionShape {
diff --git a/.bumper/Tests/WhereArchitectureTests.swift b/.bumper/Tests/WhereArchitectureTests.swift
index 783c10584..8aa980da2 100644
--- a/.bumper/Tests/WhereArchitectureTests.swift
+++ b/.bumper/Tests/WhereArchitectureTests.swift
@@ -9,21 +9,47 @@ 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\nimport WhereSurface\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 {}",
),
- ]
- )
+ SourceInput(
+ path: "Where/WhereMenuBar/Sources/MenuBar.swift",
+ component: ComponentID(WhereComponent.menuBar.rawValue),
+ source: "import SwiftUI\nimport WhereSurface\nstruct MenuBar {}",
+ ),
+ ],
+ ),
)
#expect(report.violations.isEmpty)
}
+@Test
+func `WhereSurface cannot depend upward on WhereCore`() throws {
+ let report = try bumper.evaluate(
+ RepositoryInput(
+ architecture: bumper.architecture,
+ files: [
+ SourceInput(
+ path: "Where/WhereSurface/Sources/Snapshot.swift",
+ component: ComponentID(WhereComponent.whereSurface.rawValue),
+ source: "import WhereCore\nstruct Snapshot {}",
+ ),
+ ],
+ ),
+ )
+
+ let violation = try #require(report.violations.first)
+ #expect(report.violations.count == 1)
+ #expect(violation.rule.id == .componentBoundary)
+ #expect(violation.path.rawValue == "Where/WhereSurface/Sources/Snapshot.swift")
+}
+
@Test
func `RegionKit cannot depend upward on WhereCore`() throws {
let report = try bumper.evaluate(
@@ -32,11 +58,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 +79,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 +100,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/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ea9b6fa7f..aa5f2f68e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -118,6 +118,37 @@ jobs:
if-no-files-found: warn
retention-days: 7
+ catalyst:
+ name: Build (Mac Catalyst)
+ needs: format
+ runs-on: xcode-27
+ timeout-minutes: 30
+ env:
+ CATALYST_DERIVED_DATA: ${{ github.workspace }}/catalyst-derived
+ steps:
+ - uses: actions/checkout@v4
+ - uses: jdx/mise-action@v3
+ - name: Generate project
+ run: ./ide --no-open
+ - name: Build Where for Mac Catalyst
+ run: |
+ xcodebuild build \
+ -workspace Stuff.xcworkspace \
+ -scheme Where-Catalyst \
+ -destination 'generic/platform=macOS,variant=Mac Catalyst' \
+ -derivedDataPath "$CATALYST_DERIVED_DATA" \
+ CODE_SIGNING_ALLOWED=NO
+ - name: Verify embedded Mac surfaces
+ run: |
+ APP="$CATALYST_DERIVED_DATA/Build/Products/Debug-maccatalyst/Where.app"
+ HELPER="$APP/Contents/Library/LoginItems/WhereMenuBar.app"
+ test -x "$HELPER/Contents/MacOS/WhereMenuBar"
+ test "$(plutil -extract CFBundleIdentifier raw -o - "$HELPER/Contents/Info.plist")" = "com.stuff.where.menubar"
+ test "$(plutil -extract CFBundlePackageType raw -o - "$HELPER/Contents/Info.plist")" = "APPL"
+ ! plutil -extract NSMainStoryboardFile raw -o - "$HELPER/Contents/Info.plist"
+ test -d "$APP/Contents/PlugIns/WhereWidgets.appex"
+ test -d "$APP/Contents/PlugIns/WhereShareExtension.appex"
+
snapshot:
name: Snapshot Tests (iOS)
needs: format
diff --git a/AGENTS.md b/AGENTS.md
index 1d10d1629..469a885b8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -206,6 +206,13 @@ app onto a connected iPhone without the Xcode UI, use
configured once via `./ide --team-id` (see
[`Where/AGENTS.md`](Where/AGENTS.md#installing-to-a-device)).
+Where's Mac app is the same target built for Mac Catalyst. Use the explicit
+`Where-Catalyst` scheme: it builds the native `WhereMenuBar` login item first,
+then the Catalyst app conditionally embeds it at
+`Contents/Library/LoginItems`. Keep that manual build order and copy phase
+together; Tuist rejects a direct dependency edge between the Catalyst and
+native-macOS targets.
+
## Per-module docs
Shared modules live under `Shared/`, feature modules under a top-level folder
diff --git a/BumperBowling.swift b/BumperBowling.swift
index 52e813a48..edfae2ff0 100644
--- a/BumperBowling.swift
+++ b/BumperBowling.swift
@@ -2,24 +2,28 @@ import BumperBowlingCore
enum WhereComponent: String, ComponentKey {
case regionKit
+ case whereSurface
case whereCore
case whereUI
case whereIntents
case app
case widgets
case shareExtension
+ case menuBar
case regionViewer
}
let bumper = BumperProject {
Included {
"Where/RegionKit/Sources"
+ "Where/WhereSurface/Sources"
"Where/WhereCore/Sources"
"Where/WhereUI/Sources"
"Where/WhereIntents/Sources"
"Where/Where/Sources"
"Where/WhereWidgets/Sources"
"Where/WhereShareExtension/Sources"
+ "Where/WhereMenuBar/Sources"
"Where/RegionViewer/Sources"
}
@@ -37,10 +41,16 @@ let bumper = BumperProject {
DoesNotUse("CoreLocation")
}
+ Component(.whereSurface) {
+ Owns("Where/WhereSurface/Sources")
+ Modules("WhereSurface")
+ Applies(.whereFoundationLayer)
+ }
+
Component(.whereCore) {
Owns("Where/WhereCore/Sources")
Modules("WhereCore")
- MayDependOn(.regionKit)
+ MayDependOn(.regionKit, .whereSurface)
Applies(.whereDomainLayer)
}
@@ -82,6 +92,14 @@ let bumper = BumperProject {
Applies(.whereAdapterLayer)
}
+ Component(.menuBar) {
+ Owns("Where/WhereMenuBar/Sources")
+ Modules("WhereMenuBar")
+ MayDependOn(.whereSurface)
+ Applies(.whereMacHostLayer)
+ DoesNotUse("CoreLocation", "SwiftData")
+ }
+
Component(.regionViewer) {
Owns("Where/RegionViewer/Sources")
Modules("RegionViewer")
diff --git a/Package.swift b/Package.swift
index 6a52f4599..6cf751f86 100644
--- a/Package.swift
+++ b/Package.swift
@@ -6,6 +6,7 @@ let package = Package(
defaultLocalization: "en",
platforms: [
.iOS(.v26),
+ .macOS(.v26),
],
products: [
.library(name: "StuffCore", targets: ["StuffCore"]),
@@ -22,6 +23,7 @@ let package = Package(
.library(name: "SnapshotKitTesting", targets: ["SnapshotKitTesting"]),
.library(name: "TestHostSupport", targets: ["TestHostSupport"]),
.library(name: "RegionKit", targets: ["RegionKit"]),
+ .library(name: "WhereSurface", targets: ["WhereSurface"]),
.library(name: "WhereCore", targets: ["WhereCore"]),
.library(name: "WhereUI", targets: ["WhereUI"]),
.library(name: "WhereIntents", targets: ["WhereIntents"]),
@@ -136,12 +138,17 @@ let package = Package(
.process("Resources"),
],
),
+ .target(
+ name: "WhereSurface",
+ path: "Where/WhereSurface/Sources",
+ ),
.target(
name: "WhereCore",
dependencies: [
.target(name: "CreditKit"),
.target(name: "PeriscopeCore"),
.target(name: "RegionKit"),
+ .target(name: "WhereSurface"),
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
],
path: "Where/WhereCore/Sources",
diff --git a/Project.swift b/Project.swift
index 29c947c90..0aa314f02 100644
--- a/Project.swift
+++ b/Project.swift
@@ -1,7 +1,9 @@
import ProjectDescription
let destinations: Destinations = [.iPhone, .iPad]
+let whereDestinations: Destinations = [.iPhone, .iPad, .macCatalyst]
let deployment: DeploymentTargets = .iOS("26.0")
+let macDeployment: DeploymentTargets = .macOS("26.0")
/// Local Swift package (see root `Package.swift`) for the library products
/// (StuffCore, WhereCore, WhereUI, TestHostSupport, the Broadway modules, …).
@@ -34,14 +36,37 @@ private let projectSettings: Settings = .settings(
],
)
-/// App Group shared by the Where app, its widget extension, and its share
-/// extension so every process sees the same on-disk SwiftData store (see
-/// `SwiftDataStore.appGroupIdentifier`, which must match) and the widget
-/// snapshot JSON.
+/// App Group shared by the Where app and its supporting processes. The app and
+/// share extension open the on-disk SwiftData store (see
+/// `SwiftDataStore.appGroupIdentifier`); widgets and the menu-bar helper read
+/// only the coordinated snapshot JSON.
let whereAppGroupEntitlements: Entitlements = .dictionary([
"com.apple.security.application-groups": .array([.string("group.com.stuff.where")]),
])
+/// The app additionally owns the CloudKit container that mirrors its
+/// SwiftData store. Extensions deliberately keep the App Group-only
+/// entitlement above: they write the shared local store and let the app's
+/// CloudKit-backed container publish those changes when it next opens.
+let whereAppEntitlements: Entitlements = .dictionary([
+ "aps-environment": .string("development"),
+ "com.apple.security.application-groups": .array([.string("group.com.stuff.where")]),
+ "com.apple.developer.icloud-container-identifiers": .array([
+ .string("iCloud.com.stuff.where"),
+ ]),
+ "com.apple.developer.icloud-services": .array([.string("CloudKit")]),
+ "com.apple.developer.ubiquity-kvstore-identifier": .string(
+ "$(TeamIdentifierPrefix)com.stuff.where",
+ ),
+])
+
+/// The native menu-bar login item reads only the app's published glance JSON.
+/// It has no CloudKit, network, location, or store capability of its own.
+let whereMenuBarEntitlements: Entitlements = .dictionary([
+ "com.apple.security.app-sandbox": .boolean(true),
+ "com.apple.security.application-groups": .array([.string("group.com.stuff.where")]),
+])
+
/// The environment the LFS reference images were recorded on, and the single
/// source of truth for it.
///
@@ -159,13 +184,20 @@ let project = Project(
targets: [
.target(
name: "Where",
- destinations: destinations,
+ destinations: whereDestinations,
product: .app,
bundleId: "com.stuff.where",
deploymentTargets: deployment,
infoPlist: .extendingDefault(with: [
"UILaunchScreen": .dictionary([:]),
"UIApplicationSupportsIndirectInputEvents": .boolean(true),
+ "UIBackgroundModes": .array([.string("remote-notification")]),
+ "CFBundleURLTypes": .array([
+ .dictionary([
+ "CFBundleURLName": .string("com.stuff.where"),
+ "CFBundleURLSchemes": .array([.string("where")]),
+ ]),
+ ]),
// 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.
@@ -180,7 +212,20 @@ let project = Project(
]),
sources: ["Where/Where/Sources/**"],
resources: ["Where/Where/Resources/**"],
- entitlements: whereAppGroupEntitlements,
+ copyFiles: [
+ .wrapper(
+ name: "Embed Menu Bar Login Item",
+ subpath: "Contents/Library/LoginItems",
+ files: [
+ .buildProduct(
+ name: "WhereMenuBar",
+ condition: .when([.catalyst]),
+ codeSignOnCopy: true,
+ ),
+ ],
+ ),
+ ],
+ entitlements: whereAppEntitlements,
// Writes `WhereGitSHA` / `WhereGitStatus` into the built Info.plist
// for Settings > About. A *post* script so it lands after "Process
// Info.plist" and before signing, and `basedOnDependencyAnalysis:
@@ -211,11 +256,15 @@ let project = Project(
settings: .settings(base: [
"ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS": "YES",
"ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "",
+ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]":
+ "$(SRCROOT)/Where/Where/Where-MacCatalyst.entitlements",
+ "DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER": "NO",
+ "ENABLE_HARDENED_RUNTIME[sdk=macosx*]": "YES",
]),
),
.target(
name: "WhereWidgets",
- destinations: destinations,
+ destinations: whereDestinations,
product: .appExtension,
bundleId: "com.stuff.where.widgets",
deploymentTargets: deployment,
@@ -234,10 +283,16 @@ let project = Project(
.package(product: "WhereCore"),
.package(product: "WhereUI"),
],
+ settings: .settings(base: [
+ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]":
+ "$(SRCROOT)/Where/WhereWidgets/WhereWidgets-MacCatalyst.entitlements",
+ "DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER": "NO",
+ "ENABLE_HARDENED_RUNTIME[sdk=macosx*]": "YES",
+ ]),
),
.target(
name: "WhereShareExtension",
- destinations: destinations,
+ destinations: whereDestinations,
product: .appExtension,
bundleId: "com.stuff.where.share",
deploymentTargets: deployment,
@@ -269,6 +324,47 @@ let project = Project(
.package(product: "WhereCore"),
.package(product: "WhereUI"),
],
+ settings: .settings(base: [
+ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]":
+ "$(SRCROOT)/Where/WhereShareExtension/WhereShareExtension-MacCatalyst.entitlements",
+ "DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER": "NO",
+ "ENABLE_HARDENED_RUNTIME[sdk=macosx*]": "YES",
+ ]),
+ ),
+ .target(
+ name: "WhereMenuBar",
+ destinations: [.mac],
+ product: .app,
+ bundleId: "com.stuff.where.menubar",
+ deploymentTargets: macDeployment,
+ // Use an exact plist rather than Tuist's macOS app default: that
+ // default declares `NSMainStoryboardFile = Main`, but this helper
+ // is a storyboard-free SwiftUI `@main` app.
+ infoPlist: .dictionary([
+ "CFBundleDevelopmentRegion": .string("$(DEVELOPMENT_LANGUAGE)"),
+ "CFBundleDisplayName": .string("Where"),
+ "CFBundleExecutable": .string("$(EXECUTABLE_NAME)"),
+ "CFBundleIdentifier": .string("$(PRODUCT_BUNDLE_IDENTIFIER)"),
+ "CFBundleInfoDictionaryVersion": .string("6.0"),
+ "CFBundleName": .string("$(PRODUCT_NAME)"),
+ "CFBundlePackageType": .string("$(PRODUCT_BUNDLE_PACKAGE_TYPE)"),
+ "CFBundleShortVersionString": .string("1.0"),
+ "CFBundleVersion": .string("1"),
+ // A background-only login item: the menu-bar extra is its sole UI.
+ "LSUIElement": .boolean(true),
+ "NSPrincipalClass": .string("NSApplication"),
+ ]),
+ sources: ["Where/WhereMenuBar/Sources/**"],
+ resources: ["Where/WhereMenuBar/Resources/**"],
+ entitlements: whereMenuBarEntitlements,
+ dependencies: [
+ .package(product: "WhereSurface"),
+ ],
+ settings: .settings(base: [
+ "ASSETCATALOG_COMPILER_APPICON_NAME": "",
+ "ENABLE_HARDENED_RUNTIME": "YES",
+ "REGISTER_APP_GROUPS": "YES",
+ ]),
),
.target(
name: "RegionViewer",
@@ -455,6 +551,12 @@ let project = Project(
productDependency: "RegionKit",
sources: ["Where/RegionKit/Tests/**"],
),
+ unitTests(
+ name: "WhereSurfaceTests",
+ bundleIdSuffix: "wheresurface",
+ productDependency: "WhereSurface",
+ sources: ["Where/WhereSurface/Tests/**"],
+ ),
unitTests(
name: "WhereCoreTests",
bundleIdSuffix: "wherecore",
@@ -606,9 +708,27 @@ let project = Project(
// WhereCoreTests` / `tuist test WhereTests` / `tuist test WhereUITests`
// target a single bundle without building the whole workspace.
schemes: [
- // App target schemes are normally autogenerated, but declare the
- // RegionViewer one explicitly so `tuist build RegionViewer` (and a
- // Run that launches the Catalyst app) is always available.
+ // App target schemes are normally autogenerated, but declare the two
+ // Catalyst-capable hosts explicitly so CLI builds and Runs are stable.
+ .scheme(
+ name: "Where",
+ shared: true,
+ buildAction: .buildAction(targets: ["Where"]),
+ runAction: .runAction(executable: "Where"),
+ ),
+ // Tuist rejects a target-dependency edge from a Catalyst app to a
+ // native macOS login-item app even when that edge is Catalyst-filtered.
+ // Build the helper first in a manual-order scheme; Where's conditional
+ // copy phase then embeds that product only for Catalyst.
+ .scheme(
+ name: "Where-Catalyst",
+ shared: true,
+ buildAction: .buildAction(
+ targets: ["WhereMenuBar", "Where"],
+ buildOrder: .manual,
+ ),
+ runAction: .runAction(executable: "Where"),
+ ),
.scheme(
name: "RegionViewer",
shared: true,
@@ -638,6 +758,7 @@ let project = Project(
"SnapshotKitTests",
"SnapshotKitTestingTests",
"RegionKitTests",
+ "WhereSurfaceTests",
"WhereCoreTests",
"WhereTests",
"WhereUITests",
@@ -662,6 +783,7 @@ let project = Project(
"SnapshotKitTests",
"SnapshotKitTestingTests",
"RegionKitTests",
+ "WhereSurfaceTests",
"WhereCoreTests",
"WhereTests",
"WhereUITests",
@@ -686,6 +808,7 @@ let project = Project(
testScheme(name: "SnapshotKitTests"),
testScheme(name: "SnapshotKitTestingTests"),
testScheme(name: "RegionKitTests"),
+ testScheme(name: "WhereSurfaceTests"),
testScheme(name: "WhereCoreTests"),
testScheme(name: "WhereTests"),
testScheme(name: "WhereUITests"),
diff --git a/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png b/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png
index a7b9f6e13..c7620de68 100644
--- a/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png
+++ b/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c94a58ffbf68c715e476ff057ea433419de94d9837d33062f99425d12e6c3a4a
-size 164339
+oid sha256:fc40eef4bb514f8f39a6490fd0bc3e68a728943fe7cda07ba1153c0046c321e9
+size 164512
diff --git a/Where/AGENTS.md b/Where/AGENTS.md
index 87058e828..f4ac77e71 100644
--- a/Where/AGENTS.md
+++ b/Where/AGENTS.md
@@ -1,7 +1,8 @@
# Where – Feature Shape
-Where is an iOS/iPadOS app for answering "what region was I in on which
-day?" It ingests passive GPS (Visits + significant-change), accepts
+Where is an iOS/iPadOS and Mac Catalyst app for answering "what region was I
+in on which day?" Participating iPhones and iPads ingest passive GPS (Visits +
+significant-change); every host accepts
user-asserted history (manual coordinates, whole-day overlays, evidence like
boarding passes), and rolls everything up into per-day region presence and
per-year reports. A day "counts" for a region if **any** sample in that
@@ -13,20 +14,21 @@ system, formatting, and global conventions. Read that first.
## Modules
-The layering stack, bottom-up: **RegionKit** (geometry + region lookup) →
+The layering stack, bottom-up: **WhereSurface** (presentation-ready glance
+document, Foundation only) and **RegionKit** (geometry + region lookup) →
**WhereCore** (domain; never imports SwiftUI/UIKit) → **WhereUI** (SwiftUI
views + view models) → the thin hosts (**Where** app, **WhereIntents**,
-**WhereWidgets**, **WhereShareExtension**, **RegionViewer**). Each layer
-reaches only *down*; each module's own `AGENTS.md` / `README.md` is the
-authority on what it is. Add domain behavior to WhereCore and presentation to
-WhereUI — the app target stays tiny.
+**WhereWidgets**, **WhereShareExtension**, **WhereMenuBar**, **RegionViewer**).
+Each layer reaches only *down*; each module's own `AGENTS.md` / `README.md` is
+the authority on what it is. Add domain behavior to WhereCore and presentation
+to WhereUI — the app target stays tiny.
## Layering
| Layer | Where | Owns |
|-------|-------|------|
| **Domain / services** | `WhereCore` (`WhereServices` collaborators) | Rules, detection, aggregation, persistence, side effects. Unit-test here. |
-| **View model** | `WhereUI` (`WhereModel`, the `WhereSession` coordinator, the scoped `YearReportModel` / `ResolveModel` / `BackupModel` / `RemindersSettingsModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. |
+| **View model** | `WhereUI` (`WhereModel`, the `WhereSession` coordinator, the scoped `YearReportModel` / `ResolveModel` / `BackupModel` / `RemindersSettingsModel` / `DevicesSettingsModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. |
| **Views** | `WhereUI` (`*View`) | Layout, navigation, localized copy, bindings. Never store I/O, detection, or cache/throttle policy. |
When in doubt: if the behavior would still be correct without SwiftUI, it
@@ -62,6 +64,15 @@ Rules the code enforces and agents must preserve:
`CoreLocationSource` in production, `ScriptedLocationSource` in
tests/previews. The one-shot `requestCurrentLocation()` returns `nil` rather
than throwing when no fix is available.
+- **Automatic location policy is per installation and append-only.** Stamp
+ every automatic GPS sample with its `RecordingDeviceID`; write enable/disable
+ events with an effective timestamp; route every user-facing sample read
+ through `LocationHistoryReader`. A synced cutoff hides later raw samples
+ immediately while the target device is still pending, and raw/legacy/manual
+ history is never deleted or hidden without an attributable device policy.
+ Mac Catalyst is management-only and never creates a local recording identity
+ or `CLLocationManager`; a new iPad defaults recording off while an upgraded
+ iPad without a stored preference preserves the historical enabled behavior.
- **Manual entries carry a `ManualEntryAudit`**; `DayJournal`'s write methods
take an explicit `audit:` (no default). An additive backfill can't downgrade
an authoritative row's regions, but the newer audit always wins.
@@ -169,15 +180,18 @@ slow.
## Navigation
-The logged-in shell is `MainTabs` — **three fixed tabs**: Locations, Your
-Year, Settings; everything else hangs off one of them. A new screen is a
+The logged-in shell is `MainTabs`, with three fixed sections: Locations, Your
+Year, Settings. `PhoneMainTabs` presents them as tabs on iPhone;
+`MainSplitView` presents the same sections in a stable two-column
+`NavigationSplitView` on iPad and Mac Catalyst, letting the system collapse
+columns rather than swapping roots at a width threshold. A new screen is a
pushed destination, a sheet, or a Settings row inside that shape — a fourth
-tab is a product decision to raise before building. `MainTabs` passes the
+section is a product decision to raise before building. `MainTabs` passes the
scene-scoped `YearReportModel` by explicit init injection; the always-on
`WhereSession` coordinator travels in the environment. Settings is a
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.
+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 About screen renders three live sources — the generated attribution
report (`WhereCore.AppAttribution`), `RegionDataSource`, and `BuildInfo` —
@@ -185,6 +199,17 @@ 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.
+## Glance surfaces
+
+`WhereCore.WidgetSnapshotPublisher` is the only writer of the version-tolerant
+App Group JSON used by widgets and the native `WhereMenuBar` login item. The
+document carries both the widget's domain snapshot and a presentation-ready
+`WhereSurfaceSnapshot`; lightweight hosts import only `WhereSurface`, retain
+the last good document when a read fails, and never open SwiftData, CloudKit,
+or CoreLocation. Every read and atomic publish uses `NSFileCoordinator`; a
+successful publish posts the advisory Darwin notification before WidgetKit
+reloads so the helper can refresh without polling.
+
## Localization
All user-facing copy resolves through each module's `Localizable.xcstrings`
diff --git a/Where/TODOs.md b/Where/TODOs.md
index 423a2e8bb..33b1657bf 100644
--- a/Where/TODOs.md
+++ b/Where/TODOs.md
@@ -33,9 +33,6 @@ The item format and the placement rule live in the root
- fix(WhereUI) [quick-win]: `PresenceTimelineList` returns `[]` whenever `report.report` is nil (`:12`), so the Timeline segment of Your Year renders the "no stays" empty state while the year is still loading (and during a year switch) — unlike the Calendar segment beside it, which gates on `loadState`. (audit 2026-07-26)
- refactor(WhereUI) [needs-design]: Extract a shared `ReportLoadGate`. The same `YearReportModel.loadState` gate is copy-pasted across `LocationsView.swift:60`, `ElsewhereView.swift:50`, `ResolutionView.swift:58`, and `CalendarContentView.swift:60`, and `PresenceTimelineList` skipped it entirely (above). One gate view would cover all five. (audit 2026-07-26)
- fix(WhereUI) [quick-win]: The Elsewhere entry card renders raw inflection markup instead of an agreed region count — it shows literally `^[3 region](inflect: true)`. `locations.elsewhere.subtitle` is authored for automatic grammar agreement (`^[%lld region](inflect: true)`), but the string-catalog compiler passes that markup through **verbatim** into the compiled `Localizable.strings` (unlike a real plural such as `primary.elsewhereOnly.description`, which compiles to an `NSStringLocalizedFormatKey` dict), and flattening the resource to a `String` never runs the inflection engine. Pre-existing — the catalog entry is byte-identical on `main` and predates the String Catalog symbol migration. Fix by either rendering the resource directly so SwiftUI applies inflection (`Text(.locationsElsewhereSubtitle(regionCount))` in `ElsewhereSummaryCard`, dropping the `WhereFormat` hop) or replacing the markup with an explicit plural variation. `WhereFormatTests.elsewhereCardSubtitleInflectsTheRegionCount` pins the expected output behind `withKnownIssue`, so it trips as soon as this is fixed. The bug is also baked into the `locations.Loaded_iPad.png` reference (ledgered in the broken-snapshots cluster below) — re-record that image when this lands. (agent)
-- fix(WhereUI) [needs-design]: Serialize `WhereSession.trackingEnabled` mutations. The setter spawns an unserialized `Task` per assignment (`WhereSession.swift:441`), so rapid on/off leaves start/stop racing: `startTracking()` sets `wantsTracking = true` on entry and never re-reads intent before `reconcileTracking()`, so a `stopTracking()` that runs mid-flight gets undone. Coalesce behind one in-flight task (or a generation token) and re-check intent before reconciling. (audit 2026-07-26)
- - fix(WhereUI) [needs-design]: Split the toggle binding — `wantsTracking` for user intent vs `isTracking` for effective GPS state. `wantsTracking` already exists internally and is persisted, but the public `trackingEnabled` binds effective state for both read *and* write, so the switch animates back on its own while a start is in flight. (audit 2026-07-26)
- - test(WhereUI) [quick-win]: Add an adversarial test for toggle ordering (stop while a slow scripted `startTracking()` is in flight); `WhereSessionTrackingTests` covers only the launch/foreground paths today. (audit 2026-07-26)
- refactor(WhereUI) [needs-design]: Split `WhereSession` into an always-on coordinator + a presentation view-model whose lifetime scopes its subscriptions. **Partial progress (July 2026):** `YearReportModel` is now scene-scoped in `MainTabs` — `activate()` / `deactivate()` on `scenePhase` drive `observeDataChanges()` and refresh, closing the headless-relaunch rescan leak that previously wired the subscription through launch `syncAuth`. `ResolveModel`, `BackupModel`, and `RemindersSettingsModel` are already view-scoped. Remaining: the coordinator is still ~460 lines mixing tracking intent, authorization, reset, and region-style mirrors; finish extracting presentation collaborators and drive any leftover reactive work from scene lifetime. (agent)
- test(WhereUI) [quick-win]: `ManualDayView`'s range mode has no test coverage — including its capture-only code. The deleted `manualDayViewHostsAddModes` hosted a *range-prefilled* add (two `DatePicker`s), but the `addPrefill` snapshot case in `ManualDayView.swift` is a single day (`start == end` → `dateSpan = .singleDay`), so no test ever renders the `.range` branch — live or stand-in. The range stand-in code has never executed, and the From/Through picker row rendering is unpinned. Fix: add an `AddRange` snapshot case with a multi-day `MissingDayRange` prefill (the Resolve backfill flow the deleted test existed for). (From the July 2026 snapshot-testing PR review.)
- test(WhereUI) [needs-design]: `RegionMapView`'s live `Map` branch is no longer constructed by any test. The deleted `regionMapViewHosts` mounted the real MapKit `Map` (polygon building via `clLocationCoordinates`, `mapStyle`); under capture the view always takes the `SnapshotMapStandIn` branch, so a crash or regression in the production map path — which every real user sees — would ship untested. The stand-in substitution is what the framework carve-out sanctions; the gap is purely coverage. Fix: keep one lightweight hosting test for the live branch in `WhereUITests` (this specific surface is the exception the "no hosting smoke tests" rule shouldn't swallow) — until then, this entry records the accepted gap. (From the July 2026 snapshot-testing PR review.)
@@ -103,6 +100,7 @@ re-recording:
# Completed issues
+- fix(WhereUI): Serialize automatic-recording changes and separate desired from effective state. (Resolved 2026-07-30: the fire-and-forget `trackingEnabled` binding was replaced by awaited `DevicesSettingsModel` intents over the reentrancy-safe `DeviceRecordingController`; policy is per-device and append-only, current-device acknowledgement mirrors physical GPS state, and same-clock rapid changes are ordered by a focused adversarial test. The Devices UI now renders intent, pending acknowledgement, permission state, and rollback independently.)
- 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/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb
index 4b2ed47e0..e44e15560 100755
--- a/Where/Tools/upgrade-backup.rb
+++ b/Where/Tools/upgrade-backup.rb
@@ -23,10 +23,12 @@
# old joined key and recovering any legacy epoch value to a calendar day.
# - Top level: ensures `dismissedIssues` / `trackedRegions` exist, synthesizes
# `primaryRegions` from the tracked ids (null appearance, listed order) when
-# absent, and sets `formatVersion` to 2 (the current version).
+# absent, adds empty device/policy tables, stamps legacy samples with null
+# device provenance, normalizes ISO-8601 dates to lossless Unix epoch
+# seconds, and sets `formatVersion` to 3 (the current version).
#
-# Idempotent: re-running on an already-upgraded archive is a no-op (it only
-# touches legacy `date` / `key` fields and unmapped region ids).
+# Idempotent: re-running on an already-upgraded archive produces the same
+# manifest, including the numeric date representation.
#
# Usage (from the repo root):
# ruby Where/Tools/upgrade-backup.rb INPUT.zip [OUTPUT.zip]
@@ -40,7 +42,7 @@
require "set"
MANIFEST_NAME = "manifest.json"
-CURRENT_FORMAT_VERSION = 2
+CURRENT_FORMAT_VERSION = 3
# Former enum-case region ids -> current catalog ids. `canada` / `other` are
# unchanged but listed so an already-current id passes through untouched.
@@ -60,6 +62,20 @@
"abruptChange" => %w[earlier later],
}.freeze
+# Every `Date` property reachable from `BackupArchive`. The v3 wire format uses
+# Unix epoch seconds so subsecond policy/sample ordering survives a round trip.
+DATE_FIELDS = Set.new(%w[
+ exportedAt
+ timestamp
+ capturedAt
+ recordedAt
+ dismissedAt
+ registeredAt
+ lastSeenAt
+ archivedAt
+ effectiveAt
+]).freeze
+
def die(message)
warn "error: #{message}"
exit 1
@@ -162,6 +178,30 @@ def upgrade_dismissals!(manifest)
end
end
+def date_to_epoch_seconds(value)
+ return value if value.is_a?(Numeric)
+
+ Time.iso8601(value).to_f
+rescue ArgumentError, TypeError
+ die "could not parse date value: #{value.inspect}"
+end
+
+def normalize_dates!(value)
+ case value
+ when Hash
+ value.each do |key, child|
+ value[key] = if DATE_FIELDS.include?(key) && !child.nil?
+ date_to_epoch_seconds(child)
+ else
+ normalize_dates!(child)
+ end
+ end
+ when Array
+ value.each { |element| normalize_dates!(element) }
+ end
+ value
+end
+
def upgrade_manifest(manifest)
warnings = []
upgrade_evidence!(manifest, warnings)
@@ -178,6 +218,12 @@ def upgrade_manifest(manifest)
manifest["primaryRegions"] ||= manifest["trackedRegions"].each_with_index.map do |id, index|
{ "region" => id, "appearance" => nil, "order" => index }
end
+ Array(manifest["samples"]).each do |sample|
+ sample["recordingDeviceID"] = nil unless sample.key?("recordingDeviceID")
+ end
+ manifest["recordingDevices"] ||= []
+ manifest["recordingPolicyChanges"] ||= []
+ normalize_dates!(manifest)
manifest["formatVersion"] = CURRENT_FORMAT_VERSION
warnings.uniq.each { |message| warn "warning: #{message}" }
manifest
diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md
index 6dd96b39b..3f43c5f89 100644
--- a/Where/Where/AGENTS.md
+++ b/Where/Where/AGENTS.md
@@ -1,8 +1,9 @@
# Where (app target) – Module Shape
-The **Where** iOS app target: the process's composition root and nothing else.
-Three files — `WhereApp` (`@main`), `AppDelegate` (the wiring), and
-`WhereShortcuts` (the App Shortcuts phrases). See [`README.md`](README.md).
+The **Where** iOS, iPadOS, and Mac Catalyst app target: the process's
+composition root and nothing else. Three files — `WhereApp` (`@main`),
+`AppDelegate` (the wiring), and `WhereShortcuts` (the App Shortcuts phrases).
+See [`README.md`](README.md).
This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature
[`Where/AGENTS.md`](../AGENTS.md) — read those first; they own build/format,
@@ -36,10 +37,12 @@ layering, and the domain rules this target merely starts up.
- **Launch is wired in `didFinishLaunching`, not a SwiftUI `.task`.** When
CoreLocation relaunches the app after termination there is no UI, so a view's
- `.task` is not a reliable hook; `didFinishLaunching` always runs. It builds
- the `LifecycleRunner` (whose synchronous `initializePrerequisites` installs
- the `CLLocationManager` in time to receive the queued event) and hands it to
- `RootView` through `WhereApp`. Don't move this wiring into a view.
+ `.task` is not a reliable hook; `didFinishLaunching` always runs. On an
+ iPhone or iPad it builds the `LifecycleRunner` whose synchronous
+ `initializePrerequisites` installs the `CLLocationManager` in time to receive
+ the queued event; Mac Catalyst deliberately skips that prerequisite and
+ launches management-only. It hands the runner to `RootView` through
+ `WhereApp`. Don't move this wiring into a view.
- **This target owns exactly one of each shared thing** — one `WhereModel`, one
`IntentServices`, one launcher — created here and injected down, per
[Composition](../../AGENTS.md#composition-create-once-inject-down). The
@@ -47,6 +50,15 @@ layering, and the domain rules this target merely starts up.
*behind* the onboarding gate, so this target opens nothing at startup; the
intents stack derives from whatever scope the launch resolves, in the
`onServicesReady` hook.
+- **Only the app owns the CloudKit capability.** Keep its App Group, CloudKit
+ container (`iCloud.com.stuff.where`), platform APNs entitlement, and
+ remote-notification background mode together in `Project.swift`; widgets and
+ the share extension stay App Group-only and never open a CloudKit container.
+- **Catalyst embeds, but never launches, the native `WhereMenuBar` helper.**
+ `Project.swift` builds it first in the `Where-Catalyst` scheme and conditionally
+ copies it to `Contents/Library/LoginItems`; Settings registers or unregisters
+ that exact bundle with `SMAppService`. The helper opens this app through the
+ `where://open` URL and must not gain a direct target dependency back to it.
- **Nothing here may assume the user has a store.** `didFinishLaunching` starts
the ambient log sources and drives the launch; anything wanting the user's
data waits for `.ready` and checks what it got — the Spotlight indexing after
diff --git a/Where/Where/README.md b/Where/Where/README.md
index dafc29dd7..e231f2380 100644
--- a/Where/Where/README.md
+++ b/Where/Where/README.md
@@ -1,14 +1,17 @@
# Where (app target)
-The iOS/iPadOS app bundle for **Where**. It is deliberately a shell: it starts
-the process, builds the objects everything else shares, and shows `WhereUI`'s
-`RootView`. All the behavior lives in the modules below it —
+The iOS/iPadOS and Mac Catalyst app bundle for **Where**. It is deliberately a
+shell: it starts the process, builds the objects everything else shares, and
+shows `WhereUI`'s `RootView`. All the behavior lives in the modules below it —
[`WhereCore`](../WhereCore) (domain, persistence, GPS),
[`WhereUI`](../WhereUI) (screens and view models),
[`WhereIntents`](../WhereIntents) (Siri/Shortcuts), and
[`RegionKit`](../RegionKit) (geometry) — plus the
[`WhereWidgets`](../WhereWidgets) and
-[`WhereShareExtension`](../WhereShareExtension) extensions it embeds.
+[`WhereShareExtension`](../WhereShareExtension) extensions it embeds. The
+Catalyst bundle also embeds the native
+[`WhereMenuBar`](../WhereMenuBar) login item; the user enables it from
+Settings → Devices.
For what the app *does*, start at the feature overview in
[`Where/AGENTS.md`](../AGENTS.md). For the rules that apply when editing this
@@ -27,17 +30,48 @@ target, see [`AGENTS.md`](AGENTS.md).
## Launch, briefly
`didFinishLaunching` does the wiring — not a SwiftUI `.task` — because
-CoreLocation can relaunch the app with no UI at all, and only the delegate
-callback is guaranteed to run. It registers the App Intents dependency, starts
-logging, and builds a [`LifecycleKit`](../../Shared/LifecycleKit) runner with
-the reason `.undetermined`, since the UIScene lifecycle can't yet distinguish a
-user tap from a headless wake. The runner drives the background-safe launch
-steps immediately and builds no view tree; when a scene actually activates,
-`RootView` promotes the launch to `.userForeground` and the remaining steps run.
+CoreLocation can relaunch the iPhone/iPad app with no UI at all, and only the
+delegate callback is guaranteed to run. It registers the App Intents
+dependency, starts logging, and builds a
+[`LifecycleKit`](../../Shared/LifecycleKit) runner with the reason
+`.undetermined`, since the UIScene lifecycle can't yet distinguish a user tap
+from a headless wake. The runner drives the background-safe launch steps
+immediately and builds no view tree; when a scene actually activates,
+`RootView` promotes the launch to `.userForeground` and the remaining steps
+run. Mac Catalyst uses the same lifecycle without constructing CoreLocation
+and manages the recording policies of synced iPhones and iPads.
## Build & run
-The target is declared in [`Project.swift`](../../Project.swift). Generate and
-open the workspace with `./ide`, or install to a connected iPhone from the
-command line with [`./Where/install`](../install) (macOS only, needs a signing
-team — see [`Where/AGENTS.md`](../AGENTS.md#installing-to-a-device)).
+The target is declared in [`Project.swift`](../../Project.swift). Generate the
+workspace with `./ide --no-open`. Build Mac Catalyst with the shared
+`Where-Catalyst` scheme; install to a connected iPhone from the command line
+with [`./Where/install`](../install) (macOS only, needs a signing team — see
+[`Where/AGENTS.md`](../AGENTS.md#installing-to-a-device)).
+
+## CloudKit rollout and device validation
+
+The app target owns `iCloud.com.stuff.where` plus the platform APNs entitlement
+and remote-notification background mode. Widgets and the share extension
+intentionally have only the App Group entitlement: they write/read local shared
+artifacts, while the app's single SwiftData container owns CloudKit mirroring.
+Debug uses `.localOnly`; exercise sync with a Release-signed build.
+
+Before shipping a schema change:
+
+1. Install a Release build against the Development CloudKit environment and
+ open the store so SwiftData initializes the additive schema.
+2. Inspect the new fields/record types in CloudKit Console, then deploy that
+ schema to Production before distributing the build.
+3. On two devices signed into the same iCloud account, open Settings → Devices
+ and verify both generic hardware profiles arrive; rename one and verify the
+ nickname syncs.
+4. From the carried device, turn automatic recording off for the left-behind
+ device. Verify its row says it is waiting, and that locations at/after the
+ cutoff disappear from reports as soon as the policy syncs.
+5. Open the left-behind device. Verify it stops monitoring, acknowledges Off,
+ and the waiting state clears on the carried device. Re-enable it and verify
+ new locations appear again.
+6. Archive the non-current device and verify it is hidden without losing older
+ report history. Export and replace-import a backup and verify device names,
+ raw samples, policy history, and archived state round-trip.
diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift
index 454d13edb..b140018d4 100644
--- a/Where/Where/Sources/AppDelegate.swift
+++ b/Where/Where/Sources/AppDelegate.swift
@@ -10,13 +10,14 @@ import WhereUI
/// launch, wiring both up at process launch rather than from a SwiftUI view's
/// `.task`.
///
-/// This matters for background relaunch: when CoreLocation relaunches the app
-/// after termination (a significant location change or visit), there's no UI,
-/// so a view's `.task` is not a reliable hook. `didFinishLaunching` always
-/// runs, so building the runner here (whose synchronous
-/// `initializePrerequisites` installs the `CLLocationManager`) lets CoreLocation
-/// deliver the pending event, while the async launch steps continue background
-/// tracking off the main thread.
+/// This matters for background relaunch on a participating iPhone or iPad:
+/// when CoreLocation relaunches the app after termination (a significant
+/// location change or visit), there's no UI, so a view's `.task` is not a
+/// reliable hook. `didFinishLaunching` always runs, so building the runner here
+/// (whose synchronous `initializePrerequisites` installs the
+/// `CLLocationManager` on those hosts) lets CoreLocation deliver the pending
+/// event, while the async launch steps continue background tracking off the
+/// main thread. Mac Catalyst skips the location prerequisite entirely.
@MainActor
final class AppDelegate: NSObject, UIApplicationDelegate {
/// The app's model, logging into the process-wide Periscope system. This is
@@ -67,18 +68,20 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
// possible location wake we can't yet rule out) and builds no view tree;
// `RootView`'s `enterForeground()` promotes it to `.userForeground` once
// a scene genuinely activates. A genuine headless wake simply stays
- // `.undetermined` — the queued location event is delivered through the
- // `CLLocationManager` installed below, so no launch-state guess is
- // needed to service it.
+ // `.undetermined` — on a participating iPhone or iPad the queued
+ // location event is delivered through the `CLLocationManager` installed
+ // below, so no launch-state guess is needed to service it. Catalyst has
+ // no local recorder to service.
//
// Start the process-wide ambient log sources. The durable sink belongs
// to whichever scope the user ends up in (`WhereScope` opens it), and
// no scope exists this early, so these — and everything else logged
// before the launch resolves one — reach OSLog only.
WhereLaunch.startAmbientLogging(on: .shared)
- // `initializePrerequisites` installs the CLLocationManager synchronously
- // (so a queued location event isn't lost) and registers the
- // foreground-notification presenter; the rest (store open, etc.) runs as
+ // `initializePrerequisites` installs the CLLocationManager
+ // synchronously on participating iPhone/iPad hosts (so a queued location
+ // event isn't lost) and registers the foreground-notification presenter;
+ // Catalyst skips the location half. The rest (store open, etc.) runs as
// async steps off this synchronous launch path.
// `onServicesReady` fires from the `start-session` step on every session
// (re)start: derive the App Intents stack from the launch's services —
diff --git a/Where/Where/Where-MacCatalyst.entitlements b/Where/Where/Where-MacCatalyst.entitlements
new file mode 100644
index 000000000..9aaeabd53
--- /dev/null
+++ b/Where/Where/Where-MacCatalyst.entitlements
@@ -0,0 +1,30 @@
+
+
+
+
+ aps-environment
+ development
+ com.apple.developer.aps-environment
+ development
+ com.apple.developer.icloud-container-identifiers
+
+ iCloud.com.stuff.where
+
+ com.apple.developer.icloud-services
+
+ CloudKit
+
+ com.apple.developer.ubiquity-kvstore-identifier
+ $(TeamIdentifierPrefix)com.stuff.where
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ group.com.stuff.where
+
+ com.apple.security.files.user-selected.read-write
+
+ com.apple.security.network.client
+
+
+
diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md
index 135ac5582..12f9405b6 100644
--- a/Where/WhereCore/AGENTS.md
+++ b/Where/WhereCore/AGENTS.md
@@ -4,8 +4,9 @@ WhereCore is the domain layer of the Where feature: the persistence boundary,
GPS ingestion, per-day / per-year aggregation, data-quality detection, and
the side effects that hang off a committed write. It is assembled behind one
`Sendable` value — `WhereServices` — that the UI and the App Intents stack
-talk to (widgets never do; they read the published `WidgetSnapshot` from the
-App Group). See [`README.md`](README.md) for the public API and collaborators.
+talk to (widgets and the native helper never do; they read the published App
+Group artifact). See [`README.md`](README.md) for the public API and
+collaborators.
The domain/presentation split and the rules WhereCore must uphold live in the
feature [`Where/AGENTS.md`](../AGENTS.md#layering) — read that and the root
@@ -72,6 +73,17 @@ internal shape.
- **Writes await their side effects.** `DayJournal` commits, then awaits the
reminder reconcile + widget publish in sequence, so a reader on the next
`changes()` ping never observes a half-applied write.
+- **A failed glance write is not fresh.** `WidgetSnapshotPublisher` coalesces
+ concurrent requests behind one final rebuild and updates its freshness cache
+ only after the throwing publisher sink succeeds.
+- **External writes get one glance observer.** `WhereStore.remoteChanges()`
+ carries only CloudKit/share-extension imports; `WhereServices.make` connects
+ it to the base `WidgetSnapshotPublisher` after reconciling live region
+ attribution, while local writes use their journal/ingestor paths and
+ `forIntents(sharingStoreOf:)` shares that publisher rather than starting
+ another observer or cache.
+ Treat `.NSPersistentStoreRemoteChange` as a raw write notification: stamp
+ local contexts and filter SwiftData history by author before emitting it.
- **Post-write reconciliation is defined once.** Every write and import
routes through `DayJournal.reconcileAfterDayChange()` (or its widget-less
subset `reconcileIssueState()`) — never copy the fan-out into a new write
@@ -85,6 +97,15 @@ internal shape.
`ScriptedLocationSource` in tests/previews; `requestCurrentLocation()`
returns `nil`, never throws, and backs
`LocationIngestor.captureTodayIfNeeded(now:)`.
+- **`DeviceRecordingController` owns automatic-recording policy and physical
+ GPS state.** Keep policy events append-only, serialize mutations across
+ awaits, transform profile fields from the transaction's latest stored value,
+ stamp every ingested GPS sample with the current installation id, and apply
+ `LocationHistoryReader` to every user-facing projection. Backups alone read
+ the lossless raw samples and full policy/device tables.
+- **Management-only participation has no local recording identity.** Keep its
+ ingestor inert and never register a current-device row; synced remote-device
+ policy and profile edits remain available.
- **Tracked regions live in the store, not preferences** — one
`SDTrackedRegion` row per region so cross-device edits merge; read as a
`Set` defaulting to the four. `RegionAttribution` derives the attributor
diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md
index 693234325..74084c2dd 100644
--- a/Where/WhereCore/README.md
+++ b/Where/WhereCore/README.md
@@ -24,9 +24,13 @@ one it belongs to rather than to a god-object:
- **`WhereStore`** — the value-type persistence boundary (a protocol; nothing
crossing it is a SwiftData record). Mutations run inside `perform { … }` (one
atomic transaction) and `changes()` emits once per commit and on a CloudKit
- remote import. `SwiftDataStore.make()` is the production, CloudKit-backed
- implementation; `SwiftDataStore.inMemory()` backs tests and previews. Each
- process opens its on-disk store **once** and injects it where it's needed —
+ remote import. Device-profile field edits transform the latest stored value
+ inside that transaction, so a CloudKit update cannot be overwritten by a
+ stale whole-profile read. `SwiftDataStore.make()` is the production,
+ CloudKit-backed implementation; `SwiftDataStore.inMemory()` backs tests and
+ previews. An on-disk store stamps writer contexts and filters SwiftData
+ history so `remoteChanges()` never echoes local GPS commits. Each process
+ opens its on-disk store **once** and injects it where it's needed —
in the app, the launch's `resolve-scope` step opens it and the App Intents
stack shares it via `WhereServices.forIntents(sharingStoreOf:)` — so two
subsystems never race to create/open the same store file. It also
@@ -35,7 +39,9 @@ one it belongs to rather than to a god-object:
which surface and persist each region's picked `RegionAppearance` — color
token, emoji, SF Symbol — and pick order alongside the synced rows) — one row
per region, defaulting to the four until the user chooses in the onboarding /
- Settings region picker.
+ Settings region picker. It also stores one `RecordingDevice` profile per
+ installation plus the append-only `RecordingPolicyChange` timeline used to
+ control automatic recording across devices.
- **`RegionAttribution`** — a live `RegionAttributing` built from the tracked
regions that rebuilds on `changes()` (a local edit or a remote import), so the
app + App Intents process attribute against the same synced set. Assemble
@@ -83,7 +89,20 @@ one it belongs to rather than to a god-object:
(returns `nil`, never throws, when no fix is available).
- **`LocationIngestor`** — monitoring, the persist-with-retry queue, and
authorization; after each committed sample it reconciles the badge/reminders
- and republishes the widget snapshot.
+ and republishes the widget snapshot. Every automatic sample is stamped with
+ the current installation's `RecordingDeviceID`.
+- **`DeviceRecordingController`** — serializes per-device enable/disable
+ policy with the current installation's physical `LocationIngestor`. A remote
+ disable is effective at its timestamp as soon as it syncs; the target device
+ later acknowledges that event after it has stopped. `RecordingParticipation`
+ makes local recording explicit: iPhone starts new installations on, iPad
+ starts them opt-in while retaining a migrated preference, and a
+ management-only process has no local identity or GPS lifecycle but can still
+ edit synced remote devices.
+- **`LocationHistoryReader`** — the shared policy-aware read boundary used by
+ reports, widgets, recent activity, and foreground capture checks. It filters
+ GPS samples during disabled intervals while keeping raw storage, backups,
+ legacy samples without provenance, and user-asserted samples lossless.
### Detection, notifications & the rest
@@ -100,8 +119,13 @@ one it belongs to rather than to a god-object:
- **Reconcilers** — `ReminderReconciler` (daily logging reminder + app-icon
badge), `DailySummaryReconciler` (year-to-date recap),
`DataIssueAlertReconciler` ("issues to resolve").
-- **`WidgetSnapshotPublisher`** — republishes the App Group snapshot the widgets
- read, with a freshness policy.
+- **`WidgetSnapshotPublisher`** — republishes the App Group artifact read by
+ widgets and the native menu bar helper. It carries the widget's domain data
+ plus a presentation-ready `WhereSurfaceSnapshot`, coalesces concurrent
+ requests into one final rebuild, republishes immediately for CloudKit/share
+ extension imports, and only caches a successful coordinated atomic write as
+ fresh. The base app and its derived App Intents stack share this publisher so
+ their local write paths cannot leave competing hot-path caches.
- **`BackupCoordinator`** — whole-database export / import (a ZIP archive, via
`ZIPFoundation`).
- **`RecentActivitySummarizer`** — an on-device Foundation Models narrative over
@@ -110,7 +134,9 @@ one it belongs to rather than to a god-object:
reminder / summary schedules) 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.
+ defaults by saying nothing. Its recording-intent resolver distinguishes a
+ new installation's platform default from the historical on-by-default value
+ retained by an already-onboarded installation.
- **`BuildInfo`** + **`AppAttribution`** — what Settings > About says about the
bundle it is running in. `BuildInfo.current(bundle:)` reads the marketing
version, build number, the commit the app was built from, and how the Swift
@@ -188,6 +214,10 @@ store so the retry queue can't repopulate it mid-erase.
the live `ModelContainer` is surfaced only for the read-only debug inspector.
- **Always-location.** Background day tracking needs Always; `requestPermission()`
throws `LocationPermissionDeniedError` on denial / restriction.
+- **Strong remote cutoff.** Turning a device off does not depend on that device
+ being online before reports become correct: once the policy event syncs,
+ samples at or after its effective timestamp are excluded. Its row remains
+ "waiting" until the target installation physically stops and acknowledges it.
- **Failures surface.** Store methods are `async throws`; errors are logged via
`WhereLog` and left observable — never swallowed into an empty default.
- **Foundation Models may be unavailable.** `RecentActivitySummarizer` reports a
diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift
index db37184ef..1b724096d 100644
--- a/Where/WhereCore/Sources/Backup/BackupArchive.swift
+++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift
@@ -8,20 +8,21 @@ import RegionKit
///
/// The arrays mirror the SwiftData tables exactly (`SDLocationSample` /
/// `SDEvidence` / `SDManualDay` / `SDDismissedIssue` / `SDTrackedRegion`) via
-/// their value-type representations, so an export captures everything and an
-/// import can upsert it back row-for-row.
+/// their value-type representations, plus `SDRecordingDevice` /
+/// `SDRecordingPolicyChange`, so an export captures everything and an import
+/// can upsert it back row-for-row.
public struct BackupArchive: Codable, Sendable, Hashable {
/// Bumped whenever the archive's on-disk shape changes in a way older
/// readers can't understand, so an importer can refuse a file it doesn't
/// know how to read instead of silently dropping data (see
/// `BackupService.readArchive`, which rejects any other version).
///
- /// v2 adds `primaryRegions` (each tracked region's picked appearance + pick
- /// order). There's no in-app decode fallback for a pre-v2 archive — it's
- /// reshaped out of band by `Tools/upgrade-backup.rb` (which synthesizes
- /// `primaryRegions` from `trackedRegions`), matching the module's
+ /// v3 adds sample device provenance plus the synced recording-device and
+ /// append-only policy tables, and stores dates as lossless Unix epoch
+ /// seconds. There's no in-app decode fallback for an older archive — it is
+ /// reshaped out of band by `Tools/upgrade-backup.rb`, matching the module's
/// no-migration-on-read rule (see `AGENTS.md`).
- public static let currentFormatVersion = 2
+ public static let currentFormatVersion = 3
public let formatVersion: Int
public let exportedAt: Date
@@ -40,6 +41,10 @@ public struct BackupArchive: Codable, Sendable, Hashable {
/// brings back the *look*, not just the region set. Import restores from
/// this; `trackedRegions` is the derived id list.
public let primaryRegions: [PrimaryRegion]
+ /// Every synced device profile, including archived devices.
+ public let recordingDevices: [RecordingDevice]
+ /// The full append-only policy timeline for every device.
+ public let recordingPolicyChanges: [RecordingPolicyChange]
/// One entry per evidence record that has blob bytes in the archive.
/// Evidence without bytes simply has no entry here.
public let assets: [BackupAssetEntry]
@@ -53,6 +58,8 @@ public struct BackupArchive: Codable, Sendable, Hashable {
dismissedIssues: [DismissedIssue],
trackedRegions: [Region],
primaryRegions: [PrimaryRegion],
+ recordingDevices: [RecordingDevice] = [],
+ recordingPolicyChanges: [RecordingPolicyChange] = [],
assets: [BackupAssetEntry],
) {
self.formatVersion = formatVersion
@@ -63,6 +70,8 @@ public struct BackupArchive: Codable, Sendable, Hashable {
self.dismissedIssues = dismissedIssues
self.trackedRegions = trackedRegions
self.primaryRegions = primaryRegions
+ self.recordingDevices = recordingDevices
+ self.recordingPolicyChanges = recordingPolicyChanges
self.assets = assets
}
}
diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift
index fd7b97436..18c942674 100644
--- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift
+++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift
@@ -33,6 +33,8 @@ public actor BackupCoordinator {
public let manualDayCount: Int
public let dismissedIssueCount: Int
public let trackedRegionCount: Int
+ public let recordingDeviceCount: Int
+ public let recordingPolicyChangeCount: Int
public init(
sampleCount: Int,
@@ -40,12 +42,16 @@ public actor BackupCoordinator {
manualDayCount: Int,
dismissedIssueCount: Int,
trackedRegionCount: Int,
+ recordingDeviceCount: Int = 0,
+ recordingPolicyChangeCount: Int = 0,
) {
self.sampleCount = sampleCount
self.evidenceCount = evidenceCount
self.manualDayCount = manualDayCount
self.dismissedIssueCount = dismissedIssueCount
self.trackedRegionCount = trackedRegionCount
+ self.recordingDeviceCount = recordingDeviceCount
+ self.recordingPolicyChangeCount = recordingPolicyChangeCount
}
}
@@ -117,6 +123,8 @@ public actor BackupCoordinator {
manualDays: store.allManualDays(),
dismissedIssues: store.allDismissedIssues(),
primaryRegions: store.primaryRegions(),
+ recordingDevices: store.recordingDevices(),
+ recordingPolicyChanges: store.recordingPolicyChanges(),
)
}
let evidence = tables.evidence
@@ -145,6 +153,8 @@ public actor BackupCoordinator {
// The bare ids ride alongside the primary regions for older readers.
trackedRegions: tables.primaryRegions.map(\.region),
primaryRegions: tables.primaryRegions,
+ recordingDevices: tables.recordingDevices,
+ recordingPolicyChanges: tables.recordingPolicyChanges,
blobs: blobs,
)
}.value
@@ -162,6 +172,8 @@ public actor BackupCoordinator {
let manualDays: [DayPresence]
let dismissedIssues: [DismissedIssue]
let primaryRegions: [PrimaryRegion]
+ let recordingDevices: [RecordingDevice]
+ let recordingPolicyChanges: [RecordingPolicyChange]
}
/// Delete the most recent export's staging directory now, rather than
@@ -228,6 +240,7 @@ public actor BackupCoordinator {
let blobs = result.blobs
let total = archive.samples.count + archive.evidence.count
+ archive.manualDays.count + archive.dismissedIssues.count
+ + archive.recordingDevices.count + archive.recordingPolicyChanges.count
try await Self.logger.measure(.importWrite) {
try await store.perform {
@@ -263,6 +276,14 @@ public actor BackupCoordinator {
try await store.restoreDismissedIssue(dismissal)
report()
}
+ for device in archive.recordingDevices {
+ try await store.setRecordingDevice(device)
+ report()
+ }
+ for change in archive.recordingPolicyChanges {
+ try await store.addRecordingPolicyChange(change)
+ report()
+ }
// Primary regions (with their picked looks) round-trip like any
// other data. On `.replace` the store was cleared above, so write
// the archive's set exactly; on `.merge` union it into the current
@@ -294,6 +315,8 @@ public actor BackupCoordinator {
manualDayCount: archive.manualDays.count,
dismissedIssueCount: archive.dismissedIssues.count,
trackedRegionCount: archive.primaryRegions.count,
+ recordingDeviceCount: archive.recordingDevices.count,
+ recordingPolicyChangeCount: archive.recordingPolicyChanges.count,
)
}
diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift
index 5b80978b4..c9f711b5e 100644
--- a/Where/WhereCore/Sources/Backup/BackupService.swift
+++ b/Where/WhereCore/Sources/Backup/BackupService.swift
@@ -56,14 +56,17 @@ public struct BackupService: Sendable {
private static func makeEncoder() -> JSONEncoder {
let encoder = JSONEncoder()
- encoder.dateEncodingStrategy = .iso8601
+ // Foundation's ISO-8601 strategy drops fractional seconds. Recording
+ // policy ordering and its sample cutoffs are subsecond-sensitive, so
+ // encode the `Date` value losslessly as Unix epoch seconds instead.
+ encoder.dateEncodingStrategy = .secondsSince1970
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
private static func makeDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
- decoder.dateDecodingStrategy = .iso8601
+ decoder.dateDecodingStrategy = .secondsSince1970
return decoder
}
@@ -82,6 +85,8 @@ public struct BackupService: Sendable {
dismissedIssues: [DismissedIssue] = [],
trackedRegions: [Region] = [],
primaryRegions: [PrimaryRegion] = [],
+ recordingDevices: [RecordingDevice] = [],
+ recordingPolicyChanges: [RecordingPolicyChange] = [],
blobs: [UUID: Data],
exportedAt: Date = Date(),
archiveName: String? = nil,
@@ -116,6 +121,8 @@ public struct BackupService: Sendable {
dismissedIssues: dismissedIssues,
trackedRegions: trackedRegions,
primaryRegions: primaryRegions,
+ recordingDevices: recordingDevices,
+ recordingPolicyChanges: recordingPolicyChanges,
assets: assetEntries,
)
try Self.logger.measure(.encodeManifest) {
diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift
new file mode 100644
index 000000000..6e16579f2
--- /dev/null
+++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift
@@ -0,0 +1,344 @@
+import Foundation
+
+/// Serializes synced recording policy with this process's optional physical GPS
+/// lifecycle.
+///
+/// Policy writes take effect historically at their timestamp immediately on
+/// every device that has synced them. The target device later acknowledges the
+/// latest event after it has started or stopped its local `LocationIngestor`.
+public actor DeviceRecordingController {
+ private let store: any WhereStore
+ private let ingestor: LocationIngestor
+ public nonisolated let participation: RecordingParticipation
+ public nonisolated var currentDevice: CurrentRecordingDevice? {
+ participation.currentDevice
+ }
+
+ private let now: @Sendable () -> Date
+
+ /// Reentrancy-safe gate: each public mutation/reconcile holds it across
+ /// awaits, so a rapid toggle cannot let an older start finish after a newer
+ /// stop. Actor isolation alone is insufficient because actors are reentrant.
+ private var isExclusive = false
+ private var waiters: [CheckedContinuation] = []
+ private var acceptsOperations = true
+
+ init(
+ store: any WhereStore,
+ ingestor: LocationIngestor,
+ participation: RecordingParticipation,
+ now: @escaping @Sendable () -> Date,
+ ) {
+ self.store = store
+ self.ingestor = ingestor
+ self.participation = participation
+ self.now = now
+ }
+
+ /// For a participating installation, register it if needed, migrate its
+ /// initial desired state from local preferences, then make physical
+ /// monitoring match the latest synced policy and authorization. A
+ /// management-only process stops its inert ingestor and returns `nil`.
+ @discardableResult
+ public func reconcile(
+ initialEnabled: Bool,
+ authorization: LocationAuthorizationStatus,
+ ) async throws -> RecordingDeviceConfiguration? {
+ await beginExclusive()
+ defer { endExclusive() }
+ try requireActive()
+ guard currentDevice != nil else {
+ await ingestor.stop()
+ return nil
+ }
+ return try await reconcileLocked(
+ initialEnabled: initialEnabled,
+ authorization: authorization,
+ )
+ }
+
+ /// Active device configurations, with the current device first when this
+ /// process participates and the rest ordered by most recent check-in.
+ public func devices(initialEnabled: Bool) async throws -> [RecordingDeviceConfiguration] {
+ await beginExclusive()
+ defer { endExclusive() }
+ try requireActive()
+ try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled)
+ return try await configurationsLocked(includeArchived: false)
+ }
+
+ /// Append a desired-state change. For this installation, reconcile and
+ /// acknowledge it before returning. A remote installation will show pending
+ /// until that device receives and applies the CloudKit row.
+ @discardableResult
+ public func setEnabled(
+ _ enabled: Bool,
+ for deviceID: RecordingDeviceID,
+ initialEnabled: Bool,
+ ) async throws -> [RecordingDeviceConfiguration] {
+ await beginExclusive()
+ defer { endExclusive() }
+ try requireActive()
+ try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled)
+
+ let changes = try await store.recordingPolicyChanges()
+ let change = RecordingPolicyChange(
+ id: UUID(),
+ deviceID: deviceID,
+ effectiveAt: Self.nextEffectiveDate(
+ proposed: now(),
+ after: Self.latestPolicy(for: deviceID, in: changes),
+ ),
+ isEnabled: enabled,
+ )
+ try await store.perform {
+ try await store.addRecordingPolicyChange(change)
+ if enabled {
+ try await store.updateRecordingDevice(deviceID) { $0.unarchived() }
+ }
+ }
+
+ if deviceID == currentDevice?.id {
+ let authorization = await ingestor.authorizationStatus()
+ _ = try await reconcileLocked(
+ initialEnabled: initialEnabled,
+ authorization: authorization,
+ )
+ }
+ return try await configurationsLocked(includeArchived: false)
+ }
+
+ /// Change the synced, user-editable nickname. Empty/whitespace-only text
+ /// clears the nickname and falls back to the generic system label.
+ public func rename(
+ _ deviceID: RecordingDeviceID,
+ to nickname: String,
+ initialEnabled: Bool,
+ ) async throws -> [RecordingDeviceConfiguration] {
+ await beginExclusive()
+ defer { endExclusive() }
+ try requireActive()
+ try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled)
+
+ let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
+ try await store.perform {
+ try await store.updateRecordingDevice(deviceID) {
+ $0.renamed(trimmed.isEmpty ? nil : trimmed)
+ }
+ }
+ return try await configurationsLocked(includeArchived: false)
+ }
+
+ /// Hide a non-current stale device and append an off cutoff atomically.
+ /// Policy history and raw samples remain available to backups.
+ public func archive(
+ _ deviceID: RecordingDeviceID,
+ initialEnabled: Bool,
+ ) async throws -> [RecordingDeviceConfiguration] {
+ precondition(deviceID != currentDevice?.id, "The current device cannot archive itself.")
+ await beginExclusive()
+ defer { endExclusive() }
+ try requireActive()
+ try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled)
+ guard try await store.recordingDevices().contains(where: { $0.id == deviceID })
+ else { return try await configurationsLocked(includeArchived: false) }
+
+ let date = now()
+ let changes = try await store.recordingPolicyChanges()
+ let change = RecordingPolicyChange(
+ id: UUID(),
+ deviceID: deviceID,
+ effectiveAt: Self.nextEffectiveDate(
+ proposed: date,
+ after: Self.latestPolicy(for: deviceID, in: changes),
+ ),
+ isEnabled: false,
+ )
+ try await store.perform {
+ try await store.addRecordingPolicyChange(change)
+ try await store.updateRecordingDevice(deviceID) { $0.archived(at: date) }
+ }
+ return try await configurationsLocked(includeArchived: false)
+ }
+
+ /// Permanently close this stack's policy/write gate and quiesce GPS before
+ /// reset wipes the store. A queued observer reconciliation resumes behind
+ /// this gate, sees the closed state, and cannot recreate the just-erased
+ /// current-device rows.
+ func quiesce() async {
+ await beginExclusive()
+ defer { endExclusive() }
+ acceptsOperations = false
+ await ingestor.quiesce()
+ }
+
+ /// A failed reset retains the session, so reopen its operation gate. The
+ /// next lifecycle reconciliation decides whether GPS should resume.
+ func resumeAfterFailedReset() async {
+ await beginExclusive()
+ defer { endExclusive() }
+ acceptsOperations = true
+ }
+
+ private func reconcileLocked(
+ initialEnabled: Bool,
+ authorization: LocationAuthorizationStatus,
+ ) async throws -> RecordingDeviceConfiguration {
+ guard let currentDevice else {
+ preconditionFailure("A management-only controller cannot reconcile local recording.")
+ }
+ try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled)
+ let policies = try await store.recordingPolicyChanges()
+ guard let latest = Self.latestPolicy(for: currentDevice.id, in: policies) else {
+ preconditionFailure(
+ "Current recording device was registered without an initial policy.",
+ )
+ }
+
+ let status: RecordingDeviceStatus
+ if latest.isEnabled, authorization.allowsBackgroundTracking {
+ await ingestor.start()
+ status = .recording
+ } else {
+ await ingestor.stop()
+ status = latest.isEnabled ? .permissionRequired : .off
+ }
+
+ guard let device = try await store.recordingDevices()
+ .first(where: { $0.id == currentDevice.id })
+ else {
+ preconditionFailure("Current recording device disappeared during reconciliation.")
+ }
+ let checkIn = now()
+ let needsAcknowledgement = device.lastAppliedPolicyChangeID != latest.id
+ || device.status != status
+ let needsPeriodicCheckIn = checkIn.timeIntervalSince(device.lastSeenAt) >= 15 * 60
+ var acknowledged = device
+ if needsAcknowledgement || needsPeriodicCheckIn {
+ let updated = try await store.perform {
+ try await store.updateRecordingDevice(currentDevice.id) {
+ $0.acknowledging(
+ policyChangeID: latest.id,
+ status: status,
+ at: max($0.lastSeenAt, checkIn),
+ )
+ }
+ }
+ guard let updated else {
+ preconditionFailure("Current recording device disappeared during reconciliation.")
+ }
+ acknowledged = updated
+ }
+ return RecordingDeviceConfiguration(
+ device: acknowledged,
+ isEnabled: latest.isEnabled,
+ latestPolicyChangeID: latest.id,
+ )
+ }
+
+ private func ensureCurrentDeviceLocked(initialEnabled: Bool) async throws {
+ guard let currentDevice else { return }
+ let devices = try await store.recordingDevices()
+ let policies = try await store.recordingPolicyChanges()
+ let existing = devices.first(where: { $0.id == currentDevice.id })
+ let latest = Self.latestPolicy(for: currentDevice.id, in: policies)
+ guard existing == nil || latest == nil else { return }
+
+ let date = now()
+ let profile = existing ?? RecordingDevice(
+ id: currentDevice.id,
+ systemName: currentDevice.systemName,
+ nickname: nil,
+ kind: currentDevice.kind,
+ registeredAt: date,
+ lastSeenAt: date,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: nil,
+ status: .off,
+ )
+ let initialChange = latest ?? RecordingPolicyChange(
+ id: UUID(),
+ deviceID: currentDevice.id,
+ effectiveAt: date,
+ isEnabled: initialEnabled,
+ )
+ try await store.perform {
+ if existing == nil {
+ try await store.setRecordingDevice(profile)
+ }
+ if latest == nil {
+ try await store.addRecordingPolicyChange(initialChange)
+ }
+ }
+ }
+
+ private func configurationsLocked(
+ includeArchived: Bool,
+ ) async throws -> [RecordingDeviceConfiguration] {
+ async let devices = store.recordingDevices()
+ async let policies = store.recordingPolicyChanges()
+ let (resolvedDevices, resolvedPolicies) = try await (devices, policies)
+ return resolvedDevices
+ .filter {
+ includeArchived || $0.archivedAt == nil || $0.id == currentDevice?.id
+ }
+ .map { device in
+ let latest = Self.latestPolicy(for: device.id, in: resolvedPolicies)
+ return RecordingDeviceConfiguration(
+ device: device,
+ isEnabled: latest?.isEnabled ?? true,
+ latestPolicyChangeID: latest?.id,
+ )
+ }
+ .sorted { lhs, rhs in
+ if lhs.id == currentDevice?.id { return true }
+ if rhs.id == currentDevice?.id { return false }
+ if lhs.device.lastSeenAt != rhs.device.lastSeenAt {
+ return lhs.device.lastSeenAt > rhs.device.lastSeenAt
+ }
+ return lhs.id.storeURL.absoluteString < rhs.id.storeURL.absoluteString
+ }
+ }
+
+ private static func latestPolicy(
+ for deviceID: RecordingDeviceID,
+ in changes: [RecordingPolicyChange],
+ ) -> RecordingPolicyChange? {
+ changes
+ .filter { $0.deviceID == deviceID }
+ .max { RecordingPolicyChange.isOrderedBefore($0, $1) }
+ }
+
+ /// Preserve the local order of rapid actions even when the injected clock
+ /// returns the same instant for both. UUID ordering remains the convergent
+ /// tie-break for genuinely concurrent changes written on different devices.
+ private static func nextEffectiveDate(
+ proposed: Date,
+ after latest: RecordingPolicyChange?,
+ ) -> Date {
+ guard let latest, proposed <= latest.effectiveAt else { return proposed }
+ return latest.effectiveAt.addingTimeInterval(0.000_001)
+ }
+
+ private func requireActive() throws {
+ guard acceptsOperations else { throw CancellationError() }
+ }
+
+ private func beginExclusive() async {
+ if isExclusive {
+ await withCheckedContinuation { continuation in
+ waiters.append(continuation)
+ }
+ } else {
+ isExclusive = true
+ }
+ }
+
+ private func endExclusive() {
+ if waiters.isEmpty {
+ isExclusive = false
+ } else {
+ waiters.removeFirst().resume()
+ }
+ }
+}
diff --git a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift
new file mode 100644
index 000000000..5f020b043
--- /dev/null
+++ b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+/// Shared policy-aware read path for every user-facing projection of location
+/// history. The store remains a raw, lossless persistence boundary; this reader
+/// applies the effective device cutoffs before data reaches reports or widgets.
+public struct LocationHistoryReader: Sendable {
+ private let store: any WhereStore
+
+ public init(store: any WhereStore) {
+ self.store = store
+ }
+
+ public func samples(in interval: DateInterval) async throws -> [LocationSample] {
+ async let samples = store.samples(in: interval)
+ async let policyChanges = store.recordingPolicyChanges()
+ let (resolvedSamples, resolvedPolicyChanges) = try await (samples, policyChanges)
+ return RecordingPolicyFilter.visibleSamples(
+ resolvedSamples,
+ policyChanges: resolvedPolicyChanges,
+ )
+ }
+}
diff --git a/Where/WhereCore/Sources/Devices/RecordingDevice.swift b/Where/WhereCore/Sources/Devices/RecordingDevice.swift
new file mode 100644
index 000000000..ea9664628
--- /dev/null
+++ b/Where/WhereCore/Sources/Devices/RecordingDevice.swift
@@ -0,0 +1,144 @@
+import Foundation
+
+/// Broad hardware family used to choose an icon without persisting a
+/// user-visible device name supplied by the operating system.
+public enum RecordingDeviceKind: String, Codable, Sendable, Hashable {
+ case phone
+ case tablet
+ case other
+}
+
+/// The last effective recording state acknowledged by a device.
+public enum RecordingDeviceStatus: String, Codable, Sendable, Hashable {
+ case recording
+ case off
+ case permissionRequired
+}
+
+/// Synced profile for one device that can contribute automatic locations.
+///
+/// `nickname` is user-editable and synced. `systemName` is only a generic
+/// hardware label such as “iPhone” or “iPad”; Where deliberately does not ask
+/// for the user-assigned-device-name entitlement.
+public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable {
+ public let id: RecordingDeviceID
+ public let systemName: String
+ public let nickname: String?
+ public let kind: RecordingDeviceKind
+ public let registeredAt: Date
+ public let lastSeenAt: Date
+ public let archivedAt: Date?
+ public let lastAppliedPolicyChangeID: UUID?
+ public let status: RecordingDeviceStatus
+
+ public init(
+ id: RecordingDeviceID,
+ systemName: String,
+ nickname: String?,
+ kind: RecordingDeviceKind,
+ registeredAt: Date,
+ lastSeenAt: Date,
+ archivedAt: Date?,
+ lastAppliedPolicyChangeID: UUID?,
+ status: RecordingDeviceStatus,
+ ) {
+ self.id = id
+ self.systemName = systemName
+ self.nickname = nickname
+ self.kind = kind
+ self.registeredAt = registeredAt
+ self.lastSeenAt = lastSeenAt
+ self.archivedAt = archivedAt
+ self.lastAppliedPolicyChangeID = lastAppliedPolicyChangeID
+ self.status = status
+ }
+
+ public var displayName: String {
+ let trimmed = nickname?.trimmingCharacters(in: .whitespacesAndNewlines)
+ return if let trimmed, !trimmed.isEmpty { trimmed } else { systemName }
+ }
+
+ func renamed(_ nickname: String?) -> RecordingDevice {
+ RecordingDevice(
+ id: id,
+ systemName: systemName,
+ nickname: nickname,
+ kind: kind,
+ registeredAt: registeredAt,
+ lastSeenAt: lastSeenAt,
+ archivedAt: archivedAt,
+ lastAppliedPolicyChangeID: lastAppliedPolicyChangeID,
+ status: status,
+ )
+ }
+
+ func archived(at date: Date) -> RecordingDevice {
+ RecordingDevice(
+ id: id,
+ systemName: systemName,
+ nickname: nickname,
+ kind: kind,
+ registeredAt: registeredAt,
+ lastSeenAt: lastSeenAt,
+ archivedAt: date,
+ lastAppliedPolicyChangeID: lastAppliedPolicyChangeID,
+ status: status,
+ )
+ }
+
+ func unarchived() -> RecordingDevice {
+ RecordingDevice(
+ id: id,
+ systemName: systemName,
+ nickname: nickname,
+ kind: kind,
+ registeredAt: registeredAt,
+ lastSeenAt: lastSeenAt,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: lastAppliedPolicyChangeID,
+ status: status,
+ )
+ }
+
+ func acknowledging(
+ policyChangeID: UUID,
+ status: RecordingDeviceStatus,
+ at date: Date,
+ ) -> RecordingDevice {
+ RecordingDevice(
+ id: id,
+ systemName: systemName,
+ nickname: nickname,
+ kind: kind,
+ registeredAt: registeredAt,
+ lastSeenAt: date,
+ archivedAt: archivedAt,
+ lastAppliedPolicyChangeID: policyChangeID,
+ status: status,
+ )
+ }
+}
+
+/// Local, non-synced description used to register this installation in the
+/// synced device list.
+public struct CurrentRecordingDevice: Sendable, Hashable {
+ public let id: RecordingDeviceID
+ public let systemName: String
+ public let kind: RecordingDeviceKind
+
+ public init(id: RecordingDeviceID, systemName: String, kind: RecordingDeviceKind) {
+ self.id = id
+ self.systemName = systemName
+ self.kind = kind
+ }
+
+ /// Deterministic identity for tests and previews that do not care which
+ /// installation is current.
+ public static let preview = CurrentRecordingDevice(
+ id: RecordingDeviceID(
+ rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
+ ),
+ systemName: "iPhone",
+ kind: .phone,
+ )
+}
diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift
new file mode 100644
index 000000000..c2cbc6c70
--- /dev/null
+++ b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift
@@ -0,0 +1,28 @@
+import Foundation
+
+/// One row shown by device-management UI: the synced profile plus its latest
+/// desired policy and whether that policy has been acknowledged by the device.
+public struct RecordingDeviceConfiguration: Identifiable, Sendable, Hashable {
+ public let device: RecordingDevice
+ public let isEnabled: Bool
+ public let latestPolicyChangeID: UUID?
+
+ public var id: RecordingDeviceID {
+ device.id
+ }
+
+ public var isPending: Bool {
+ guard let latestPolicyChangeID else { return false }
+ return device.lastAppliedPolicyChangeID != latestPolicyChangeID
+ }
+
+ public init(
+ device: RecordingDevice,
+ isEnabled: Bool,
+ latestPolicyChangeID: UUID?,
+ ) {
+ self.device = device
+ self.isEnabled = isEnabled
+ self.latestPolicyChangeID = latestPolicyChangeID
+ }
+}
diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceID.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceID.swift
new file mode 100644
index 000000000..487cfbf8c
--- /dev/null
+++ b/Where/WhereCore/Sources/Devices/RecordingDeviceID.swift
@@ -0,0 +1,35 @@
+import Foundation
+
+/// Stable identity of one installation that can record automatic locations.
+///
+/// The value encodes as a single `store://devices/` URL so the same
+/// identity is readable in backups, SwiftData, and structured logs without
+/// exposing a raw, stringly-typed key.
+public struct RecordingDeviceID: Hashable, Sendable, Identifiable, WhereStoreURLCodable {
+ public let rawValue: UUID
+
+ public var id: RecordingDeviceID {
+ self
+ }
+
+ public init(rawValue: UUID) {
+ self.rawValue = rawValue
+ }
+
+ public var storeURL: URL {
+ StoreURL.url(
+ collection: "devices",
+ type: rawValue.uuidString.lowercased(),
+ items: [:],
+ )
+ }
+
+ public init?(storeURL: URL) {
+ guard let parts = StoreURL.parts(of: storeURL),
+ parts.collection == "devices",
+ parts.items.isEmpty,
+ let rawValue = UUID(uuidString: parts.type)
+ else { return nil }
+ self.rawValue = rawValue
+ }
+}
diff --git a/Where/WhereCore/Sources/Devices/RecordingParticipation.swift b/Where/WhereCore/Sources/Devices/RecordingParticipation.swift
new file mode 100644
index 000000000..1f1d43cb5
--- /dev/null
+++ b/Where/WhereCore/Sources/Devices/RecordingParticipation.swift
@@ -0,0 +1,30 @@
+/// Whether this process may contribute automatic locations from the local
+/// installation, plus the policy a genuinely new installation starts with.
+///
+/// A management-only process can still read and edit synced recording-device
+/// rows, but it has no local device identity and must never start GPS.
+public enum RecordingParticipation: Sendable, Hashable {
+ case recording(
+ device: CurrentRecordingDevice,
+ defaultEnabledForNewInstallation: Bool,
+ )
+ case managementOnly
+
+ public var currentDevice: CurrentRecordingDevice? {
+ switch self {
+ case let .recording(device, _): device
+ case .managementOnly: nil
+ }
+ }
+
+ public var defaultEnabledForNewInstallation: Bool {
+ switch self {
+ case let .recording(_, isEnabled): isEnabled
+ case .managementOnly: false
+ }
+ }
+
+ public var supportsLocalRecording: Bool {
+ currentDevice != nil
+ }
+}
diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift
new file mode 100644
index 000000000..4d3238c0d
--- /dev/null
+++ b/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+/// Append-only change to automatic recording policy for one device.
+///
+/// The timestamp is the effective historical cutoff. A device that has not
+/// received the CloudKit change may briefly keep producing raw samples, but
+/// every report filters those samples from this instant onward.
+public struct RecordingPolicyChange: Identifiable, Codable, Sendable, Hashable {
+ public let id: UUID
+ public let deviceID: RecordingDeviceID
+ public let effectiveAt: Date
+ public let isEnabled: Bool
+
+ public init(
+ id: UUID,
+ deviceID: RecordingDeviceID,
+ effectiveAt: Date,
+ isEnabled: Bool,
+ ) {
+ self.id = id
+ self.deviceID = deviceID
+ self.effectiveAt = effectiveAt
+ self.isEnabled = isEnabled
+ }
+}
+
+extension RecordingPolicyChange {
+ /// Deterministic latest-wins ordering. UUID text breaks equal-timestamp
+ /// ties so devices that receive concurrent CloudKit rows converge.
+ static func isOrderedBefore(
+ _ lhs: RecordingPolicyChange,
+ _ rhs: RecordingPolicyChange,
+ ) -> Bool {
+ if lhs.effectiveAt != rhs.effectiveAt {
+ return lhs.effectiveAt < rhs.effectiveAt
+ }
+ return lhs.id.uuidString < rhs.id.uuidString
+ }
+}
diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift
new file mode 100644
index 000000000..aa20e20f4
--- /dev/null
+++ b/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+/// Applies device recording policy to raw location samples.
+///
+/// Policy changes are append-only and evaluated at each sample timestamp.
+/// Legacy samples without a device ID remain visible because no device policy
+/// can be attributed to them safely.
+public enum RecordingPolicyFilter {
+ public static func visibleSamples(
+ _ samples: [LocationSample],
+ policyChanges: [RecordingPolicyChange],
+ ) -> [LocationSample] {
+ let timelines = Dictionary(grouping: policyChanges, by: \.deviceID)
+ .mapValues { $0.sorted(by: RecordingPolicyChange.isOrderedBefore) }
+
+ return samples.filter { sample in
+ guard sample.source.isGPS, let deviceID = sample.recordingDeviceID else {
+ return true
+ }
+ guard let timeline = timelines[deviceID] else {
+ return true
+ }
+ let latest = timeline.last { change in
+ change.effectiveAt <= sample.timestamp
+ }
+ return latest?.isEnabled ?? true
+ }
+ }
+}
diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift
index e0e2e7689..5c2e1dac9 100644
--- a/Where/WhereCore/Sources/Location/LocationIngestor.swift
+++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift
@@ -29,6 +29,10 @@ public actor LocationIngestor {
private let store: any WhereStore
private let locationSource: any LocationSource
+ /// Nil for a management-only process. Such a process can use the service
+ /// layer for reads and manual writes, but every automatic-location entry
+ /// point remains inert because there is no installation to attribute it to.
+ private let recordingDeviceID: RecordingDeviceID?
private let calendar: Calendar
private let onPersisted: PostPersistHook
/// Durable mirror of `retryQueue`, so a backlog survives the process dying
@@ -89,6 +93,7 @@ public actor LocationIngestor {
init(
store: any WhereStore,
locationSource: any LocationSource,
+ recordingDeviceID: RecordingDeviceID?,
calendar: Calendar,
outbox: any LocationOutbox = NoOpLocationOutbox(),
retryQueueCapacity: Int = 1000,
@@ -97,6 +102,7 @@ public actor LocationIngestor {
precondition(retryQueueCapacity > 0, "retryQueueCapacity must be positive")
self.store = store
self.locationSource = locationSource
+ self.recordingDeviceID = recordingDeviceID
self.calendar = calendar
self.outbox = outbox
self.retryQueueCapacity = retryQueueCapacity
@@ -119,6 +125,7 @@ public actor LocationIngestor {
/// single-consumer `AsyncStream`, so a later `start()` would iterate an
/// already-finished stream and silently drop every subsequent sample.
public func start() async {
+ guard let recordingDeviceID else { return }
// Re-open the sample gate a prior `quiesce()` may have shut (e.g. the
// relaunch after a reset resumes ingestion here).
acceptsSamples = true
@@ -134,7 +141,7 @@ public actor LocationIngestor {
if !restored.isEmpty {
Self.logger { .restoredBacklog(count: restored.count) }
}
- retryQueue = restored + retryQueue
+ retryQueue = restored.map { $0.recorded(by: recordingDeviceID) } + retryQueue
}
// Flush anything that failed to persist before this session started,
// before we (re)attach the stream consumer.
@@ -242,6 +249,7 @@ public actor LocationIngestor {
/// this stays safe regardless because `requestCurrentLocation()` returns
/// `nil` when no fix is available.
public func captureTodayIfNeeded(now: Date) {
+ guard recordingDeviceID != nil else { return }
guard captureTask == nil else { return }
captureTask = Task { [weak self] in
await self?.performTodayCapture(now: now)
@@ -262,8 +270,12 @@ public actor LocationIngestor {
}
let interval = DateInterval(start: startOfDay, end: endOfDay)
do {
- let existing = try await store.samples(in: interval)
- if existing.contains(where: \.source.isGPS) { return }
+ let existing = try await LocationHistoryReader(store: store).samples(in: interval)
+ if existing.contains(where: {
+ $0.source.isGPS
+ && ($0.recordingDeviceID == recordingDeviceID
+ || $0.recordingDeviceID == nil)
+ }) { return }
} catch {
// Fail closed: if today's samples can't be read we skip rather than
// risk logging a duplicate fix. Surfaced, not silently swallowed.
@@ -324,6 +336,8 @@ public actor LocationIngestor {
/// failure. Drains any backlog first so a single transient outage doesn't
/// permanently reorder samples on disk.
private func processIngestedSample(_ sample: LocationSample) async {
+ guard let recordingDeviceID else { return }
+ let sample = sample.recorded(by: recordingDeviceID)
let drainedDays = await drainRetryQueue()
do {
try await store.perform { try await store.add(sample: sample) }
diff --git a/Where/WhereCore/Sources/Location/LocationSample.swift b/Where/WhereCore/Sources/Location/LocationSample.swift
index b3776b7aa..b0e2b7d4e 100644
--- a/Where/WhereCore/Sources/Location/LocationSample.swift
+++ b/Where/WhereCore/Sources/Location/LocationSample.swift
@@ -103,6 +103,9 @@ public struct LocationSample: Identifiable, Hashable, Codable, Sendable {
public let coordinate: Coordinate
public let horizontalAccuracy: Double
public let source: SampleSource
+ /// Installation that produced an automatic GPS sample. Nil for legacy
+ /// samples and user-asserted/manual data.
+ public let recordingDeviceID: RecordingDeviceID?
public init(
id: UUID = UUID(),
@@ -110,11 +113,27 @@ public struct LocationSample: Identifiable, Hashable, Codable, Sendable {
coordinate: Coordinate,
horizontalAccuracy: Double,
source: SampleSource,
+ recordingDeviceID: RecordingDeviceID? = nil,
) {
self.id = id
self.timestamp = timestamp
self.coordinate = coordinate
self.horizontalAccuracy = horizontalAccuracy
self.source = source
+ self.recordingDeviceID = recordingDeviceID
+ }
+
+ /// Stamp an automatic sample with the installation that received it.
+ /// User-asserted samples intentionally remain device-agnostic.
+ func recorded(by deviceID: RecordingDeviceID) -> LocationSample {
+ guard source.isGPS else { return self }
+ return LocationSample(
+ id: id,
+ timestamp: timestamp,
+ coordinate: coordinate,
+ horizontalAccuracy: horizontalAccuracy,
+ source: source,
+ recordingDeviceID: deviceID,
+ )
}
}
diff --git a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift
index f3c2b3ff1..5345631fd 100644
--- a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift
+++ b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift
@@ -41,13 +41,17 @@ enum SwiftDataStoreLog: LogEvent {
case ignoredUnknownPrimaryRegions(ids: [String])
/// Dropped a record that failed to materialize into a domain value.
case droppedCorruptRecord(type: String)
+ /// Persistent history could not be read to classify a store write.
+ case historyReadFailed(description: String)
static let eventName = "SwiftDataStore"
var level: LogLevel {
switch self {
case .openedInMemory, .openedOnDisk: .info
- case .ignoredUnknownTrackedRegions, .ignoredUnknownPrimaryRegions: .warning
+ case .ignoredUnknownTrackedRegions, .ignoredUnknownPrimaryRegions,
+ .historyReadFailed:
+ .warning
case .droppedCorruptRecord: .fault
}
}
@@ -64,6 +68,8 @@ enum SwiftDataStoreLog: LogEvent {
"Ignored \(ids.count) unknown primary-region id(s): \(ids.joined(separator: ", "))"
case let .droppedCorruptRecord(type):
"Dropped corrupt SwiftData record of type \(type)"
+ case let .historyReadFailed(description):
+ "Failed to read SwiftData history: \(description)"
}
}
}
diff --git a/Where/WhereCore/Sources/Persistence/StoreChangeBroadcaster.swift b/Where/WhereCore/Sources/Persistence/StoreChangeBroadcaster.swift
index 6a4c4f927..f8d34af16 100644
--- a/Where/WhereCore/Sources/Persistence/StoreChangeBroadcaster.swift
+++ b/Where/WhereCore/Sources/Persistence/StoreChangeBroadcaster.swift
@@ -3,14 +3,9 @@ import Foundation
/// Fans "the persisted data changed" pings out to any number of independent
/// `AsyncStream` subscribers.
///
-/// The persistence boundary (`SwiftDataStore`) owns one of these and pings it
-/// once per committed change — after every outermost `perform` transaction
-/// commits, and on a CloudKit remote import synced from another device. That
-/// gives every reader a single signal regardless of *who* wrote: a manual edit,
-/// live GPS ingestion, or a remote sync. Consumers re-derive what they mirror
-/// (the `DataIssueScanner` drops its cache; `WhereSession` re-pulls its report +
-/// data-issue scan), so the payload is a bare `Void` — N pending pings and one
-/// are equivalent.
+/// `SwiftDataStore` owns one for every committed change and another for the
+/// remote-import subset. Consumers re-derive what they mirror, so the payload
+/// is a bare `Void` — N pending pings and one are equivalent.
///
/// Like `AuthorizationStatusBroadcaster`, each `subscribe()` gets an isolated
/// stream — an `AsyncStream` is single-pass, and the session is dropped + rebuilt
diff --git a/Where/WhereCore/Sources/Persistence/StoreHistoryClassifier.swift b/Where/WhereCore/Sources/Persistence/StoreHistoryClassifier.swift
new file mode 100644
index 000000000..4f1d63cd5
--- /dev/null
+++ b/Where/WhereCore/Sources/Persistence/StoreHistoryClassifier.swift
@@ -0,0 +1,48 @@
+import Foundation
+import SwiftData
+
+/// Separates this store instance's transactions from writes made elsewhere.
+///
+/// `NSPersistentStoreRemoteChange` is a write notification, not an
+/// external-origin guarantee. Every writer context owned by `SwiftDataStore`
+/// carries `localAuthor`; history after a checkpoint is external when at least
+/// one transaction has a different author. Each operation uses a fresh
+/// `ModelContext`, keeping this value stateless and safe to use from the
+/// store's long-lived observation task.
+struct StoreHistoryClassifier {
+ struct Classification {
+ let latestToken: DefaultHistoryToken?
+ let containsExternalTransaction: Bool
+ }
+
+ let container: ModelContainer
+ let localAuthor: String
+
+ /// Returns the newest history token, used as the observation checkpoint.
+ func checkpoint() throws -> DefaultHistoryToken? {
+ let context = ModelContext(container)
+ var descriptor = HistoryDescriptor(
+ sortBy: [SortDescriptor(\.transactionIdentifier, order: .reverse)],
+ )
+ descriptor.fetchLimit = 1
+ return try context.fetchHistory(descriptor).first?.token
+ }
+
+ /// Classifies every transaction after `token` and advances the checkpoint.
+ func classify(after token: DefaultHistoryToken?) throws -> Classification {
+ let context = ModelContext(container)
+ var descriptor = if let token {
+ HistoryDescriptor(
+ predicate: #Predicate { $0.token > token },
+ )
+ } else {
+ HistoryDescriptor()
+ }
+ descriptor.sortBy = [SortDescriptor(\.transactionIdentifier, order: .forward)]
+ let transactions = try context.fetchHistory(descriptor)
+ return Classification(
+ latestToken: transactions.last?.token ?? token,
+ containsExternalTransaction: transactions.contains { $0.author != localAuthor },
+ )
+ }
+}
diff --git a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift
index 1a4f149dd..0a13450ae 100644
--- a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift
+++ b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift
@@ -1,11 +1,22 @@
import CoreData
import Foundation
-/// Abstraction over "the persistent store imported changes from elsewhere" —
-/// for a CloudKit-backed store, a sync landing from another device. A
-/// `SwiftDataStore` observes one of these and re-pings its `changes()` fan-out,
-/// so a remote import refreshes the UI exactly like a local commit (one read
-/// path, regardless of who wrote).
+/// A persistent-store write signal awaiting origin classification.
+///
+/// Core Data's `.NSPersistentStoreRemoteChange` name is misleading: Apple
+/// documents that it posts for every persistent-store write, including writes
+/// from the current process. `SwiftDataStore` therefore classifies production
+/// events through SwiftData history before deciding whether to emit its
+/// external-only side-effect signal. The scripted case is explicitly external
+/// so tests can drive the post-classification path without a persistent store.
+enum StoreRemoteChangeEvent {
+ case persistentStoreWrite
+ case external
+}
+
+/// Abstraction over persistent-store write notifications. A `SwiftDataStore`
+/// observes one and uses transaction history to separate its own commits from
+/// CloudKit or sibling-process writes.
///
/// The seam exists so the whole remote-change path is exercisable off-device:
/// production wires `PersistentStoreRemoteChangeSource` (a real Core Data
@@ -14,66 +25,59 @@ import Foundation
/// the notification on import — stays untested here.
///
/// Class-only (`AnyObject`) because every implementation owns long-lived state
-/// (a notification token, an `AsyncStream.Continuation`) that can't be
+/// (an observer registration, an `AsyncStream.Continuation`) that can't be
/// value-copied. Mirrors `LocationSource`.
protocol StoreRemoteChangeSource: AnyObject, Sendable {
- /// Emits once per imported remote change. A bare `Void`: the store re-pings
- /// its fan-out and consumers re-read, so they only need to know *that*
- /// something changed. Exactly one consumer (the store) subscribes, so this
- /// is a single stream rather than a broadcaster.
- var remoteChanges: AsyncStream { get }
+ /// Emits once per persistent-store notification (production) or explicitly
+ /// external test event. Exactly one store consumes this stream.
+ var remoteChanges: AsyncStream { get }
}
/// Production `StoreRemoteChangeSource`: bridges Core Data's
-/// `.NSPersistentStoreRemoteChange` notification into an `AsyncStream`. That
-/// notification fires both when the CloudKit mirror
-/// (`NSPersistentCloudKitContainer`) imports records synced from another device
-/// and when a sibling process writes to a shared App Group store (the Where
-/// share extension saving evidence) — persistent-history tracking is on for
-/// on-disk stores. Observing it and re-reading is Apple's documented way to
-/// react to remote SwiftData/CloudKit and cross-process changes.
+/// `.NSPersistentStoreRemoteChange` notification into an `AsyncStream`.
+/// Despite its name, the notification fires for every write, including a
+/// `ModelContext.save()` in this process. The source deliberately preserves
+/// that raw meaning; `SwiftDataStore` checks transaction authors before it
+/// calls a write external.
///
-/// One store per app, so it forwards every remote-change notification rather
+/// One store per app, so it forwards every store-write notification rather
/// than filtering by coordinator (SwiftData doesn't expose the underlying
/// `NSPersistentStoreCoordinator` to filter on anyway).
-final class PersistentStoreRemoteChangeSource: StoreRemoteChangeSource, @unchecked Sendable {
- let remoteChanges: AsyncStream
+final class PersistentStoreRemoteChangeSource: NSObject, StoreRemoteChangeSource,
+ @unchecked Sendable
+{
+ let remoteChanges: AsyncStream
private let center: NotificationCenter
- private let continuation: AsyncStream.Continuation
- private let observer: NSObjectProtocol
+ private let continuation: AsyncStream.Continuation
init(center: NotificationCenter = .default) {
self.center = center
- var cont: AsyncStream.Continuation!
+ var cont: AsyncStream.Continuation!
remoteChanges = AsyncStream { cont = $0 }
continuation = cont
- // Capture the continuation in a local — deliberately *not*
- // `self.continuation` — so the long-lived observer block (which
- // `NotificationCenter` retains until `removeObserver`) doesn't capture
- // `self`. Capturing `self` would keep this source alive for as long as
- // the observer is registered, so `deinit` (which removes it) could
- // never run. The stored `continuation` property exists only for
- // `deinit` to `finish()`.
- let captured = cont!
- observer = center.addObserver(
- forName: .NSPersistentStoreRemoteChange,
+ super.init()
+ center.addObserver(
+ self,
+ selector: #selector(persistentStoreDidWrite),
+ name: .NSPersistentStoreRemoteChange,
object: nil,
- queue: nil,
- ) { _ in
- captured.yield()
- }
+ )
+ }
+
+ @objc private func persistentStoreDidWrite(_: Notification) {
+ continuation.yield(.persistentStoreWrite)
}
deinit {
- center.removeObserver(observer)
+ center.removeObserver(self)
continuation.finish()
}
}
#if DEBUG
- /// Hand-driven `StoreRemoteChangeSource` for tests: `yield()` simulates a
- /// remote import landing, so the store-observes-remote-change path can be
- /// driven deterministically without CloudKit or a device.
+ /// Hand-driven `StoreRemoteChangeSource` for tests: `yield()` sends an
+ /// explicitly external event, so the post-classification path can be driven
+ /// deterministically without CloudKit or a device.
///
/// `@_spi(Testing)` + `#if DEBUG` per the agents.md testing-hook convention:
/// it's test-only scaffolding that mustn't ship in release. Import it with
@@ -83,19 +87,19 @@ final class PersistentStoreRemoteChangeSource: StoreRemoteChangeSource, @uncheck
public final class ScriptedStoreRemoteChangeSource: StoreRemoteChangeSource,
@unchecked Sendable
{
- let remoteChanges: AsyncStream
- private let continuation: AsyncStream.Continuation
+ let remoteChanges: AsyncStream
+ private let continuation: AsyncStream.Continuation
init() {
- var cont: AsyncStream.Continuation!
+ var cont: AsyncStream.Continuation!
remoteChanges = AsyncStream { cont = $0 }
continuation = cont
}
- /// Simulate a remote import: a store observing this source re-pings its
- /// `changes()` fan-out. Named for the `continuation.yield()` it makes.
+ /// Simulate a change already known to be external. Named for the
+ /// continuation operation it performs.
func yield() {
- continuation.yield()
+ continuation.yield(.external)
}
func finish() {
diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift
index de4268e3b..54b4536d2 100644
--- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift
+++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift
@@ -102,9 +102,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
/// Whether a store of this mode can receive writes from outside this
/// process — a sibling App Group process (the share extension) for any
- /// on-disk store, or a CloudKit sync from another device — surfaced as
- /// `.NSPersistentStoreRemoteChange`. In-memory stores have no shared
- /// container and no other writers, so there's nothing to observe.
+ /// on-disk store, or a CloudKit sync from another device. Core Data's
+ /// `.NSPersistentStoreRemoteChange` notifies about those and local
+ /// writes; history classification separates them. In-memory stores have
+ /// no shared container and no other writers, so there's nothing to
+ /// observe.
var observesRemoteChanges: Bool {
switch self {
case .inMemory: false
@@ -113,10 +115,10 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
}
}
- /// App Group the on-disk store lives in, shared by the Where app, its
- /// widget extension, and the share extension so every process opens the
- /// *same* SwiftData store. Must match the `com.apple.security.application-groups`
- /// entitlement each of those targets declares (see `Project.swift`).
+ /// App Group the on-disk store lives in, shared by the Where app and share
+ /// extension so both processes open the *same* SwiftData store. Widgets and
+ /// the menu-bar helper hold the same App Group entitlement only to read the
+ /// published glance artifact. See `Project.swift`.
public static let appGroupIdentifier = "group.com.stuff.where"
public static func makeContainer(storage: Storage) throws -> ModelContainer {
@@ -131,6 +133,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
SDManualDay.self,
SDDismissedIssue.self,
SDTrackedRegion.self,
+ SDRecordingDevice.self,
+ SDRecordingPolicyChange.self,
])
// On-disk storage lives in the App Group container so the share
// extension (and any other sibling process) writes into the same store
@@ -140,10 +144,9 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
case .localOnly, .cloudKit: .identifier(appGroupIdentifier)
}
// CloudKit mode backs the container with `NSPersistentCloudKitContainer`,
- // which enables persistent-history tracking and posts
- // `.NSPersistentStoreRemoteChange` on remote import — no extra knobs
- // needed (and SwiftData exposes none). `make` observes that notification
- // via `PersistentStoreRemoteChangeSource`.
+ // which enables persistent-history tracking. SwiftData's Core Data store
+ // posts `.NSPersistentStoreRemoteChange` for every write; `make` observes
+ // it and uses the history author to identify imports.
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: storage == .inMemory,
@@ -202,14 +205,20 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
let store = SwiftDataStore(modelContainer: container)
// On-disk stores live in a shared App Group container, so another process
// (the share extension) — or, for CloudKit, a sync from another device —
- // can commit behind our back. Both surface as
- // `.NSPersistentStoreRemoteChange` (persistent-history tracking is on for
- // on-disk stores); forward those into `changes()` so an external write
- // refreshes the UI like a local commit. This is what makes a
- // share-extension add show up live in the running app (debug included),
- // not just on next launch.
+ // can commit behind our back. Core Data posts
+ // `.NSPersistentStoreRemoteChange` for those *and* this process's own
+ // writes, so checkpoint history before installing the observer and
+ // classify later transactions by their author. This is what makes a
+ // share-extension add show up live without making every local GPS save
+ // look external.
if storage.observesRemoteChanges {
- store.startObservingRemoteChanges(PersistentStoreRemoteChangeSource())
+ let classifier = store.historyClassifier
+ let checkpoint = Self.historyCheckpoint(for: classifier)
+ store.startObservingRemoteChanges(
+ PersistentStoreRemoteChangeSource(),
+ classifier: classifier,
+ checkpoint: checkpoint,
+ )
}
return store
}
@@ -228,7 +237,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
) throws -> SwiftDataStore {
let container = try makeContainer(storage: .inMemory)
let store = SwiftDataStore(modelContainer: container)
- store.startObservingRemoteChanges(remoteChangeSource)
+ let classifier = store.historyClassifier
+ store.startObservingRemoteChanges(
+ remoteChangeSource,
+ classifier: classifier,
+ checkpoint: Self.historyCheckpoint(for: classifier),
+ )
return store
}
@@ -253,14 +267,30 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
SDManualDay.self,
SDDismissedIssue.self,
SDTrackedRegion.self,
+ SDRecordingDevice.self,
+ SDRecordingPolicyChange.self,
]
}
private static let logger = WhereLog.root(SwiftDataStoreLog.self)
+ /// Unique to this store instance. Every context created by `perform` stamps
+ /// it into SwiftData history, so a system write notification can be proven
+ /// local instead of inferred from the notification's misleading name.
+ private nonisolated let localTransactionAuthor = "where-\(UUID().uuidString)"
+
+ private nonisolated var historyClassifier: StoreHistoryClassifier {
+ StoreHistoryClassifier(
+ container: modelContainer,
+ localAuthor: localTransactionAuthor,
+ )
+ }
/// Fans "committed data changed" pings to `changes()` subscribers. Fired
/// once per outermost `perform` commit (see `perform`).
private let changeBroadcaster = StoreChangeBroadcaster()
+ /// The remote-import subset of `changeBroadcaster`, used by expensive
+ /// side-effect publishers whose local-write paths already invoke them.
+ private let remoteChangeBroadcaster = StoreChangeBroadcaster()
/// A fresh stream that pings whenever committed data changes (see the
/// `WhereStore` contract). `nonisolated` so a subscriber needn't hop onto
@@ -270,18 +300,21 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
changeBroadcaster.subscribe()
}
- /// Forwards a `StoreRemoteChangeSource`'s remote-import events into the same
- /// `changes()` fan-out a local commit pings. `nonisolated(unsafe)` for the
- /// same reason as the scanner's: assigned once during setup, cancelled in
- /// `deinit`, never accessed concurrently. The task captures only the
- /// `Sendable` broadcaster + source (no `self`), so there's no retain cycle.
+ public nonisolated func remoteChanges() -> AsyncStream {
+ remoteChangeBroadcaster.subscribe()
+ }
+
+ /// Classifies persistent-store write notifications and forwards only
+ /// external transactions to the external side-effect fan-out.
+ /// `nonisolated(unsafe)` because it is assigned once during factory setup,
+ /// cancelled in `deinit`, and never otherwise accessed concurrently.
private nonisolated(unsafe) var remoteChangeTask: Task?
- /// Begin re-pinging `changes()` on every remote import from `source`, so a
- /// CloudKit sync from another device refreshes observers identically to a
- /// local write — one read path for every write origin. `nonisolated` so the
- /// factories can wire it without hopping onto the actor. The forwarding task
- /// retains `source`, so the caller needn't.
+ /// Begin classifying store-write events. The initial classification closes
+ /// the checkpoint-to-observer race: the checkpoint is taken first, the
+ /// source begins observing second, and this catch-up fetch sees any write
+ /// that landed in between. A queued notification for that same write then
+ /// finds no newer transaction and is harmless.
///
/// `private` and wired exactly once per store from a factory — `make`
/// (any on-disk store) or `inMemory(remoteChangeSource:)` (tests) — so
@@ -289,16 +322,80 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
/// unsynchronized, `nonisolated(unsafe)` `remoteChangeTask` sound without a
/// re-arm/cancel dance: it's assigned once before the store is shared and
/// only read again in `deinit`.
- private nonisolated func startObservingRemoteChanges(_ source: any StoreRemoteChangeSource) {
- remoteChangeTask = Task { [changeBroadcaster] in
- for await _ in source.remoteChanges {
- changeBroadcaster.send()
+ private nonisolated func startObservingRemoteChanges(
+ _ source: any StoreRemoteChangeSource,
+ classifier: StoreHistoryClassifier,
+ checkpoint: DefaultHistoryToken?,
+ ) {
+ remoteChangeTask = Task { [changeBroadcaster, remoteChangeBroadcaster] in
+ var token = Self.forwardStoreChange(
+ .persistentStoreWrite,
+ classifier: classifier,
+ after: checkpoint,
+ changeBroadcaster: changeBroadcaster,
+ remoteChangeBroadcaster: remoteChangeBroadcaster,
+ )
+ for await event in source.remoteChanges {
+ guard Task.isCancelled == false else { return }
+ token = Self.forwardStoreChange(
+ event,
+ classifier: classifier,
+ after: token,
+ changeBroadcaster: changeBroadcaster,
+ remoteChangeBroadcaster: remoteChangeBroadcaster,
+ )
}
}
}
+ private static func historyCheckpoint(
+ for classifier: StoreHistoryClassifier,
+ ) -> DefaultHistoryToken? {
+ do {
+ return try classifier.checkpoint()
+ } catch {
+ logger { .historyReadFailed(description: String(describing: error)) }
+ return nil
+ }
+ }
+
+ /// Returns the checkpoint to use for the next notification.
+ private static func forwardStoreChange(
+ _ event: StoreRemoteChangeEvent,
+ classifier: StoreHistoryClassifier,
+ after token: DefaultHistoryToken?,
+ changeBroadcaster: StoreChangeBroadcaster,
+ remoteChangeBroadcaster: StoreChangeBroadcaster,
+ ) -> DefaultHistoryToken? {
+ switch event {
+ case .external:
+ changeBroadcaster.send()
+ remoteChangeBroadcaster.send()
+ return token
+ case .persistentStoreWrite:
+ do {
+ let classification = try classifier.classify(after: token)
+ if classification.containsExternalTransaction {
+ changeBroadcaster.send()
+ remoteChangeBroadcaster.send()
+ }
+ return classification.latestToken
+ } catch {
+ // A failed history read can't prove the event local. Refresh
+ // conservatively so UI/surfaces remain honest, then try to
+ // recover at the newest token for the next notification.
+ logger { .historyReadFailed(description: String(describing: error)) }
+ changeBroadcaster.send()
+ remoteChangeBroadcaster.send()
+ return (try? classifier.checkpoint()) ?? token
+ }
+ }
+ }
+
deinit {
remoteChangeTask?.cancel()
+ changeBroadcaster.finishAll()
+ remoteChangeBroadcaster.finishAll()
}
/// Peer `ModelContext` active for the duration of an outermost
@@ -364,6 +461,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
// actor reentrancy.
await beginExclusive()
let peer = ModelContext(modelContainer)
+ peer.author = localTransactionAuthor
writerContext = peer
defer {
writerContext = nil
@@ -469,6 +567,112 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
}
}
+ // MARK: - Recording devices
+
+ public func recordingDevices() async throws -> [RecordingDevice] {
+ let context = readContext()
+ var descriptor = FetchDescriptor(
+ sortBy: [SortDescriptor(\.lastSeenAt, order: .reverse)],
+ )
+ descriptor.includePendingChanges = true
+ let values = try context.fetch(descriptor).compactMap { record in
+ let value = record.toValue()
+ if value == nil { Self.logFault(forCorrupt: record) }
+ return value
+ }
+ // CloudKit cannot enforce uniqueness. Converge duplicate rows by taking
+ // the most recently seen profile for each stable installation id.
+ return Dictionary(grouping: values, by: \.id)
+ .compactMap { _, duplicates in
+ duplicates.max { $0.lastSeenAt < $1.lastSeenAt }
+ }
+ .sorted {
+ if $0.lastSeenAt != $1.lastSeenAt { return $0.lastSeenAt > $1.lastSeenAt }
+ return $0.id.storeURL.absoluteString < $1.id.storeURL.absoluteString
+ }
+ }
+
+ public func setRecordingDevice(_ device: RecordingDevice) async throws {
+ let context = mutationContext()
+ let id = device.id.rawValue
+ let existing = try context.fetch(
+ FetchDescriptor(predicate: #Predicate { $0.id == id }),
+ )
+ if let first = existing.first {
+ first.update(from: device)
+ for duplicate in existing.dropFirst() {
+ context.delete(duplicate)
+ }
+ } else {
+ context.insert(SDRecordingDevice(value: device))
+ }
+ }
+
+ public func updateRecordingDevice(
+ _ id: RecordingDeviceID,
+ transform: @Sendable (RecordingDevice) -> RecordingDevice,
+ ) async throws -> RecordingDevice? {
+ let context = mutationContext()
+ let rawID = id.rawValue
+ let records = try context.fetch(
+ FetchDescriptor(predicate: #Predicate { $0.id == rawID }),
+ )
+ let candidates = records.compactMap { record -> (SDRecordingDevice, RecordingDevice)? in
+ guard let value = record.toValue() else {
+ Self.logFault(forCorrupt: record)
+ return nil
+ }
+ return (record, value)
+ }
+ guard let selected = candidates.max(by: {
+ $0.1.lastSeenAt < $1.1.lastSeenAt
+ }) else { return nil }
+
+ let updated = transform(selected.1)
+ precondition(updated.id == id, "A recording-device update cannot change its identity.")
+ selected.0.update(from: updated)
+ for duplicate in records where duplicate !== selected.0 {
+ context.delete(duplicate)
+ }
+ return updated
+ }
+
+ public func recordingPolicyChanges() async throws -> [RecordingPolicyChange] {
+ let context = readContext()
+ var descriptor = FetchDescriptor(
+ sortBy: [
+ SortDescriptor(\.effectiveAt),
+ SortDescriptor(\.id),
+ ],
+ )
+ descriptor.includePendingChanges = true
+ let values = try context.fetch(descriptor).compactMap { record in
+ let value = record.toValue()
+ if value == nil { Self.logFault(forCorrupt: record) }
+ return value
+ }
+ // Keep one value per event id if CloudKit delivers duplicate rows.
+ return Dictionary(grouping: values, by: \.id)
+ .compactMap { _, duplicates in duplicates.first }
+ .sorted(by: RecordingPolicyChange.isOrderedBefore)
+ }
+
+ public func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws {
+ let context = mutationContext()
+ let id = change.id
+ let existing = try context.fetch(
+ FetchDescriptor(predicate: #Predicate { $0.id == id }),
+ )
+ if let first = existing.first {
+ first.update(from: change)
+ for duplicate in existing.dropFirst() {
+ context.delete(duplicate)
+ }
+ } else {
+ context.insert(SDRecordingPolicyChange(value: change))
+ }
+ }
+
public func write(evidence: Evidence, blob: Data?) async throws {
let context = mutationContext()
let id = evidence.id
@@ -691,6 +895,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
for tracked in try context.fetch(FetchDescriptor()) {
context.delete(tracked)
}
+ for device in try context.fetch(FetchDescriptor()) {
+ context.delete(device)
+ }
+ for policy in try context.fetch(FetchDescriptor()) {
+ context.delete(policy)
+ }
}
public func dismissedIssueIDs() async throws -> Set {
@@ -905,6 +1115,9 @@ final class SDLocationSample {
/// `.other` label is not preserved here (fetch the `Evidence` row
/// for that).
var evidenceKindRaw: String?
+ /// Installation that produced an automatic sample. Nil on legacy rows and
+ /// manual/evidence-implied samples.
+ var recordingDeviceID: UUID?
init() {}
@@ -922,6 +1135,7 @@ final class SDLocationSample {
sourceRaw = value.source.discriminator
evidenceId = value.source.evidenceId
evidenceKindRaw = value.source.evidenceKind?.discriminator
+ recordingDeviceID = value.recordingDeviceID?.rawValue
}
func toValue() -> LocationSample? {
@@ -939,6 +1153,7 @@ final class SDLocationSample {
coordinate: Coordinate(latitude: latitude, longitude: longitude),
horizontalAccuracy: horizontalAccuracy,
source: source,
+ recordingDeviceID: recordingDeviceID.map(RecordingDeviceID.init(rawValue:)),
)
}
}
@@ -1156,3 +1371,94 @@ final class SDTrackedRegion {
orderIndex = order
}
}
+
+/// One synced installation profile. Every field is optional because CloudKit
+/// may materialize a partial row before all fields arrive.
+@Model
+final class SDRecordingDevice {
+ var id: UUID?
+ var systemName: String?
+ var nickname: String?
+ var kindRaw: String?
+ var registeredAt: Date?
+ var lastSeenAt: Date?
+ var archivedAt: Date?
+ var lastAppliedPolicyChangeID: UUID?
+ var statusRaw: String?
+
+ init() {}
+
+ convenience init(value: RecordingDevice) {
+ self.init()
+ update(from: value)
+ }
+
+ func update(from value: RecordingDevice) {
+ id = value.id.rawValue
+ systemName = value.systemName
+ nickname = value.nickname
+ kindRaw = value.kind.rawValue
+ registeredAt = value.registeredAt
+ lastSeenAt = value.lastSeenAt
+ archivedAt = value.archivedAt
+ lastAppliedPolicyChangeID = value.lastAppliedPolicyChangeID
+ statusRaw = value.status.rawValue
+ }
+
+ func toValue() -> RecordingDevice? {
+ guard let id,
+ let systemName,
+ let kindRaw,
+ let kind = RecordingDeviceKind(rawValue: kindRaw),
+ let registeredAt,
+ let lastSeenAt,
+ let statusRaw,
+ let status = RecordingDeviceStatus(rawValue: statusRaw)
+ else { return nil }
+ return RecordingDevice(
+ id: RecordingDeviceID(rawValue: id),
+ systemName: systemName,
+ nickname: nickname,
+ kind: kind,
+ registeredAt: registeredAt,
+ lastSeenAt: lastSeenAt,
+ archivedAt: archivedAt,
+ lastAppliedPolicyChangeID: lastAppliedPolicyChangeID,
+ status: status,
+ )
+ }
+}
+
+/// Append-only desired recording state. Optional columns keep the CloudKit
+/// schema additive and tolerant of partially synced rows.
+@Model
+final class SDRecordingPolicyChange {
+ var id: UUID?
+ var deviceID: UUID?
+ var effectiveAt: Date?
+ var isEnabled: Bool?
+
+ init() {}
+
+ convenience init(value: RecordingPolicyChange) {
+ self.init()
+ update(from: value)
+ }
+
+ func update(from value: RecordingPolicyChange) {
+ id = value.id
+ deviceID = value.deviceID.rawValue
+ effectiveAt = value.effectiveAt
+ isEnabled = value.isEnabled
+ }
+
+ func toValue() -> RecordingPolicyChange? {
+ guard let id, let deviceID, let effectiveAt, let isEnabled else { return nil }
+ return RecordingPolicyChange(
+ id: id,
+ deviceID: RecordingDeviceID(rawValue: deviceID),
+ effectiveAt: effectiveAt,
+ isEnabled: isEnabled,
+ )
+ }
+}
diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift
index f7f1300c1..0e2ca975f 100644
--- a/Where/WhereCore/Sources/Persistence/WhereStore.swift
+++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift
@@ -8,9 +8,10 @@ import RegionKit
/// All methods are `async throws` so the production CloudKit-backed
/// implementation has somewhere to surface I/O errors.
///
-/// All mutating methods (`add(sample:)`, `write(evidence:blob:)`,
-/// `setManualDay`, `clearManualDay`, `clear(in:)`, and the
-/// `EvidenceBlobStore` writers)
+/// All mutating methods (`add(sample:)`, `setRecordingDevice`,
+/// `updateRecordingDevice`,
+/// `addRecordingPolicyChange`, `write(evidence:blob:)`, `setManualDay`,
+/// `clearManualDay`, `clear(in:)`, and the `EvidenceBlobStore` writers)
/// MUST be called from inside a `perform { ... }` block — the block
/// boundary is what owns the underlying write transaction. The
/// production `SwiftDataStore` implementation traps with a
@@ -40,10 +41,44 @@ public protocol WhereStore: Sendable {
/// import, so a consumer that re-derives on each ping can't go stale.
func changes() -> AsyncStream
+ /// A fresh stream containing only changes imported from outside this
+ /// process (CloudKit or another App Group process).
+ ///
+ /// This is a narrow side-effect trigger, not a second read path:
+ /// `changes()` remains the signal for readers, while expensive publishers
+ /// use this stream when local writes already invoke them directly and
+ /// putting every hot GPS commit through a full rebuild would duplicate work.
+ func remoteChanges() -> AsyncStream
+
func add(sample: LocationSample) async throws
func samples(in interval: DateInterval) async throws -> [LocationSample]
func allSamples() async throws -> [LocationSample]
+ /// Every synced device profile, including archived devices. Callers decide
+ /// whether archived rows belong in their surface.
+ func recordingDevices() async throws -> [RecordingDevice]
+
+ /// Upsert one synced device profile by ``RecordingDevice/id``. Must run
+ /// inside `perform { ... }`.
+ func setRecordingDevice(_ device: RecordingDevice) async throws
+
+ /// Transform the latest stored value for one device inside the current
+ /// transaction, returning the value that was written. Unlike a read followed
+ /// by ``setRecordingDevice(_:)``, this preserves fields another process or
+ /// CloudKit import changed before the transaction began. A missing device is
+ /// a no-op and returns `nil`.
+ @discardableResult
+ func updateRecordingDevice(
+ _ id: RecordingDeviceID,
+ transform: @Sendable (RecordingDevice) -> RecordingDevice,
+ ) async throws -> RecordingDevice?
+
+ /// Every append-only recording-policy event, oldest first.
+ func recordingPolicyChanges() async throws -> [RecordingPolicyChange]
+
+ /// Add or update one policy event by id. Must run inside `perform { ... }`.
+ func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws
+
func write(evidence: Evidence, blob: Data?) async throws
func evidence(in interval: DateInterval) async throws -> [Evidence]
/// Every evidence record in the store, regardless of `capturedAt`. Used
@@ -128,6 +163,11 @@ public protocol WhereStore: Sendable {
}
extension WhereStore {
+ /// Stores without an external writer return an already-finished stream.
+ public func remoteChanges() -> AsyncStream {
+ AsyncStream { $0.finish() }
+ }
+
/// Regions tracked out of the box, until the user chooses their own. The
/// "no rows yet" fallback for ``trackedRegions()`` and the historical
/// California / New York / Canada / European Union set.
diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift
index ddaf769b4..03e6a4959 100644
--- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift
+++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift
@@ -36,6 +36,17 @@ public final class WherePreferences {
set { store.set(newValue, forKey: Keys.wantsTracking.rawValue) }
}
+ /// Resolve the local recording intent before this installation has written
+ /// one. Existing onboarded installations retain the historical `true`
+ /// default, while a genuinely new installation can adopt a platform policy
+ /// such as iPad's opt-in default.
+ public func wantsTracking(defaultForNewInstallation defaultValue: Bool) -> Bool {
+ if let stored = store.object(forKey: Keys.wantsTracking.rawValue) as? Bool {
+ return stored
+ }
+ return hasOnboarded ? true : defaultValue
+ }
+
/// Whether the daily "log before the day ends" reminder is enabled. Defaults
/// to `true` so the safety net is active out of the box.
public var remindersEnabled: Bool {
diff --git a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift
index 579a40f32..b5e52b156 100644
--- a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift
+++ b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift
@@ -104,6 +104,9 @@ public actor RecentActivitySummarizer {
private let calendar: Calendar
private let now: @Sendable () -> Date
private let segmentLimit: Int
+ private var history: LocationHistoryReader {
+ LocationHistoryReader(store: store)
+ }
private static let logger = WhereLog.recentActivity(RecentActivitySummarizerLog.self)
@@ -130,7 +133,7 @@ public actor RecentActivitySummarizer {
/// model, or a generation error.
public func summary(for window: RecentActivityWindow) async throws -> RecentActivitySummary {
let interval = window.interval(now: now(), calendar: calendar)
- let samples = try await store.samples(in: interval)
+ let samples = try await history.samples(in: interval)
guard !samples.isEmpty else {
Self.logger { .skippedNoSamples }
return .empty
diff --git a/Where/WhereCore/Sources/RegionAttribution.swift b/Where/WhereCore/Sources/RegionAttribution.swift
index 5f9feea7c..2bd658e33 100644
--- a/Where/WhereCore/Sources/RegionAttribution.swift
+++ b/Where/WhereCore/Sources/RegionAttribution.swift
@@ -18,10 +18,72 @@ final class RegionAttribution: RegionAttributing {
var trackedIDs: Set
}
+ /// Serializes every explicit and observed reconciliation. The widget's
+ /// remote-change path also reconciles before it publishes, so keeping the
+ /// read/rebuild/install sequence on one actor prevents an older rebuild
+ /// from landing after a newer tracked-region change.
+ private actor Reconciler {
+ private let store: any WhereStore
+ private let state: OSAllocatedUnfairLock
+ private var isReconciling = false
+ private var waiters: [CheckedContinuation] = []
+
+ init(store: any WhereStore, state: OSAllocatedUnfairLock) {
+ self.store = store
+ self.state = state
+ }
+
+ func reconcile() async {
+ await beginExclusive()
+ defer { endExclusive() }
+
+ let tracked: Set
+ do {
+ tracked = try await store.trackedRegions()
+ } catch {
+ // Degraded-but-handled: keep the last-good attributor rather
+ // than replacing it with an empty or partially read set.
+ RegionAttribution.logger {
+ .trackedRegionsReadFailed(description: String(describing: error))
+ }
+ return
+ }
+ let ids = Set(tracked.map(\.rawValue))
+ let changed = state.withLock { $0.trackedIDs != ids }
+ guard changed else { return }
+ // Canonical order so the rebuilt attributor's first-match priority
+ // is deterministic (see WhereServices.make).
+ let rebuilt = RegionAttribution.logger.measure(.rebuild, budget: .seconds(1)) {
+ RegionAttributor(for: Region.inCanonicalOrder(tracked))
+ }
+ state.withLock { $0 = State(attributor: rebuilt, trackedIDs: ids) }
+ }
+
+ /// Hold the reconciliation slot across the async store read and the
+ /// synchronous rebuild/install. Actor isolation alone is insufficient:
+ /// another call can otherwise enter while `trackedRegions()` suspends
+ /// and let an older read install after a newer one.
+ private func beginExclusive() async {
+ if isReconciling {
+ await withCheckedContinuation { waiters.append($0) }
+ } else {
+ isReconciling = true
+ }
+ }
+
+ private func endExclusive() {
+ if waiters.isEmpty {
+ isReconciling = false
+ } else {
+ waiters.removeFirst().resume()
+ }
+ }
+ }
+
private static let logger = WhereLog.root(RegionAttributionLog.self)
- private let store: any WhereStore
private let state: OSAllocatedUnfairLock
+ private let reconciler: Reconciler
/// Set once in `init` and only cancelled in `deinit`, so there's no
/// concurrent access to guard.
private nonisolated(unsafe) var observer: Task?
@@ -33,11 +95,12 @@ final class RegionAttribution: RegionAttributing {
/// store, so there's no flash of the wrong set at launch).
/// - trackedIDs: the region ids `initial` was built from.
init(store: any WhereStore, initial: RegionAttributor, trackedIDs: Set) {
- self.store = store
- state = OSAllocatedUnfairLock(initialState: State(
+ let state = OSAllocatedUnfairLock(initialState: State(
attributor: initial,
trackedIDs: trackedIDs,
))
+ self.state = state
+ reconciler = Reconciler(store: store, state: state)
observer = Task { [weak self] in
for await _ in store.changes() {
await self?.reconcile()
@@ -67,28 +130,9 @@ final class RegionAttribution: RegionAttributing {
/// Re-read the tracked regions and rebuild the attributor when the set
/// changed. Cheap when nothing changed (a fetch + a set compare); the file
- /// parse runs only on an actual change. Serialized by the single observer
- /// task; also exposed so callers/tests can reconcile deterministically.
+ /// parse runs only on an actual change. The reconciler actor serializes the
+ /// ordinary observer with explicit callers such as external publishing.
func reconcile() async {
- let tracked: Set
- do {
- tracked = try await store.trackedRegions()
- } catch {
- // Degraded-but-handled: keep the last-good attributor rather than
- // silently freezing on an empty/stale set, and surface the failure so
- // a persistent read error is observable instead of invisible.
- Self.logger { .trackedRegionsReadFailed(description: String(describing: error)) }
- return
- }
- let ids = Set(tracked.map(\.rawValue))
- let changed = state.withLock { $0.trackedIDs != ids }
- guard changed else { return }
- // Canonical order so the rebuilt attributor's first-match priority is
- // deterministic (see WhereServices.make). Re-parsing every tracked
- // region's GeoJSON is the expensive part, hence the span.
- let rebuilt = Self.logger.measure(.rebuild, budget: .seconds(1)) {
- RegionAttributor(for: Region.inCanonicalOrder(tracked))
- }
- state.withLock { $0 = State(attributor: rebuilt, trackedIDs: ids) }
+ await reconciler.reconcile()
}
}
diff --git a/Where/WhereCore/Sources/Reporting/ReportReader.swift b/Where/WhereCore/Sources/Reporting/ReportReader.swift
index 151fd5cb0..01c071be1 100644
--- a/Where/WhereCore/Sources/Reporting/ReportReader.swift
+++ b/Where/WhereCore/Sources/Reporting/ReportReader.swift
@@ -14,6 +14,9 @@ public struct ReportReader: Sendable {
let store: any WhereStore
let aggregator: DayAggregator
let attributor: any RegionAttributing
+ private var history: LocationHistoryReader {
+ LocationHistoryReader(store: store)
+ }
/// The half-open date interval covering `year` in the aggregator's calendar.
func yearInterval(year: Int) -> DateInterval {
@@ -34,7 +37,7 @@ public struct ReportReader: Sendable {
public func yearReport(for year: Int) async throws -> YearReport {
try await Self.logger.measure(.yearReport, budget: .seconds(1)) {
let interval = aggregator.yearInterval(year: year)
- let samples = try await store.samples(in: interval)
+ let samples = try await history.samples(in: interval)
let manuals = try await store.manualDays(in: dayRange(for: year))
return aggregator.report(
for: year,
@@ -53,7 +56,7 @@ public struct ReportReader: Sendable {
/// raw); the `DaySamples` grouping is itself deferred until a detector asks.
public func dataIssueReads(for year: Int) async throws -> DataIssueReads {
try await Self.logger.measure(.dataIssueReads, budget: .seconds(2)) {
- let samples = try await store.samples(in: aggregator.yearInterval(year: year))
+ let samples = try await history.samples(in: aggregator.yearInterval(year: year))
let manuals = try await store.manualDays(in: dayRange(for: year))
let report = aggregator.report(
for: year,
@@ -92,7 +95,7 @@ public struct ReportReader: Sendable {
public func locations(in region: Region, year: Int) async throws -> [RegionDayLocations] {
try await Self.logger.measure(.regionLocations, budget: .seconds(1)) {
let interval = aggregator.yearInterval(year: year)
- let samples = try await store.samples(in: interval)
+ let samples = try await history.samples(in: interval)
return aggregator.locations(in: region, samples: samples, attributor: attributor)
}
}
@@ -108,7 +111,7 @@ public struct ReportReader: Sendable {
guard let end = aggregator.calendar.date(byAdding: .day, value: 1, to: start) else {
return [:]
}
- let samples = try await store.samples(in: DateInterval(start: start, end: end))
+ let samples = try await history.samples(in: DateInterval(start: start, end: end))
return aggregator.pointsByRegion(onDay: day, samples: samples, attributor: attributor)
}
}
@@ -119,7 +122,7 @@ public struct ReportReader: Sendable {
public func representativeCoordinates(for year: Int) async throws -> [Region: Coordinate] {
try await Self.logger.measure(.representativeCoordinates, budget: .seconds(1)) {
let interval = aggregator.yearInterval(year: year)
- let samples = try await store.samples(in: interval)
+ let samples = try await history.samples(in: interval)
return aggregator.representativeCoordinates(samples: samples, attributor: attributor)
}
}
diff --git a/Where/WhereCore/Sources/WhereServices+Intents.swift b/Where/WhereCore/Sources/WhereServices+Intents.swift
index 144b64dd5..236bcdc0d 100644
--- a/Where/WhereCore/Sources/WhereServices+Intents.swift
+++ b/Where/WhereCore/Sources/WhereServices+Intents.swift
@@ -3,9 +3,10 @@ import Foundation
extension WhereServices {
/// Assemble the App Intents stack (Siri, Spotlight, Shortcuts — executing
/// in the app's own process) over the **same store, live attribution,
- /// aggregation calendar, and clock `base` already holds** — only the
- /// location source differs (``IdleLocationSource``, so resolving an
- /// intent never starts GPS).
+ /// aggregation calendar, and clock `base` already holds**. Its location
+ /// source is ``IdleLocationSource`` and its recording participation is
+ /// management-only, so resolving an intent never starts GPS or registers a
+ /// second local device.
///
/// This is the *only* way an intents stack is built, and it is
/// deliberately synchronous and non-throwing: deriving from an assembled
@@ -21,17 +22,21 @@ extension WhereServices {
/// The notification and widget seams come from `base` for the same reason
/// the attributor does: a stack derived from the demo world is built out of
/// no-ops, and minting real ones here would let a demo intent post a real
- /// notification or reload the user's widgets.
+ /// notification or reload the user's widgets. The derived stack does not
+ /// start another external-change observer; `base` owns the single observer
+ /// that republishes for their shared store and refresher.
public static func forIntents(sharingStoreOf base: WhereServices) -> WhereServices {
WhereServices(
store: base.store,
locationSource: IdleLocationSource(),
+ recordingParticipation: .managementOnly,
attributor: base.attributor,
aggregator: base.aggregator,
reminderScheduler: base.reminderScheduler,
summaryScheduler: base.summaryScheduler,
issueAlertScheduler: base.issueAlertScheduler,
widgetRefresher: base.widgetRefresher,
+ sharedWidgetPublisher: base.widgets,
now: base.now,
)
}
@@ -49,6 +54,7 @@ extension WhereServices {
try await make(
store: store,
locationSource: IdleLocationSource(),
+ recordingParticipation: .managementOnly,
reminderScheduler: NoopLoggingReminderScheduler(),
summaryScheduler: NoopDailySummaryScheduler(),
issueAlertScheduler: NoopDataIssueAlertScheduler(),
diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift
index f1d5de62b..6fa4f606b 100644
--- a/Where/WhereCore/Sources/WhereServices.swift
+++ b/Where/WhereCore/Sources/WhereServices.swift
@@ -30,6 +30,9 @@ public struct WhereServices: Sendable {
public let issueAlerts: DataIssueAlertReconciler
/// Live GPS ingestion: monitoring, retry queue, authorization.
public let ingestor: LocationIngestor
+ /// Synced per-device recording intent and the current installation's
+ /// serialized physical start/stop reconciliation.
+ public let recording: DeviceRecordingController
/// User-sourced writes: manual days, backfills, clears, evidence.
public let journal: DayJournal
/// Backup export / import.
@@ -66,6 +69,10 @@ public struct WhereServices: Sendable {
/// The clock the stack was built with, retained so a derived stack can't
/// diverge from an injected test/preview clock.
let now: @Sendable () -> Date
+ /// Whether this process contributes automatic locations locally. Retained
+ /// so derived stacks preserve the same capability and new-install policy
+ /// without re-detecting the host platform.
+ let recordingParticipation: RecordingParticipation
/// The live SwiftData container when the backing store is the production
/// `SwiftDataStore`; `nil` for non-SwiftData stores (e.g. test fakes).
/// Surfaced only for read-only debug tooling (the SwiftData inspector) so
@@ -88,12 +95,17 @@ public struct WhereServices: Sendable {
public init(
store: any WhereStore,
locationSource: any LocationSource,
+ recordingParticipation: RecordingParticipation = .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: true,
+ ),
attributor: any RegionAttributing = RegionAttributor.shared,
aggregator: DayAggregator = DayAggregator(),
reminderScheduler: any LoggingReminderScheduling = NoopLoggingReminderScheduler(),
summaryScheduler: any DailySummaryScheduling = NoopDailySummaryScheduler(),
issueAlertScheduler: any DataIssueAlertScheduling = NoopDataIssueAlertScheduler(),
widgetRefresher: any WidgetTimelineRefreshing = NoopWidgetTimelineRefresher(),
+ sharedWidgetPublisher: WidgetSnapshotPublisher? = nil,
locationOutbox: any LocationOutbox = NoOpLocationOutbox(),
activitySummaryGenerator: any ActivitySummaryGenerating = FoundationModelSummaryGenerator(),
now: @escaping @Sendable () -> Date = { Date() },
@@ -132,16 +144,16 @@ public struct WhereServices: Sendable {
calendar: aggregator.calendar,
now: now,
)
- // The reader runs in *this* (app) process and shares the store, calendar,
- // and attributor so the published snapshot's day/year line up with
- // everything else reported.
- let widgetReader = WidgetDataReader(
- store: store,
- aggregator: aggregator,
- attributor: attributor,
- )
- let widgets = WidgetSnapshotPublisher(
- widgetReader: widgetReader,
+ // A derived App Intents stack shares the base publisher as well as its
+ // store. That keeps the publisher's hot-path cache coherent when an
+ // intent writes immediately before the base ingestor handles a sample.
+ // Independent test/preview stacks build their own publisher.
+ let widgets = sharedWidgetPublisher ?? WidgetSnapshotPublisher(
+ widgetReader: WidgetDataReader(
+ store: store,
+ aggregator: aggregator,
+ attributor: attributor,
+ ),
widgetRefresher: widgetRefresher,
attributor: attributor,
calendar: aggregator.calendar,
@@ -155,6 +167,7 @@ public struct WhereServices: Sendable {
let ingestor = LocationIngestor(
store: store,
locationSource: locationSource,
+ recordingDeviceID: recordingParticipation.currentDevice?.id,
calendar: aggregator.calendar,
outbox: locationOutbox,
onPersisted: { outcome in
@@ -179,6 +192,12 @@ public struct WhereServices: Sendable {
}
},
)
+ let recording = DeviceRecordingController(
+ store: store,
+ ingestor: ingestor,
+ participation: recordingParticipation,
+ now: now,
+ )
let journal = DayJournal(
store: store,
aggregator: aggregator,
@@ -210,6 +229,7 @@ public struct WhereServices: Sendable {
self.issueAlerts = issueAlerts
self.widgets = widgets
self.ingestor = ingestor
+ self.recording = recording
self.journal = journal
self.backup = backup
self.resolution = resolution
@@ -222,6 +242,7 @@ public struct WhereServices: Sendable {
self.issueAlertScheduler = issueAlertScheduler
self.widgetRefresher = widgetRefresher
self.now = now
+ self.recordingParticipation = recordingParticipation
modelContainer = (store as? SwiftDataStore)?.inspectorContainer
}
@@ -238,6 +259,7 @@ public struct WhereServices: Sendable {
public static func make(
store: any WhereStore,
locationSource: any LocationSource,
+ recordingParticipation: RecordingParticipation,
aggregator: DayAggregator = DayAggregator(),
reminderScheduler: any LoggingReminderScheduling,
summaryScheduler: any DailySummaryScheduling,
@@ -255,9 +277,10 @@ public struct WhereServices: Sendable {
initial: RegionAttributor(for: Region.inCanonicalOrder(tracked)),
trackedIDs: Set(tracked.map(\.rawValue)),
)
- return WhereServices(
+ let services = WhereServices(
store: store,
locationSource: locationSource,
+ recordingParticipation: recordingParticipation,
attributor: attribution,
aggregator: aggregator,
reminderScheduler: reminderScheduler,
@@ -268,6 +291,15 @@ public struct WhereServices: Sendable {
activitySummaryGenerator: activitySummaryGenerator,
now: now,
)
+ // The base app service owns the one external-change observer. Local
+ // commits already publish through the journal/ingestor paths; this
+ // remote-only stream covers CloudKit and share-extension imports
+ // without putting every GPS commit on a second full-rebuild path.
+ await services.widgets.startObservingExternalChanges(
+ store.remoteChanges(),
+ beforePublishing: { await attribution.reconcile() },
+ )
+ return services
}
/// A fresh stream that fires whenever persisted data changes — local commits
@@ -319,8 +351,13 @@ public struct WhereServices: Sendable {
/// on persistence failure so the caller can surface it rather than silently
/// half-erasing.
public func reset() async throws {
- await ingestor.quiesce()
- try await journal.eraseAllData()
+ await recording.quiesce()
+ do {
+ try await journal.eraseAllData()
+ } catch {
+ await recording.resumeAfterFailedReset()
+ throw error
+ }
// `eraseAllData()` commits, which pings `store.changes()` and the
// scanner self-invalidates off it — but that observation is async. Drop
// the cache inline too so it's provably empty by the time `reset()`
diff --git a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift
index 6879639f9..9af71d3ff 100644
--- a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift
+++ b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift
@@ -1,9 +1,10 @@
import Foundation
import RegionKit
+import WhereSurface
/// Everything the Where widgets render, captured as one `Sendable` value:
/// which regions the snapshot's day already counts for, plus the per-region
-/// day totals for the calendar year containing that day.
+/// day totals from January 1 through that day.
///
/// `Codable` because the app process publishes this (after each committed
/// store write) to a small JSON file in the shared App Group container,
@@ -12,12 +13,12 @@ import RegionKit
public struct WidgetSnapshot: Hashable, Sendable, Codable {
/// Start-of-day (in the reader's calendar) this snapshot describes.
public let day: Date
- /// The calendar year containing `day`; the year `totals` covers.
+ /// The calendar year containing `day`; the year `totals` belongs to.
public let year: Int
/// Regions `day` counts for so far. Empty when nothing is logged yet.
public let dayRegions: Set
- /// Day counts per region for `year` (a `YearReport.totals`). A day in
- /// two regions counts once for each.
+ /// Day counts per region from January 1 of `year` through `day`, inclusive.
+ /// A day in two regions counts once for each.
public let totals: [Region: Int]
/// The user's picked appearances for their primary regions, carried across
/// the App Group so the widget process can render each region's chosen
@@ -26,23 +27,33 @@ public struct WidgetSnapshot: Hashable, Sendable, Codable {
/// hasn't customized (and for snapshots written before this field existed) —
/// those fall back to the default look.
public let appearances: [Region: RegionAppearance]
+ /// When the app generated this artifact. Optional only so an artifact
+ /// published by an older app version still decodes.
+ public let generatedAt: Date?
+ /// Presentation-ready data for store-free glance processes. Optional only
+ /// for compatibility with artifacts published before WhereSurface existed.
+ public let surface: WhereSurfaceSnapshot?
public init(
day: Date,
year: Int,
dayRegions: Set,
totals: [Region: Int],
- appearances: [Region: RegionAppearance] = [:],
+ appearances: [Region: RegionAppearance],
+ generatedAt: Date?,
+ surface: WhereSurfaceSnapshot?,
) {
self.day = day
self.year = year
self.dayRegions = dayRegions
self.totals = totals
self.appearances = appearances
+ self.generatedAt = generatedAt
+ self.surface = surface
}
private enum CodingKeys: String, CodingKey {
- case day, year, dayRegions, totals, appearances
+ case day, year, dayRegions, totals, appearances, generatedAt, surface
}
public init(from decoder: any Decoder) throws {
@@ -55,6 +66,8 @@ public struct WidgetSnapshot: Hashable, Sendable, Codable {
// empty map rather than failing (the widget then uses default looks).
appearances = try container
.decodeIfPresent([Region: RegionAppearance].self, forKey: .appearances) ?? [:]
+ generatedAt = try container.decodeIfPresent(Date.self, forKey: .generatedAt)
+ surface = try container.decodeIfPresent(WhereSurfaceSnapshot.self, forKey: .surface)
}
}
@@ -68,6 +81,9 @@ public struct WidgetDataReader: Sendable {
private let store: any WhereStore
private let aggregator: DayAggregator
private let attributor: any RegionAttributing
+ private var history: LocationHistoryReader {
+ LocationHistoryReader(store: store)
+ }
public init(
store: any WhereStore,
@@ -89,7 +105,7 @@ public struct WidgetDataReader: Sendable {
let year = calendarDay.year
let interval = aggregator.yearInterval(year: year)
let dayRange = CalendarDay.yearRange(year)
- let samples = try await store.samples(in: interval)
+ let samples = try await history.samples(in: interval)
let manualDays = try await store.manualDays(in: dayRange)
let report = aggregator.report(
for: year,
@@ -100,16 +116,51 @@ public struct WidgetDataReader: Sendable {
let dayRegions = report.days
.first { $0.day == calendarDay }?
.regions ?? []
+ var totalsToDate: [Region: Int] = [:]
+ for day in report.days where day.day <= calendarDay {
+ for region in day.regions {
+ totalsToDate[region, default: 0] += 1
+ }
+ }
var appearances: [Region: RegionAppearance] = [:]
for primary in try await store.primaryRegions() {
if let appearance = primary.appearance { appearances[primary.region] = appearance }
}
+ let surfaceRegion: (Region) -> WhereSurfaceSnapshot.Region = { region in
+ WhereSurfaceSnapshot.Region(
+ id: region.rawValue,
+ name: region.localizedName,
+ emoji: appearances[region]?.emoji,
+ symbolName: appearances[region]?.symbolName,
+ )
+ }
+ let todayRegions = Region.inCanonicalOrder(dayRegions).map(surfaceRegion)
+ let yearToDate = Region.rankedByDayCount(
+ totalsToDate,
+ days: { $0.value },
+ region: { $0.key },
+ )
+ .prefix(3)
+ .map { total in
+ WhereSurfaceSnapshot.DayCount(
+ region: surfaceRegion(total.key),
+ days: total.value,
+ )
+ }
+ let surface = WhereSurfaceSnapshot(
+ day: startOfDay,
+ todayRegions: todayRegions,
+ year: year,
+ yearToDate: Array(yearToDate),
+ )
return WidgetSnapshot(
day: startOfDay,
year: year,
dayRegions: dayRegions,
- totals: report.totals,
+ totals: totalsToDate,
appearances: appearances,
+ generatedAt: date,
+ surface: surface,
)
}
}
diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift
index 2866ec218..e90334844 100644
--- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift
+++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift
@@ -24,6 +24,13 @@ public actor WidgetSnapshotPublisher {
private let maxAge: TimeInterval
private var lastPublished: PublishedWidgetSnapshot?
+ private var pendingPublishTask: Task?
+ private var publishRequested = false
+ private var retryFailedPublish = false
+ private var externalChangesTask: Task?
+ #if DEBUG
+ private var receivedPublishRequestCount = 0
+ #endif
private struct PublishedWidgetSnapshot {
let snapshot: WidgetSnapshot
@@ -61,7 +68,7 @@ public actor WidgetSnapshotPublisher {
/// than `maxAge`, or nothing published yet (cold launch) all fall through to
/// a full rebuild.
public func refreshIfStale() async {
- if let last = lastPublished {
+ if retryFailedPublish == false, let last = lastPublished {
let today = calendar.startOfDay(for: now())
let isFresh = now().timeIntervalSince(last.publishedAt) < maxAge
if last.snapshot.day == today, isFresh {
@@ -72,15 +79,95 @@ public actor WidgetSnapshotPublisher {
}
/// Recompute today's `WidgetSnapshot` from the store and hand it to the
- /// refresher to publish + reload. Called after every committed mutation that
- /// can change what a widget shows. A failure here is non-fatal: the widget
- /// keeps showing its last published snapshot.
+ /// refresher to publish + reload. Concurrent callers join one task; a call
+ /// arriving while that task is publishing coalesces into one final rebuild
+ /// so the artifact includes the latest committed store state.
+ ///
+ /// A failure here is non-fatal: the widget keeps showing its last published
+ /// snapshot, and the failed result is never cached as fresh.
func publish() async {
+ #if DEBUG
+ receivedPublishRequestCount += 1
+ #endif
+ if let pendingPublishTask {
+ publishRequested = true
+ await pendingPublishTask.value
+ return
+ }
+
+ publishRequested = true
+ let task = Task {
+ await self.drainPublishRequests()
+ }
+ pendingPublishTask = task
+ await task.value
+ }
+
+ /// Rebuild after every store change imported from another process or
+ /// device. `beforePublishing` refreshes any live derived dependencies from
+ /// that same store state before the snapshot reads them. Local writes do not
+ /// enter this stream: their journal/ingestor paths already invoke the exact
+ /// publish operation they need.
+ func startObservingExternalChanges(
+ _ changes: AsyncStream,
+ beforePublishing: @escaping @Sendable () async -> Void,
+ ) {
+ precondition(
+ externalChangesTask == nil,
+ "WidgetSnapshotPublisher external observation started twice",
+ )
+ externalChangesTask = Task { [weak self] in
+ for await _ in changes {
+ guard Task.isCancelled == false else { return }
+ await beforePublishing()
+ guard Task.isCancelled == false else { return }
+ await self?.publish()
+ }
+ }
+ }
+
+ /// Stop the remote-import observer. Scope teardown normally reaches this
+ /// through `deinit`; the explicit pair also makes lifecycle tests and a
+ /// deliberate restart unambiguous.
+ func stopObservingExternalChanges() async {
+ let task = externalChangesTask
+ task?.cancel()
+ await task?.value
+ externalChangesTask = nil
+ }
+
+ deinit {
+ externalChangesTask?.cancel()
+ }
+
+ #if DEBUG
+ /// Number of calls received by this instance. Test-only visibility lets
+ /// a concurrency test establish that every caller joined the in-flight
+ /// task before releasing its controlled sink.
+ @_spi(Testing) public var testingReceivedPublishRequestCount: Int {
+ receivedPublishRequestCount
+ }
+ #endif
+
+ private func drainPublishRequests() async {
+ repeat {
+ publishRequested = false
+ await performPublish()
+ } while publishRequested
+ pendingPublishTask = nil
+ }
+
+ private func performPublish() async {
await Self.logger.measure(.publish, budget: .seconds(2)) {
do {
- let snapshot = try await widgetReader.snapshot(asOf: now())
- await widgetRefresher.publish(snapshot)
- lastPublished = PublishedWidgetSnapshot(snapshot: snapshot, publishedAt: now())
+ let generatedAt = now()
+ let snapshot = try await widgetReader.snapshot(asOf: generatedAt)
+ try await widgetRefresher.publish(snapshot)
+ lastPublished = PublishedWidgetSnapshot(
+ snapshot: snapshot,
+ publishedAt: generatedAt,
+ )
+ retryFailedPublish = false
Self.logger {
.published(
day: dayLogLabel(snapshot.day),
@@ -88,6 +175,7 @@ public actor WidgetSnapshotPublisher {
)
}
} catch {
+ retryFailedPublish = true
Self.logger { .buildFailed(description: error.localizedDescription) }
}
}
@@ -102,7 +190,7 @@ public actor WidgetSnapshotPublisher {
/// add to its own day; a region already present means the day's regions and
/// the year totals are both unchanged.)
func publishAfterIngest(of sample: LocationSample) async {
- if let last = lastPublished {
+ if retryFailedPublish == false, let last = lastPublished {
let day = calendar.startOfDay(for: sample.timestamp)
let region = attributor.region(at: sample.coordinate)
if day == last.snapshot.day, last.snapshot.dayRegions.contains(region) {
diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift
index 5c27d4bc5..499d58dbe 100644
--- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift
+++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift
@@ -1,16 +1,17 @@
import Foundation
import PeriscopeCore
+import WhereSurface
/// Reads and writes the widgets' published `WidgetSnapshot` as a small JSON
-/// file in the App Group container shared by the app and the widget
-/// extension.
+/// file in the App Group container shared by the app and the widget extension.
+/// Every access is coordinated across processes, and writes atomically replace
+/// the authoritative artifact.
///
/// Only the app process writes (after each committed store change, via
/// `WidgetCenterTimelineRefresher`); the widget process only reads. This is
/// deliberately not SwiftData: the payload is one already-aggregated value,
-/// so a plain `Codable` file avoids the widget paying SwiftData container
-/// startup — and keeps the user's real, CloudKit-synced store private to
-/// the app's own sandbox.
+/// so a plain `Codable` file avoids SwiftData container startup in short-lived
+/// processes and keeps the app as the only CloudKit synchronizer.
public struct WidgetSnapshotStore: Sendable {
/// Thrown when the App Group container can't be resolved, which means
/// the running process is missing the
@@ -20,12 +21,6 @@ public struct WidgetSnapshotStore: Sendable {
public init() {}
}
- /// The single App Group identifier every Where process shares (app, widget
- /// extension, share extension). Sourced from `SwiftDataStore` so there's one
- /// canonical value rather than a per-store literal that could drift.
- private static let appGroupIdentifier = SwiftDataStore.appGroupIdentifier
- private static let fileName = "widget-snapshot.json"
-
/// Directory the snapshot file lives in. Exposed via `init` so tests can
/// point at a temp directory; production resolves the App Group via
/// `shared()`.
@@ -39,7 +34,7 @@ public struct WidgetSnapshotStore: Sendable {
/// `AppGroupUnavailableError` when the container can't be resolved.
public static func shared() throws -> WidgetSnapshotStore {
guard let container = FileManager.default.containerURL(
- forSecurityApplicationGroupIdentifier: appGroupIdentifier,
+ forSecurityApplicationGroupIdentifier: WhereSurfaceStore.appGroupIdentifier,
) else {
throw AppGroupUnavailableError()
}
@@ -47,14 +42,14 @@ public struct WidgetSnapshotStore: Sendable {
}
private var fileURL: URL {
- directory.appending(path: Self.fileName)
+ directory.appending(path: WhereSurfaceStore.snapshotFileName)
}
- /// Atomically replace the published snapshot. Atomic so the widget never
- /// reads a half-written file.
+ /// Coordinate and atomically replace the published snapshot so another
+ /// process never reads a half-written file.
public func write(_ snapshot: WidgetSnapshot) throws {
let data = try JSONEncoder().encode(snapshot)
- try data.write(to: fileURL, options: .atomic)
+ try WhereSurfaceFileCoordinator().write(data, to: fileURL)
}
/// The last published snapshot, or `nil` if nothing has been written yet
@@ -69,11 +64,12 @@ public struct WidgetSnapshotStore: Sendable {
/// the bad file — so the signature stays non-throwing for the widget's
/// timeline provider.
public func read() -> WidgetSnapshot? {
- guard let data = try? Data(contentsOf: fileURL) else { return nil }
do {
+ guard let data = try WhereSurfaceFileCoordinator().read(from: fileURL)
+ else { return nil }
return try JSONDecoder().decode(WidgetSnapshot.self, from: data)
} catch {
- Self.logger(attachments: [.error(error, name: "decode-error")]) {
+ Self.logger(attachments: [.error(error, name: "read-error")]) {
.unreadableSnapshot(description: error.localizedDescription)
}
return nil
diff --git a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift
index 6e8542212..721b5cdcb 100644
--- a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift
+++ b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift
@@ -1,4 +1,5 @@
import PeriscopeCore
+import WhereSurface
import WidgetKit
/// Publishes a freshly-computed `WidgetSnapshot` for the widget extension
@@ -9,7 +10,7 @@ import WidgetKit
public protocol WidgetTimelineRefreshing: Sendable {
/// Persist `snapshot` where the widget process can read it, then ask
/// WidgetKit to rebuild every timeline.
- func publish(_ snapshot: WidgetSnapshot) async
+ func publish(_ snapshot: WidgetSnapshot) async throws
}
/// A `WidgetTimelineRefreshing` that does nothing. For SwiftUI previews and
@@ -18,7 +19,7 @@ public protocol WidgetTimelineRefreshing: Sendable {
public struct NoopWidgetTimelineRefresher: WidgetTimelineRefreshing {
public init() {}
- public func publish(_: WidgetSnapshot) async {}
+ public func publish(_: WidgetSnapshot) async throws {}
}
/// Production `WidgetTimelineRefreshing`: writes the snapshot to the shared
@@ -30,13 +31,15 @@ public struct WidgetCenterTimelineRefresher: WidgetTimelineRefreshing {
public init() {}
- public func publish(_ snapshot: WidgetSnapshot) async {
+ public func publish(_ snapshot: WidgetSnapshot) async throws {
do {
try WidgetSnapshotStore.shared().write(snapshot)
Self.logger { .wroteSnapshot }
} catch {
Self.logger { .publishFailed(description: error.localizedDescription) }
+ throw error
}
+ WhereSurfaceChangeNotification.post()
WidgetCenter.shared.reloadAllTimelines()
}
}
diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift
index dbd2132ed..2dfc1f5dc 100644
--- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift
+++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift
@@ -46,9 +46,14 @@ struct BackupCoordinatorTests {
id: .borderDrift(day: CalendarDay(year: 2026, month: 4, day: 1)),
dismissedAt: Date(timeIntervalSince1970: 1_700_000_000),
)
+ private static let recordingDeviceID = RecordingDeviceID(
+ rawValue: UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")!,
+ )
+ private static let recordingPolicyID =
+ UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")!
- /// Seed all four tables (sample, evidence + blob, manual day, dismissed
- /// issue) directly into a store so backup tests don't depend on the journal.
+ /// Seed every persisted domain directly into a store so backup tests don't
+ /// depend on the journal or recording controller.
private static func seed(_ store: SwiftDataStore) async throws {
try await store.perform {
try await store.add(sample: sample(at: "2026-03-15T12:00:00-07:00"))
@@ -59,6 +64,23 @@ struct BackupCoordinatorTests {
regions: [.newYork],
))
try await store.restoreDismissedIssue(dismissal)
+ try await store.setRecordingDevice(RecordingDevice(
+ id: recordingDeviceID,
+ systemName: "iPad",
+ nickname: "Travel iPad",
+ kind: .tablet,
+ registeredAt: dismissal.dismissedAt,
+ lastSeenAt: dismissal.dismissedAt,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: recordingPolicyID,
+ status: .recording,
+ ))
+ try await store.addRecordingPolicyChange(RecordingPolicyChange(
+ id: recordingPolicyID,
+ deviceID: recordingDeviceID,
+ effectiveAt: dismissal.dismissedAt,
+ isEnabled: true,
+ ))
}
}
@@ -76,6 +98,8 @@ struct BackupCoordinatorTests {
#expect(summary.evidenceCount == 1)
#expect(summary.manualDayCount == 1)
#expect(summary.dismissedIssueCount == 1)
+ #expect(summary.recordingDeviceCount == 1)
+ #expect(summary.recordingPolicyChangeCount == 1)
#expect(try await destination.store.allSamples() == source.store.allSamples())
#expect(try await destination.store.allEvidence() == source.store.allEvidence())
@@ -84,6 +108,9 @@ struct BackupCoordinatorTests {
#expect(try await destination.store.allDismissedIssues() == source.store
.allDismissedIssues())
#expect(try await destination.store.allDismissedIssues() == [Self.dismissal])
+ #expect(try await destination.store.recordingDevices() == source.store.recordingDevices())
+ #expect(try await destination.store.recordingPolicyChanges() == source.store
+ .recordingPolicyChanges())
#expect(try await destination.store.evidenceBlob(for: Self.evidence.id) == Self.blob)
// An import that lands new data runs the post-import hook once.
#expect(await destination.onImport.count == 1)
diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift
index a0757de48..3e5ebeace 100644
--- a/Where/WhereCore/Tests/BackupServiceTests.swift
+++ b/Where/WhereCore/Tests/BackupServiceTests.swift
@@ -6,12 +6,15 @@ import WhereCore
struct BackupServiceTests {
private static let calendar = WhereCoreTestSupport.calendar()
- // Whole-second timestamps so the `.iso8601` date strategy (no
- // fractional seconds) round-trips exactly.
- private static let exportDate = Date(timeIntervalSince1970: 1_700_000_000)
+ private static let exportDate = Date(timeIntervalSince1970: 1_700_000_000.123_456)
private static let evidenceWithBlobId =
UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!
private static let evidenceNoBlobId = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!
+ private static let recordingDeviceID = RecordingDeviceID(
+ rawValue: UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!,
+ )
+ private static let recordingPolicyID =
+ UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!
private static func sampleFixtures() -> [LocationSample] {
[
@@ -21,6 +24,7 @@ struct BackupServiceTests {
coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194),
horizontalAccuracy: 5,
source: .gpsVisit,
+ recordingDeviceID: recordingDeviceID,
),
LocationSample(
id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!,
@@ -32,6 +36,33 @@ struct BackupServiceTests {
]
}
+ private static func recordingDeviceFixtures() -> [RecordingDevice] {
+ [
+ RecordingDevice(
+ id: recordingDeviceID,
+ systemName: "iPad",
+ nickname: "Travel iPad",
+ kind: .tablet,
+ registeredAt: exportDate,
+ lastSeenAt: exportDate,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: recordingPolicyID,
+ status: .recording,
+ ),
+ ]
+ }
+
+ private static func recordingPolicyFixtures() -> [RecordingPolicyChange] {
+ [
+ RecordingPolicyChange(
+ id: recordingPolicyID,
+ deviceID: recordingDeviceID,
+ effectiveAt: exportDate,
+ isEnabled: true,
+ ),
+ ]
+ }
+
private static func evidenceFixtures() -> [Evidence] {
[
Evidence(
@@ -84,12 +115,16 @@ struct BackupServiceTests {
let blobs: [UUID: Data] = [Self.evidenceWithBlobId: Data("boarding-pass-pdf".utf8)]
let dismissedIssues = Self.dismissedIssueFixtures()
+ let recordingDevices = Self.recordingDeviceFixtures()
+ let recordingPolicies = Self.recordingPolicyFixtures()
let url = try service.makeArchiveFile(
samples: samples,
evidence: evidence,
manualDays: manualDays,
dismissedIssues: dismissedIssues,
+ recordingDevices: recordingDevices,
+ recordingPolicyChanges: recordingPolicies,
blobs: blobs,
exportedAt: Self.exportDate,
)
@@ -107,11 +142,70 @@ struct BackupServiceTests {
#expect(result.archive.manualDays == manualDays)
// Dismissals round-trip verbatim, id and timestamp.
#expect(result.archive.dismissedIssues == dismissedIssues)
+ #expect(result.archive.recordingDevices == recordingDevices)
+ #expect(result.archive.recordingPolicyChanges == recordingPolicies)
// Only the evidence with bytes gets an asset; the other is metadata-only.
#expect(result.archive.assets.map(\.evidenceId) == [Self.evidenceWithBlobId])
#expect(result.blobs == blobs)
}
+ @Test func rapidPolicyCutoffPreservesSubsecondOrderingAcrossArchiveRoundTrip() throws {
+ let service = BackupService()
+ let enabledAt = Date(timeIntervalSince1970: 1_700_000_000.125)
+ let disabledAt = enabledAt.addingTimeInterval(0.000_001)
+ let visibleSample = try LocationSample(
+ id: #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")),
+ timestamp: enabledAt.addingTimeInterval(0.000_000_5),
+ coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194),
+ horizontalAccuracy: 5,
+ source: .gpsVisit,
+ recordingDeviceID: Self.recordingDeviceID,
+ )
+ let hiddenSample = try LocationSample(
+ id: #require(UUID(uuidString: "22222222-2222-2222-2222-222222222222")),
+ timestamp: disabledAt,
+ coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194),
+ horizontalAccuracy: 5,
+ source: .gpsVisit,
+ recordingDeviceID: Self.recordingDeviceID,
+ )
+ // If these timestamps collapse, UUID tie-breaking selects the earlier
+ // enabled policy and exposes the sample at the disabled cutoff.
+ let policies = try [
+ RecordingPolicyChange(
+ id: #require(UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")),
+ deviceID: Self.recordingDeviceID,
+ effectiveAt: enabledAt,
+ isEnabled: true,
+ ),
+ RecordingPolicyChange(
+ id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000000")),
+ deviceID: Self.recordingDeviceID,
+ effectiveAt: disabledAt,
+ isEnabled: false,
+ ),
+ ]
+ let samples = [visibleSample, hiddenSample]
+ let url = try service.makeArchiveFile(
+ samples: samples,
+ evidence: [],
+ manualDays: [],
+ recordingPolicyChanges: policies,
+ blobs: [:],
+ exportedAt: disabledAt,
+ )
+ defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
+
+ let archive = try service.readArchive(at: url).archive
+
+ #expect(archive.recordingPolicyChanges == policies)
+ #expect(archive.samples == samples)
+ #expect(RecordingPolicyFilter.visibleSamples(
+ archive.samples,
+ policyChanges: archive.recordingPolicyChanges,
+ ).map(\.id) == [visibleSample.id])
+ }
+
@Test func archiveNameIsDateAndTimeStamped() throws {
let service = BackupService()
let url = try service.makeArchiveFile(
@@ -258,6 +352,8 @@ struct BackupServiceTests {
),
PrimaryRegion(region: .newYork, appearance: nil, order: 1),
],
+ recordingDevices: Self.recordingDeviceFixtures(),
+ recordingPolicyChanges: Self.recordingPolicyFixtures(),
assets: [BackupAssetEntry(
evidenceId: Self.evidenceWithBlobId,
filename: "assets/\(Self.evidenceWithBlobId.uuidString)",
@@ -265,15 +361,15 @@ struct BackupServiceTests {
)
let encoder = JSONEncoder()
- encoder.dateEncodingStrategy = .iso8601
+ encoder.dateEncodingStrategy = .secondsSince1970
let data = try encoder.encode(archive)
let decoder = JSONDecoder()
- decoder.dateDecodingStrategy = .iso8601
+ decoder.dateDecodingStrategy = .secondsSince1970
let decoded = try decoder.decode(BackupArchive.self, from: data)
#expect(decoded == archive)
- #expect(decoded.formatVersion == 2)
+ #expect(decoded.formatVersion == BackupArchive.currentFormatVersion)
}
@Test func readingAFileThatIsNotAZipThrows() throws {
diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift
index aad946b65..d50f04abc 100644
--- a/Where/WhereCore/Tests/DayJournalTests.swift
+++ b/Where/WhereCore/Tests/DayJournalTests.swift
@@ -17,7 +17,7 @@ struct DayJournalTests {
private actor SpyRefresher: WidgetTimelineRefreshing {
private(set) var publishCount = 0
- func publish(_: WidgetSnapshot) async {
+ func publish(_: WidgetSnapshot) async throws {
publishCount += 1
}
}
diff --git a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift
new file mode 100644
index 000000000..37fa2d202
--- /dev/null
+++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift
@@ -0,0 +1,273 @@
+import Foundation
+import Testing
+@_spi(Testing) @testable import WhereCore
+
+struct DeviceRecordingControllerTests {
+ private static let now = WhereCoreTestSupport.iso("2026-07-30T12:00:00-07:00")
+
+ private static func makeServices(
+ authorization: LocationAuthorizationStatus,
+ ) throws -> (WhereServices, SwiftDataStore) {
+ let store = try SwiftDataStore.inMemory()
+ let services = WhereServices(
+ store: store,
+ locationSource: ScriptedLocationSource(authorizationStatus: authorization),
+ recordingParticipation: .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: true,
+ ),
+ now: { now },
+ )
+ return (services, store)
+ }
+
+ @Test func firstReconcileRegistersMigratedIntentAndAcknowledgesRecording() async throws {
+ let (services, store) = try Self.makeServices(authorization: .always)
+
+ let configuration = try #require(try await services.recording.reconcile(
+ initialEnabled: true,
+ authorization: .always,
+ ))
+
+ #expect(configuration.id == CurrentRecordingDevice.preview.id)
+ #expect(configuration.isEnabled)
+ #expect(configuration.isPending == false)
+ #expect(configuration.device.status == .recording)
+ #expect(await services.ingestor.isActive)
+ #expect(try await store.recordingDevices().count == 1)
+ #expect(try await store.recordingPolicyChanges().count == 1)
+ }
+
+ @Test func enabledWithoutAlwaysPermissionIsAcknowledgedAsPermissionRequired() async throws {
+ let (services, _) = try Self.makeServices(authorization: .whenInUse)
+
+ let configuration = try #require(try await services.recording.reconcile(
+ initialEnabled: true,
+ authorization: .whenInUse,
+ ))
+
+ #expect(configuration.isEnabled)
+ #expect(configuration.isPending == false)
+ #expect(configuration.device.status == .permissionRequired)
+ #expect(await services.ingestor.isActive == false)
+ }
+
+ @Test func rapidChangesWithTheSameClockValueKeepInvocationOrder() async throws {
+ let (services, _) = try Self.makeServices(authorization: .always)
+ _ = try await services.recording.setEnabled(
+ true,
+ for: CurrentRecordingDevice.preview.id,
+ initialEnabled: false,
+ )
+ let devices = try await services.recording.setEnabled(
+ false,
+ for: CurrentRecordingDevice.preview.id,
+ initialEnabled: false,
+ )
+ let current = try #require(
+ devices.first(where: { $0.id == CurrentRecordingDevice.preview.id }),
+ )
+
+ #expect(current.isEnabled == false)
+ #expect(current.device.status == .off)
+ #expect(current.isPending == false)
+ #expect(await services.ingestor.isActive == false)
+ }
+
+ @Test func remoteDisableIsPendingUntilThatDeviceAcknowledges() async throws {
+ let (services, store) = try Self.makeServices(authorization: .always)
+ _ = try await services.recording.reconcile(
+ initialEnabled: true,
+ authorization: .always,
+ )
+ let remoteID = try RecordingDeviceID(
+ rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")),
+ )
+ let initialPolicyID = try #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"))
+ try await store.perform {
+ try await store.setRecordingDevice(RecordingDevice(
+ id: remoteID,
+ systemName: "iPad",
+ nickname: "Travel iPad",
+ kind: .tablet,
+ registeredAt: Self.now,
+ lastSeenAt: Self.now,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: initialPolicyID,
+ status: .recording,
+ ))
+ try await store.addRecordingPolicyChange(RecordingPolicyChange(
+ id: initialPolicyID,
+ deviceID: remoteID,
+ effectiveAt: Self.now.addingTimeInterval(-60),
+ isEnabled: true,
+ ))
+ }
+
+ let devices = try await services.recording.setEnabled(
+ false,
+ for: remoteID,
+ initialEnabled: true,
+ )
+ let remote = try #require(devices.first(where: { $0.id == remoteID }))
+
+ #expect(remote.isEnabled == false)
+ #expect(remote.isPending)
+ #expect(remote.device.status == .recording)
+ }
+
+ @Test func archivingTurnsRemoteDeviceOffAndHidesItAtomically() async throws {
+ let (services, store) = try Self.makeServices(authorization: .always)
+ _ = try await services.recording.reconcile(
+ initialEnabled: true,
+ authorization: .always,
+ )
+ let remoteID = try RecordingDeviceID(
+ rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")),
+ )
+ try await store.perform {
+ try await store.setRecordingDevice(RecordingDevice(
+ id: remoteID,
+ systemName: "iPad",
+ nickname: nil,
+ kind: .tablet,
+ registeredAt: Self.now,
+ lastSeenAt: Self.now,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: nil,
+ status: .off,
+ ))
+ }
+
+ let visible = try await services.recording.archive(
+ remoteID,
+ initialEnabled: true,
+ )
+
+ #expect(visible.contains(where: { $0.id == remoteID }) == false)
+ let archived = try #require(
+ try await store.recordingDevices().first(where: { $0.id == remoteID }),
+ )
+ #expect(archived.archivedAt == Self.now)
+ let latest = try #require(
+ try await store.recordingPolicyChanges().last(where: { $0.deviceID == remoteID }),
+ )
+ #expect(latest.isEnabled == false)
+ }
+
+ @Test func archivedCurrentDeviceCanSeeItselfAndReenable() async throws {
+ let (services, store) = try Self.makeServices(authorization: .always)
+ let policyID = try #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"))
+ try await store.perform {
+ try await store.setRecordingDevice(RecordingDevice(
+ id: CurrentRecordingDevice.preview.id,
+ systemName: "iPhone",
+ nickname: nil,
+ kind: .phone,
+ registeredAt: Self.now,
+ lastSeenAt: Self.now,
+ archivedAt: Self.now,
+ lastAppliedPolicyChangeID: policyID,
+ status: .off,
+ ))
+ try await store.addRecordingPolicyChange(RecordingPolicyChange(
+ id: policyID,
+ deviceID: CurrentRecordingDevice.preview.id,
+ effectiveAt: Self.now,
+ isEnabled: false,
+ ))
+ }
+
+ let before = try await services.recording.devices(initialEnabled: false)
+ #expect(before.map(\.id) == [CurrentRecordingDevice.preview.id])
+
+ let after = try await services.recording.setEnabled(
+ true,
+ for: CurrentRecordingDevice.preview.id,
+ initialEnabled: false,
+ )
+ let current = try #require(after.first)
+ #expect(current.isEnabled)
+ #expect(current.device.archivedAt == nil)
+ #expect(current.device.status == .recording)
+ }
+
+ @Test func quiescedControllerCannotRecreateRowsAfterReset() async throws {
+ let (services, store) = try Self.makeServices(authorization: .always)
+ _ = try await services.recording.reconcile(
+ initialEnabled: true,
+ authorization: .always,
+ )
+
+ await services.recording.quiesce()
+ try await store.perform { try await store.clearAll() }
+
+ await #expect(throws: CancellationError.self) {
+ _ = try await services.recording.devices(initialEnabled: true)
+ }
+ #expect(try await store.recordingDevices().isEmpty)
+ #expect(try await store.recordingPolicyChanges().isEmpty)
+ }
+
+ @Test func managementOnlyReconcileNeverRegistersOrStartsLocalRecording() async throws {
+ let store = try SwiftDataStore.inMemory()
+ let source = ScriptedLocationSource(authorizationStatus: .always)
+ let services = WhereServices(
+ store: store,
+ locationSource: source,
+ recordingParticipation: .managementOnly,
+ now: { Self.now },
+ )
+
+ let configuration = try await services.recording.reconcile(
+ initialEnabled: true,
+ authorization: .always,
+ )
+ await services.ingestor.start()
+ await services.ingestor.captureTodayIfNeeded(now: Self.now)
+
+ #expect(configuration == nil)
+ #expect(await services.ingestor.isActive == false)
+ #expect(try await store.recordingDevices().isEmpty)
+ #expect(try await store.recordingPolicyChanges().isEmpty)
+ #expect(try await store.allSamples().isEmpty)
+ }
+
+ @Test func managementOnlyControllerCanManageASyncedRemoteDevice() async throws {
+ let store = try SwiftDataStore.inMemory()
+ let services = WhereServices(
+ store: store,
+ locationSource: IdleLocationSource(),
+ recordingParticipation: .managementOnly,
+ now: { Self.now },
+ )
+ let remoteID = try RecordingDeviceID(
+ rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")),
+ )
+ try await store.perform {
+ try await store.setRecordingDevice(RecordingDevice(
+ id: remoteID,
+ systemName: "iPad",
+ nickname: nil,
+ kind: .tablet,
+ registeredAt: Self.now,
+ lastSeenAt: Self.now,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: nil,
+ status: .off,
+ ))
+ }
+
+ let before = try await services.recording.devices(initialEnabled: false)
+ let after = try await services.recording.setEnabled(
+ true,
+ for: remoteID,
+ initialEnabled: false,
+ )
+
+ #expect(before.map(\.id) == [remoteID])
+ #expect(after.map(\.id) == [remoteID])
+ #expect(after.first?.isEnabled == true)
+ #expect(try await store.recordingDevices().count == 1)
+ }
+}
diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift
index cf4afc36b..935abbda5 100644
--- a/Where/WhereCore/Tests/LocationIngestorTests.swift
+++ b/Where/WhereCore/Tests/LocationIngestorTests.swift
@@ -51,6 +51,7 @@ struct LocationIngestorTests {
LocationIngestor(
store: store,
locationSource: source,
+ recordingDeviceID: CurrentRecordingDevice.preview.id,
calendar: WhereCoreTestSupport.calendar(),
outbox: outbox,
retryQueueCapacity: retryQueueCapacity,
@@ -115,7 +116,9 @@ struct LocationIngestorTests {
// wait on it directly rather than on the sample count — a count poll can
// observe the committed row before `onPersisted` records the outcome.
try await waitUntil { await recorder.last?.liveSample != nil }
- #expect(try await store.allSamples().count == 1)
+ let stored = try await store.allSamples()
+ #expect(stored.count == 1)
+ #expect(stored.first?.recordingDeviceID == CurrentRecordingDevice.preview.id)
}
@Test func captureTodaySkipsWhenGPSSampleAlreadyExistsToday() async throws {
@@ -181,6 +184,7 @@ struct LocationIngestorTests {
let ingestor = LocationIngestor(
store: store,
locationSource: source,
+ recordingDeviceID: CurrentRecordingDevice.preview.id,
calendar: WhereCoreTestSupport.calendar(),
onPersisted: { outcome in await recorder.record(outcome) },
)
@@ -532,6 +536,29 @@ private actor ToggleFailingStore: WhereStore {
try await backing.allSamples()
}
+ func recordingDevices() async throws -> [RecordingDevice] {
+ try await backing.recordingDevices()
+ }
+
+ func setRecordingDevice(_ device: RecordingDevice) async throws {
+ try await backing.setRecordingDevice(device)
+ }
+
+ func updateRecordingDevice(
+ _ id: RecordingDeviceID,
+ transform: @Sendable (RecordingDevice) -> RecordingDevice,
+ ) async throws -> RecordingDevice? {
+ try await backing.updateRecordingDevice(id, transform: transform)
+ }
+
+ func recordingPolicyChanges() async throws -> [RecordingPolicyChange] {
+ try await backing.recordingPolicyChanges()
+ }
+
+ func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws {
+ try await backing.addRecordingPolicyChange(change)
+ }
+
func write(evidence: Evidence, blob: Data?) async throws {
try await backing.write(evidence: evidence, blob: blob)
}
diff --git a/Where/WhereCore/Tests/RecordingParticipationTests.swift b/Where/WhereCore/Tests/RecordingParticipationTests.swift
new file mode 100644
index 000000000..01b510ffa
--- /dev/null
+++ b/Where/WhereCore/Tests/RecordingParticipationTests.swift
@@ -0,0 +1,23 @@
+import Testing
+@testable import WhereCore
+
+struct RecordingParticipationTests {
+ @Test func recordingCarriesItsDeviceAndNewInstallationDefault() {
+ let participation = RecordingParticipation.recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: false,
+ )
+
+ #expect(participation.currentDevice == .preview)
+ #expect(participation.defaultEnabledForNewInstallation == false)
+ #expect(participation.supportsLocalRecording)
+ }
+
+ @Test func managementOnlyHasNoLocalRecordingCapability() {
+ let participation = RecordingParticipation.managementOnly
+
+ #expect(participation.currentDevice == nil)
+ #expect(participation.defaultEnabledForNewInstallation == false)
+ #expect(participation.supportsLocalRecording == false)
+ }
+}
diff --git a/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift b/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift
new file mode 100644
index 000000000..a82e76baf
--- /dev/null
+++ b/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift
@@ -0,0 +1,116 @@
+import Foundation
+import RegionKit
+import Testing
+@testable import WhereCore
+
+struct RecordingPolicyFilterTests {
+ private static let deviceID = RecordingDeviceID(
+ rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!,
+ )
+
+ private static func sample(
+ _ timestamp: String,
+ source: SampleSource = .gpsVisit,
+ deviceID: RecordingDeviceID? = Self.deviceID,
+ ) -> LocationSample {
+ LocationSample(
+ timestamp: WhereCoreTestSupport.iso(timestamp),
+ coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194),
+ horizontalAccuracy: 5,
+ source: source,
+ recordingDeviceID: deviceID,
+ )
+ }
+
+ private static func policy(
+ _ timestamp: String,
+ enabled: Bool,
+ id: String,
+ ) -> RecordingPolicyChange {
+ RecordingPolicyChange(
+ id: UUID(uuidString: id)!,
+ deviceID: deviceID,
+ effectiveAt: WhereCoreTestSupport.iso(timestamp),
+ isEnabled: enabled,
+ )
+ }
+
+ @Test func disabledIntervalIsExcludedAndReenabledIntervalReturns() {
+ let before = Self.sample("2026-03-01T08:00:00-08:00")
+ let during = Self.sample("2026-03-02T08:00:00-08:00")
+ let after = Self.sample("2026-03-03T08:00:00-08:00")
+ let policies = [
+ Self.policy(
+ "2026-03-02T00:00:00-08:00",
+ enabled: false,
+ id: "10000000-0000-0000-0000-000000000000",
+ ),
+ Self.policy(
+ "2026-03-03T00:00:00-08:00",
+ enabled: true,
+ id: "20000000-0000-0000-0000-000000000000",
+ ),
+ ]
+
+ let visible = RecordingPolicyFilter.visibleSamples(
+ [before, during, after],
+ policyChanges: policies,
+ )
+
+ #expect(visible.map(\.id) == [before.id, after.id])
+ }
+
+ @Test func cutoffTimestampIsInclusive() {
+ let cutoff = Self.policy(
+ "2026-03-02T08:00:00-08:00",
+ enabled: false,
+ id: "10000000-0000-0000-0000-000000000000",
+ )
+ let sample = Self.sample("2026-03-02T08:00:00-08:00")
+
+ #expect(RecordingPolicyFilter.visibleSamples(
+ [sample],
+ policyChanges: [cutoff],
+ ).isEmpty)
+ }
+
+ @Test func legacyAndUserAssertedSamplesRemainVisible() {
+ let legacy = Self.sample("2026-03-02T08:00:00-08:00", deviceID: nil)
+ let manual = Self.sample(
+ "2026-03-02T09:00:00-08:00",
+ source: .manual,
+ deviceID: Self.deviceID,
+ )
+ let cutoff = Self.policy(
+ "2026-03-01T00:00:00-08:00",
+ enabled: false,
+ id: "10000000-0000-0000-0000-000000000000",
+ )
+
+ let visible = RecordingPolicyFilter.visibleSamples(
+ [legacy, manual],
+ policyChanges: [cutoff],
+ )
+
+ #expect(visible.map(\.id) == [legacy.id, manual.id])
+ }
+
+ @Test func equalTimestampPoliciesConvergeByID() {
+ let disabled = Self.policy(
+ "2026-03-02T00:00:00-08:00",
+ enabled: false,
+ id: "10000000-0000-0000-0000-000000000000",
+ )
+ let enabled = Self.policy(
+ "2026-03-02T00:00:00-08:00",
+ enabled: true,
+ id: "20000000-0000-0000-0000-000000000000",
+ )
+ let sample = Self.sample("2026-03-02T08:00:00-08:00")
+
+ #expect(RecordingPolicyFilter.visibleSamples(
+ [sample],
+ policyChanges: [enabled, disabled],
+ ) == [sample])
+ }
+}
diff --git a/Where/WhereCore/Tests/ReportReaderTests.swift b/Where/WhereCore/Tests/ReportReaderTests.swift
index 6572fd3d4..b0ff71793 100644
--- a/Where/WhereCore/Tests/ReportReaderTests.swift
+++ b/Where/WhereCore/Tests/ReportReaderTests.swift
@@ -41,6 +41,40 @@ struct ReportReaderTests {
#expect(report.totals == [.california: 1, .newYork: 1])
}
+ @Test func yearReportAppliesDeviceRecordingCutoffs() async throws {
+ let (reader, store) = try Self.makeReader()
+ let deviceID = try RecordingDeviceID(
+ rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")),
+ )
+ try await store.perform {
+ try await store.add(sample: LocationSample(
+ timestamp: WhereCoreTestSupport.iso("2026-01-10T12:00:00-08:00"),
+ coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194),
+ horizontalAccuracy: 0,
+ source: .gpsVisit,
+ recordingDeviceID: deviceID,
+ ))
+ try await store.add(sample: LocationSample(
+ timestamp: WhereCoreTestSupport.iso("2026-01-12T12:00:00-08:00"),
+ coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060),
+ horizontalAccuracy: 0,
+ source: .gpsVisit,
+ recordingDeviceID: deviceID,
+ ))
+ try await store.addRecordingPolicyChange(RecordingPolicyChange(
+ id: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!,
+ deviceID: deviceID,
+ effectiveAt: WhereCoreTestSupport.iso("2026-01-11T00:00:00-08:00"),
+ isEnabled: false,
+ ))
+ }
+
+ let report = try await reader.yearReport(for: 2026)
+
+ #expect(report.days.count == 1)
+ #expect(report.totals == [.california: 1])
+ }
+
@Test func manualDaysReturnsOnlyTheRequestedYear() async throws {
let (reader, store) = try Self.makeReader()
try await store.perform {
diff --git a/Where/WhereCore/Tests/StoreHistoryClassifierTests.swift b/Where/WhereCore/Tests/StoreHistoryClassifierTests.swift
new file mode 100644
index 000000000..8a3583317
--- /dev/null
+++ b/Where/WhereCore/Tests/StoreHistoryClassifierTests.swift
@@ -0,0 +1,36 @@
+import SwiftData
+import Testing
+@testable import WhereCore
+
+/// Transaction-author filtering behind the external-only store signal.
+struct StoreHistoryClassifierTests {
+ @Test func excludesLocalAuthorAndIncludesAnotherAuthor() throws {
+ let container = try SwiftDataStore.makeContainer(storage: .inMemory)
+ let localAuthor = "where-tests-local"
+ let classifier = StoreHistoryClassifier(
+ container: container,
+ localAuthor: localAuthor,
+ )
+ let initialToken = try classifier.checkpoint()
+
+ let localContext = ModelContext(container)
+ localContext.author = localAuthor
+ let localDay = SDManualDay()
+ localDay.dayKey = "2026-03-15"
+ localContext.insert(localDay)
+ try localContext.save()
+
+ let local = try classifier.classify(after: initialToken)
+ #expect(local.containsExternalTransaction == false)
+
+ let otherContext = ModelContext(container)
+ otherContext.author = "where-tests-other-process"
+ let otherDay = SDManualDay()
+ otherDay.dayKey = "2026-03-16"
+ otherContext.insert(otherDay)
+ try otherContext.save()
+
+ let external = try classifier.classify(after: local.latestToken)
+ #expect(external.containsExternalTransaction)
+ }
+}
diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift
index 964f54576..8d53d18a6 100644
--- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift
+++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift
@@ -96,21 +96,118 @@ struct SwiftDataStoreTests {
#expect(await !firstPing(stream, within: .milliseconds(200)))
}
- /// A remote import (simulated via a scripted source) re-pings the same
- /// `changes()` fan-out a local commit does, so observers can't tell a sync
- /// from another device apart from a local write — one read path.
- @Test func remoteChangeForwardsToChanges() async throws {
+ @Test func recordingDeviceAndPolicyRoundTripWithoutDuplicateLogicalRows() async throws {
+ let store = try SwiftDataStore.inMemory()
+ let deviceID = try RecordingDeviceID(
+ rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")),
+ )
+ let policyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"))
+ let date = Date(timeIntervalSinceReferenceDate: 100)
+ let device = RecordingDevice(
+ id: deviceID,
+ systemName: "iPad",
+ nickname: "Home iPad",
+ kind: .tablet,
+ registeredAt: date,
+ lastSeenAt: date,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: policyID,
+ status: .off,
+ )
+ let policy = RecordingPolicyChange(
+ id: policyID,
+ deviceID: deviceID,
+ effectiveAt: date,
+ isEnabled: false,
+ )
+
+ try await store.perform {
+ try await store.setRecordingDevice(device)
+ try await store.setRecordingDevice(device)
+ try await store.addRecordingPolicyChange(policy)
+ try await store.addRecordingPolicyChange(policy)
+ }
+
+ #expect(try await store.recordingDevices() == [device])
+ #expect(try await store.recordingPolicyChanges() == [policy])
+ }
+
+ @Test func recordingDeviceTransformStartsFromTheLatestStoredProfile() async throws {
+ let store = try SwiftDataStore.inMemory()
+ let deviceID = try RecordingDeviceID(
+ rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")),
+ )
+ let oldPolicyID = try #require(
+ UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"),
+ )
+ let appliedPolicyID = try #require(
+ UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"),
+ )
+ let registeredAt = Date(timeIntervalSinceReferenceDate: 100)
+ let original = RecordingDevice(
+ id: deviceID,
+ systemName: "iPhone",
+ nickname: nil,
+ kind: .phone,
+ registeredAt: registeredAt,
+ lastSeenAt: registeredAt,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: oldPolicyID,
+ status: .off,
+ )
+ try await store.perform { try await store.setRecordingDevice(original) }
+
+ // Model a CloudKit import that landed after a controller read `original`
+ // but before its acknowledgement transaction began.
+ let archivedAt = registeredAt.addingTimeInterval(30)
+ let imported = RecordingDevice(
+ id: deviceID,
+ systemName: original.systemName,
+ nickname: "Synced nickname",
+ kind: original.kind,
+ registeredAt: original.registeredAt,
+ lastSeenAt: archivedAt,
+ archivedAt: archivedAt,
+ lastAppliedPolicyChangeID: oldPolicyID,
+ status: .off,
+ )
+ let checkedInAt = registeredAt.addingTimeInterval(20)
+ let maybeUpdated = try await store.perform {
+ try await store.setRecordingDevice(imported)
+ return try await store.updateRecordingDevice(deviceID) {
+ $0.acknowledging(
+ policyChangeID: appliedPolicyID,
+ status: .recording,
+ at: max($0.lastSeenAt, checkedInAt),
+ )
+ }
+ }
+ let updated = try #require(maybeUpdated)
+
+ #expect(updated.nickname == imported.nickname)
+ #expect(updated.archivedAt == imported.archivedAt)
+ #expect(updated.lastSeenAt == imported.lastSeenAt)
+ #expect(updated.lastAppliedPolicyChangeID == appliedPolicyID)
+ #expect(updated.status == .recording)
+ #expect(try await store.recordingDevices() == [updated])
+ }
+
+ /// A remote import (simulated via a scripted source) pings both the general
+ /// read-refresh stream and the remote-only side-effect stream.
+ @Test func remoteChangeForwardsToBothChangeStreams() async throws {
let source = ScriptedStoreRemoteChangeSource()
// The remote-change wiring is folded into the factory (there's no
// public `startObservingRemoteChanges` to call), so the store observes
// `source` from construction.
let store = try SwiftDataStore.inMemory(remoteChangeSource: source)
// Subscribe before emitting so the forwarded ping isn't missed.
- let stream = store.changes()
+ let changes = store.changes()
+ let remoteChanges = store.remoteChanges()
source.yield()
- #expect(await firstPing(stream, within: .seconds(2)))
+ #expect(await firstPing(changes, within: .seconds(2)))
+ #expect(await firstPing(remoteChanges, within: .seconds(2)))
}
/// Once `perform`'s `peer.save()` returns, the committed write must be
diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift
new file mode 100644
index 000000000..985082038
--- /dev/null
+++ b/Where/WhereCore/Tests/WherePreferencesTests.swift
@@ -0,0 +1,27 @@
+import Testing
+@testable import WhereCore
+
+struct WherePreferencesTests {
+ @Test func newInstallationUsesTheSuppliedRecordingDefault() {
+ let preferences = WherePreferences(store: InMemoryKeyValueStore())
+
+ #expect(preferences.wantsTracking(defaultForNewInstallation: false) == false)
+ #expect(preferences.wantsTracking(defaultForNewInstallation: true))
+ }
+
+ @Test func onboardedInstallationWithoutAnExplicitValueKeepsLegacyRecordingOn() {
+ let preferences = WherePreferences(store: InMemoryKeyValueStore())
+ preferences.hasOnboarded = true
+
+ #expect(preferences.wantsTracking(defaultForNewInstallation: false))
+ }
+
+ @Test func explicitRecordingIntentWinsOverPlatformAndMigrationDefaults() {
+ let preferences = WherePreferences(store: InMemoryKeyValueStore())
+ preferences.hasOnboarded = true
+ preferences.wantsTracking = false
+
+ #expect(preferences.wantsTracking(defaultForNewInstallation: true) == false)
+ #expect(preferences.wantsTracking(defaultForNewInstallation: false) == false)
+ }
+}
diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift
index fa127e1ed..21758a8d6 100644
--- a/Where/WhereCore/Tests/WhereServicesTests.swift
+++ b/Where/WhereCore/Tests/WhereServicesTests.swift
@@ -1,7 +1,7 @@
import Foundation
import RegionKit
import Testing
-@_spi(Testing) import WhereCore
+@_spi(Testing) @testable import WhereCore
/// Integration coverage for the assembled `WhereServices`: the cross-collaborator
/// wiring that no single focused suite owns — the ingestor's post-persist hook
@@ -57,6 +57,10 @@ struct WhereServicesTests {
let services = try await WhereServices.make(
store: store,
locationSource: ScriptedLocationSource(),
+ recordingParticipation: .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: true,
+ ),
aggregator: Self.makeAggregator(),
reminderScheduler: NoopLoggingReminderScheduler(),
summaryScheduler: NoopDailySummaryScheduler(),
@@ -728,10 +732,29 @@ struct WhereServicesTests {
in: Self.pacificCalendar,
regions: [.california],
)
+ let deviceID = CurrentRecordingDevice.preview.id
+ let policyID = try #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"))
try await store.perform {
try await store.add(sample: seedSample)
try await store.write(evidence: Self.backupEvidence, blob: Self.backupBlob)
try await store.setManualDay(seedDay)
+ try await store.setRecordingDevice(RecordingDevice(
+ id: deviceID,
+ systemName: "iPhone",
+ nickname: nil,
+ kind: .phone,
+ registeredAt: seedSample.timestamp,
+ lastSeenAt: seedSample.timestamp,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: policyID,
+ status: .recording,
+ ))
+ try await store.addRecordingPolicyChange(RecordingPolicyChange(
+ id: policyID,
+ deviceID: deviceID,
+ effectiveAt: seedSample.timestamp,
+ isEnabled: true,
+ ))
}
try await store.perform { try await store.clearAll() }
@@ -739,6 +762,8 @@ struct WhereServicesTests {
#expect(try await store.allSamples().isEmpty)
#expect(try await store.allEvidence().isEmpty)
#expect(try await store.allManualDays().isEmpty)
+ #expect(try await store.recordingDevices().isEmpty)
+ #expect(try await store.recordingPolicyChanges().isEmpty)
}
// MARK: - Logging reminders
@@ -1109,6 +1134,77 @@ struct WhereServicesTests {
#expect(snapshot?.totals == [.california: 1])
}
+ @Test func remoteImportPublishesOnceFromTheBaseService() async throws {
+ let remoteSource = ScriptedStoreRemoteChangeSource()
+ let store = try SwiftDataStore.inMemory(remoteChangeSource: remoteSource)
+ let now = WhereCoreTestSupport.iso("2026-03-15T20:00:00-07:00")
+ try await store.perform {
+ try await store.setManualDay(DayPresence(
+ date: now,
+ in: Self.makeAggregator().calendar,
+ regions: [.california],
+ ))
+ }
+ let refresher = SpyWidgetRefresher()
+ let services = try await WhereServices.make(
+ store: store,
+ locationSource: ScriptedLocationSource(),
+ recordingParticipation: .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: true,
+ ),
+ aggregator: Self.makeAggregator(),
+ reminderScheduler: NoopLoggingReminderScheduler(),
+ summaryScheduler: NoopDailySummaryScheduler(),
+ issueAlertScheduler: NoopDataIssueAlertScheduler(),
+ widgetRefresher: refresher,
+ now: { now },
+ )
+ let intents = WhereServices.forIntents(sharingStoreOf: services)
+
+ remoteSource.yield()
+
+ try await waitUntil { await refresher.publishCount == 1 }
+ #expect(await refresher.lastSnapshot?.dayRegions == [.california])
+ // Keep the derived stack alive through the assertion: if it had started
+ // a duplicate remote observer, the shared refresher would see two
+ // publishes for the one import.
+ withExtendedLifetime(intents) {}
+ #expect(await refresher.publishCount == 1)
+ }
+
+ @Test func intentWriteKeepsBaseWidgetIngestCacheCoherent() async throws {
+ let now = WhereCoreTestSupport.iso("2026-03-15T20:00:00-07:00")
+ let refresher = SpyWidgetRefresher()
+ let (services, _) = try Self.makeWidgetServices(
+ refresher: refresher,
+ now: now,
+ )
+ let intents = WhereServices.forIntents(sharingStoreOf: services)
+
+ // Seed the base publisher's fast-path cache with a manual California
+ // day, then replace that overlay through the intent-derived stack.
+ try await services.journal.addManualDay(
+ date: now,
+ regions: [.california],
+ audit: nil,
+ )
+ try await intents.journal.addManualDay(
+ date: now,
+ regions: [.newYork],
+ audit: nil,
+ )
+ #expect(await refresher.lastSnapshot?.dayRegions == [.newYork])
+
+ // A subsequent live California sample must rebuild to include both
+ // sources. With separate publisher caches, the base still remembered
+ // California from before the intent write and incorrectly skipped.
+ try await services.journal.ingest(sample(at: "2026-03-15T21:00:00-07:00"))
+
+ #expect(await refresher.publishCount == 3)
+ #expect(await refresher.lastSnapshot?.dayRegions == [.california, .newYork])
+ }
+
@Test func gpsIngestPublishesWidgetSnapshot() async throws {
let refresher = SpyWidgetRefresher()
let (services, source) = try Self.makeWidgetServices(refresher: refresher)
@@ -1315,7 +1411,7 @@ private actor SpyWidgetRefresher: WidgetTimelineRefreshing {
publishedSnapshots.last
}
- func publish(_ snapshot: WidgetSnapshot) async {
+ func publish(_ snapshot: WidgetSnapshot) async throws {
publishedSnapshots.append(snapshot)
}
}
@@ -1367,6 +1463,29 @@ private actor ToggleFailingStore: WhereStore {
try await backing.allSamples()
}
+ func recordingDevices() async throws -> [RecordingDevice] {
+ try await backing.recordingDevices()
+ }
+
+ func setRecordingDevice(_ device: RecordingDevice) async throws {
+ try await backing.setRecordingDevice(device)
+ }
+
+ func updateRecordingDevice(
+ _ id: RecordingDeviceID,
+ transform: @Sendable (RecordingDevice) -> RecordingDevice,
+ ) async throws -> RecordingDevice? {
+ try await backing.updateRecordingDevice(id, transform: transform)
+ }
+
+ func recordingPolicyChanges() async throws -> [RecordingPolicyChange] {
+ try await backing.recordingPolicyChanges()
+ }
+
+ func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws {
+ try await backing.addRecordingPolicyChange(change)
+ }
+
func write(evidence: Evidence, blob: Data?) async throws {
try await backing.write(evidence: evidence, blob: blob)
}
diff --git a/Where/WhereCore/Tests/WidgetDataReaderTests.swift b/Where/WhereCore/Tests/WidgetDataReaderTests.swift
index b27ff46b5..653f2c41e 100644
--- a/Where/WhereCore/Tests/WidgetDataReaderTests.swift
+++ b/Where/WhereCore/Tests/WidgetDataReaderTests.swift
@@ -29,14 +29,17 @@ struct WidgetDataReaderTests {
@Test func emptyStoreYieldsEmptySnapshot() async throws {
let (reader, _) = try Self.makeReader()
+ let asOf = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")
- let snapshot = try await reader
- .snapshot(asOf: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00"))
+ let snapshot = try await reader.snapshot(asOf: asOf)
#expect(snapshot.year == 2026)
#expect(snapshot.dayRegions.isEmpty)
#expect(snapshot.totals.isEmpty)
#expect(snapshot.appearances.isEmpty)
+ #expect(snapshot.generatedAt == asOf)
+ #expect(snapshot.surface?.todayRegions.isEmpty == true)
+ #expect(snapshot.surface?.yearToDate.isEmpty == true)
}
@Test func snapshotCarriesPickedRegionAppearances() async throws {
@@ -48,12 +51,19 @@ struct WidgetDataReaderTests {
// A tracked region with no picked look contributes no appearance.
PrimaryRegion(region: .newYork, appearance: nil, order: 1),
])
+ try await store.setManualDay(DayPresence(
+ date: WhereCoreTestSupport.iso("2026-03-15T00:00:00-07:00"),
+ in: WhereCoreTestSupport.calendar(),
+ regions: [.california],
+ ))
}
let snapshot = try await reader
.snapshot(asOf: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00"))
#expect(snapshot.appearances == [.california: caLook])
+ #expect(snapshot.surface?.todayRegions.first?.emoji == "🌴")
+ #expect(snapshot.surface?.todayRegions.first?.symbolName == "sun.max.fill")
}
@Test func snapshotAppearancesSurviveCodableRoundTrip() throws {
@@ -64,6 +74,8 @@ struct WidgetDataReaderTests {
dayRegions: [.newYork],
totals: [.newYork: 3],
appearances: [.newYork: look],
+ generatedAt: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00"),
+ surface: nil,
)
let data = try JSONEncoder().encode(snapshot)
let decoded = try JSONDecoder().decode(WidgetSnapshot.self, from: data)
@@ -71,7 +83,26 @@ struct WidgetDataReaderTests {
#expect(decoded.appearances == [.newYork: look])
}
- @Test func samplesAndManualDaysRollUpLikeTheYearReport() async throws {
+ @Test func oldSnapshotDecodesWithoutSurfaceFields() throws {
+ let data = Data(
+ """
+ {
+ "day": 0,
+ "year": 2026,
+ "dayRegions": ["us-CA"],
+ "totals": ["us-CA", 1]
+ }
+ """.utf8,
+ )
+
+ let snapshot = try JSONDecoder().decode(WidgetSnapshot.self, from: data)
+
+ #expect(snapshot.generatedAt == nil)
+ #expect(snapshot.surface == nil)
+ #expect(snapshot.appearances.isEmpty)
+ }
+
+ @Test func totalsIncludeTheSnapshotDayButExcludeFutureDays() async throws {
let (reader, store) = try Self.makeReader()
try await store.perform {
// Two same-day samples in CA, one in NY the next day.
@@ -90,7 +121,7 @@ struct WidgetDataReaderTests {
latitude: 40.7128,
longitude: -74.0060,
))
- // A manual backfill for a third day.
+ // A future manual entry must not leak into a year-to-date count.
try await store.setManualDay(DayPresence(
date: WhereCoreTestSupport.iso("2026-05-01T00:00:00-07:00"),
in: WhereCoreTestSupport.calendar(),
@@ -103,7 +134,11 @@ struct WidgetDataReaderTests {
#expect(snapshot.year == 2026)
#expect(snapshot.dayRegions == [.california])
- #expect(snapshot.totals == [.california: 1, .newYork: 1, .canada: 1])
+ #expect(snapshot.totals == [.california: 1])
+ let surface = try #require(snapshot.surface)
+ #expect(surface.todayRegions.map(\.id) == ["us-CA"])
+ #expect(surface.yearToDate.map(\.region.id) == ["us-CA"])
+ #expect(surface.yearToDate.map(\.days) == [1])
}
@Test func dayRegionsAreEmptyWhenTodayHasNoData() async throws {
diff --git a/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift b/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift
index e85019e98..20d3322c5 100644
--- a/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift
+++ b/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift
@@ -1,7 +1,7 @@
import Foundation
import RegionKit
import Testing
-@testable import WhereCore
+@_spi(Testing) @testable import WhereCore
/// Covers the freshness gate (`refreshIfStale`) and the hot-path change
/// detection (`publishAfterIngest`) the controller delegates every widget
@@ -11,12 +11,79 @@ struct WidgetSnapshotPublisherTests {
private(set) var publishCount = 0
private(set) var lastSnapshot: WidgetSnapshot?
- func publish(_ snapshot: WidgetSnapshot) async {
+ func publish(_ snapshot: WidgetSnapshot) async throws {
publishCount += 1
lastSnapshot = snapshot
}
}
+ private actor ControllableRefresher: WidgetTimelineRefreshing {
+ struct Failure: Error {}
+
+ private(set) var publishCount = 0
+ private var shouldFailNextPublish = false
+
+ func failNextPublish() {
+ shouldFailNextPublish = true
+ }
+
+ func publish(_: WidgetSnapshot) async throws {
+ publishCount += 1
+ if shouldFailNextPublish {
+ shouldFailNextPublish = false
+ throw Failure()
+ }
+ }
+ }
+
+ private actor GatedRefresher: WidgetTimelineRefreshing {
+ private var firstPublishContinuation: CheckedContinuation?
+ private(set) var publishCount = 0
+
+ var isFirstPublishSuspended: Bool {
+ firstPublishContinuation != nil
+ }
+
+ func publish(_: WidgetSnapshot) async throws {
+ publishCount += 1
+ guard publishCount == 1 else { return }
+ await withCheckedContinuation { continuation in
+ firstPublishContinuation = continuation
+ }
+ }
+
+ func resumeFirstPublish() {
+ firstPublishContinuation?.resume()
+ firstPublishContinuation = nil
+ }
+ }
+
+ private actor GatedExternalPreparation {
+ private var continuation: CheckedContinuation?
+
+ var isWaiting: Bool {
+ continuation != nil
+ }
+
+ func prepare() async {
+ await withTaskCancellationHandler {
+ guard Task.isCancelled == false else { return }
+ await withCheckedContinuation { continuation in
+ self.continuation = continuation
+ }
+ } onCancel: {
+ Task { await self.resume() }
+ }
+ }
+
+ func resume() {
+ continuation?.resume()
+ continuation = nil
+ }
+ }
+
+ private struct WaitTimeout: Error {}
+
private static func makePublisher(
now: @escaping @Sendable () -> Date,
maxAge: TimeInterval = WidgetSnapshotPublisher.defaultMaxAge,
@@ -43,11 +110,47 @@ struct WidgetSnapshotPublisherTests {
return (publisher, store, refresher)
}
+ private static func makePublisher(
+ now: @escaping @Sendable () -> Date,
+ refresher: any WidgetTimelineRefreshing,
+ ) throws -> WidgetSnapshotPublisher {
+ let store = try SwiftDataStore.inMemory()
+ let aggregator = DayAggregator(
+ calendar: WhereCoreTestSupport.calendar(),
+ timeZone: WhereCoreTestSupport.pacific,
+ )
+ return WidgetSnapshotPublisher(
+ widgetReader: WidgetDataReader(
+ store: store,
+ aggregator: aggregator,
+ attributor: RegionAttributor.shared,
+ ),
+ widgetRefresher: refresher,
+ attributor: RegionAttributor.shared,
+ calendar: WhereCoreTestSupport.calendar(),
+ now: now,
+ )
+ }
+
+ private static func waitUntil(
+ _ predicate: @escaping @Sendable () async -> Bool,
+ ) async throws {
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: .seconds(2))
+ while await predicate() == false {
+ try Task.checkCancellation()
+ guard clock.now < deadline else { throw WaitTimeout() }
+ await Task.yield()
+ }
+ }
+
@Test func publishBuildsAndPublishesASnapshot() async throws {
let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")
let (publisher, _, refresher) = try Self.makePublisher(now: { now })
await publisher.publish()
#expect(await refresher.publishCount == 1)
+ #expect(await refresher.lastSnapshot?.generatedAt == now)
+ #expect(await refresher.lastSnapshot?.surface != nil)
}
@Test func refreshIfStaleSkipsWhenFresh() async throws {
@@ -117,4 +220,138 @@ struct WidgetSnapshotPublisherTests {
await publisher.publishAfterIngest(of: nyc)
#expect(await refresher.publishCount == 2)
}
+
+ @Test func aFailedWriteIsNotCachedAsFresh() async throws {
+ let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")
+ let refresher = ControllableRefresher()
+ let publisher = try Self.makePublisher(now: { now }, refresher: refresher)
+
+ await refresher.failNextPublish()
+ await publisher.publish()
+ #expect(await refresher.publishCount == 1)
+
+ await publisher.refreshIfStale()
+ #expect(await refresher.publishCount == 2)
+
+ await publisher.refreshIfStale()
+ #expect(await refresher.publishCount == 2)
+ }
+
+ @Test func failureAfterASuccessInvalidatesTheFreshnessGate() async throws {
+ let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")
+ let refresher = ControllableRefresher()
+ let publisher = try Self.makePublisher(now: { now }, refresher: refresher)
+
+ await publisher.publish()
+ await refresher.failNextPublish()
+ await publisher.publish()
+ #expect(await refresher.publishCount == 2)
+
+ // The earlier successful snapshot is still young, but it predates the
+ // failed mutation publish and therefore must not suppress this retry.
+ await publisher.refreshIfStale()
+ #expect(await refresher.publishCount == 3)
+
+ await publisher.refreshIfStale()
+ #expect(await refresher.publishCount == 3)
+ }
+
+ @Test func failureAfterASuccessInvalidatesTheIngestFastPath() async throws {
+ let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")
+ let store = try SwiftDataStore.inMemory()
+ let aggregator = DayAggregator(
+ calendar: WhereCoreTestSupport.calendar(),
+ timeZone: WhereCoreTestSupport.pacific,
+ )
+ let refresher = ControllableRefresher()
+ let publisher = WidgetSnapshotPublisher(
+ widgetReader: WidgetDataReader(
+ store: store,
+ aggregator: aggregator,
+ attributor: RegionAttributor.shared,
+ ),
+ widgetRefresher: refresher,
+ attributor: RegionAttributor.shared,
+ calendar: WhereCoreTestSupport.calendar(),
+ now: { now },
+ )
+ let sample = LocationSample(
+ timestamp: now,
+ coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194),
+ horizontalAccuracy: 0,
+ source: .gpsSignificantChange,
+ )
+ try await store.perform { try await store.add(sample: sample) }
+ await publisher.publish()
+
+ await refresher.failNextPublish()
+ await publisher.publish()
+ #expect(await refresher.publishCount == 2)
+
+ // Even though this sample's day and region match the last good
+ // snapshot, the intervening failed rebuild means that snapshot may no
+ // longer represent other store changes.
+ await publisher.publishAfterIngest(of: sample)
+ #expect(await refresher.publishCount == 3)
+ }
+
+ @Test func anExternalChangeTriggersAFullPublish() async throws {
+ let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")
+ let (publisher, _, refresher) = try Self.makePublisher(now: { now })
+ let changes = StoreChangeBroadcaster()
+ let preparation = GatedExternalPreparation()
+
+ await publisher.startObservingExternalChanges(
+ changes.subscribe(),
+ beforePublishing: { await preparation.prepare() },
+ )
+ changes.send()
+
+ try await Self.waitUntil { await preparation.isWaiting }
+ #expect(await refresher.publishCount == 0)
+ await preparation.resume()
+ try await Self.waitUntil { await refresher.publishCount == 1 }
+ await publisher.stopObservingExternalChanges()
+ }
+
+ @Test func stoppingExternalObservationDuringPreparationSkipsThePublish() async throws {
+ let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")
+ let (publisher, _, refresher) = try Self.makePublisher(now: { now })
+ let changes = StoreChangeBroadcaster()
+ let preparation = GatedExternalPreparation()
+
+ await publisher.startObservingExternalChanges(
+ changes.subscribe(),
+ beforePublishing: { await preparation.prepare() },
+ )
+ changes.send()
+ try await Self.waitUntil { await preparation.isWaiting }
+
+ await publisher.stopObservingExternalChanges()
+ #expect(await publisher.testingReceivedPublishRequestCount == 0)
+ #expect(await refresher.publishCount == 0)
+ }
+
+ @Test func concurrentRequestsCoalesceIntoOneFinalRebuild() async throws {
+ let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")
+ let refresher = GatedRefresher()
+ let publisher = try Self.makePublisher(now: { now }, refresher: refresher)
+
+ let first = Task { await publisher.publish() }
+ try await Self.waitUntil { await refresher.isFirstPublishSuspended }
+
+ let joined = (0 ..< 5).map { _ in
+ Task { await publisher.publish() }
+ }
+ try await Self.waitUntil {
+ await publisher.testingReceivedPublishRequestCount == 6
+ }
+ await refresher.resumeFirstPublish()
+
+ await first.value
+ for task in joined {
+ await task.value
+ }
+ #expect(await refresher.publishCount == 2)
+ }
}
diff --git a/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift b/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift
index 65a19bcc2..931effc7b 100644
--- a/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift
+++ b/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift
@@ -21,6 +21,9 @@ struct WidgetSnapshotStoreTests {
year: 2026,
dayRegions: dayRegions,
totals: totals,
+ appearances: [:],
+ generatedAt: Date(timeIntervalSince1970: 1_700_000_100),
+ surface: nil,
)
}
diff --git a/Where/WhereIntents/Tests/WhereIntentReaderTests.swift b/Where/WhereIntents/Tests/WhereIntentReaderTests.swift
index e0c4e45a5..928ff8b5b 100644
--- a/Where/WhereIntents/Tests/WhereIntentReaderTests.swift
+++ b/Where/WhereIntents/Tests/WhereIntentReaderTests.swift
@@ -73,6 +73,9 @@ struct WhereIntentReaderTests {
year: 2026,
dayRegions: [.canada],
totals: [:],
+ appearances: [:],
+ generatedAt: today,
+ surface: nil,
)
}
#expect(try await reader.todayRegions() == [.canada])
@@ -104,6 +107,9 @@ struct WhereIntentReaderTests {
year: 2026,
dayRegions: [.canada],
totals: [:],
+ appearances: [:],
+ generatedAt: today,
+ surface: nil,
)
}
#expect(try await reader.todayRegions() == [.newYork])
diff --git a/Where/WhereMenuBar/AGENTS.md b/Where/WhereMenuBar/AGENTS.md
new file mode 100644
index 000000000..7dda85c43
--- /dev/null
+++ b/Where/WhereMenuBar/AGENTS.md
@@ -0,0 +1,29 @@
+# WhereMenuBar – Module Shape
+
+WhereMenuBar is the native macOS, icon-only menu bar helper for Where. See
+[`README.md`](README.md). This file complements the root
+[`AGENTS.md`](../../AGENTS.md), feature [`Where/AGENTS.md`](../AGENTS.md), and
+the shared contract [`WhereSurface/AGENTS.md`](../WhereSurface/AGENTS.md).
+
+## Scope & dependencies
+
+- Depend on WhereSurface plus system AppKit/SwiftUI only; never import
+ WhereCore, RegionKit, SwiftData, CloudKit, WidgetKit, or location frameworks.
+- Read the App Group artifact only. The helper never writes shared data or
+ launches the host automatically.
+- Keep login-item registration in the Catalyst app; the helper only renders
+ and handles its explicit Open Where action.
+
+## Invariants
+
+- Keep the status item icon-only and give its button an accessibility label.
+- Preserve the last good snapshot when refresh or decode fails, showing its
+ original relative age and a failure note.
+- Pair Darwin observer registration with removal; treat delivery as advisory.
+- Keep user-facing copy in this target's generated string catalog.
+
+## Testing
+
+Wire-format, file-read, and compatibility behavior lives in
+[`WhereSurface/Tests`](../WhereSurface/Tests); payload construction and ranking
+live in WhereCore tests. Keep this target a thin native host.
diff --git a/Where/WhereMenuBar/README.md b/Where/WhereMenuBar/README.md
new file mode 100644
index 000000000..698df3046
--- /dev/null
+++ b/Where/WhereMenuBar/README.md
@@ -0,0 +1,25 @@
+# WhereMenuBar
+
+**WhereMenuBar** is Where's native macOS menu bar companion. It is an
+`LSUIElement` helper embedded in the Mac Catalyst app and optionally registered
+by the user as a login item.
+
+The status item is icon-only. Its popover shows the regions observed today,
+the top three year-to-date day counts, the age of the last successful publish,
+and an explicit **Open Where** button. It keeps stale content visible when a
+later refresh fails and never launches the main app on its own.
+
+## Data boundary
+
+The helper depends only on [`WhereSurface`](../WhereSurface). It reads the
+presentation-ready overlay in the App Group's `widget-snapshot.json` and
+treats the Darwin change notification as an advisory refresh hint. It never
+opens SwiftData, contacts CloudKit, requests location, or recomputes reports.
+
+## Packaging
+
+The Tuist target is a native macOS app with bundle identifier
+`com.stuff.where.menubar`, sandbox + App Group entitlements, and
+`LSUIElement = true`. The Catalyst app embeds it in
+`Contents/Library/LoginItems` and owns the `SMAppService.loginItem` user
+control.
diff --git a/Where/WhereMenuBar/Resources/Localizable.xcstrings b/Where/WhereMenuBar/Resources/Localizable.xcstrings
new file mode 100644
index 000000000..59fd6bd0e
--- /dev/null
+++ b/Where/WhereMenuBar/Resources/Localizable.xcstrings
@@ -0,0 +1,186 @@
+{
+ "sourceLanguage" : "en",
+ "strings" : {
+ "menuBar.accessibilityLabel" : {
+ "comment" : "Accessibility label for the icon-only Where menu bar item.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Where"
+ }
+ }
+ }
+ },
+ "menuBar.day" : {
+ "comment" : "Singular unit shown after a year-to-date day count.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "day"
+ }
+ }
+ }
+ },
+ "menuBar.days" : {
+ "comment" : "Plural unit shown after a year-to-date day count.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "days"
+ }
+ }
+ }
+ },
+ "menuBar.openWhere" : {
+ "comment" : "Button that explicitly opens the main Where app.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Open Where"
+ }
+ }
+ }
+ },
+ "menuBar.refreshFailed" : {
+ "comment" : "Shown while retaining stale data after a refresh fails.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Couldn’t refresh. Showing the last update."
+ }
+ }
+ }
+ },
+ "menuBar.today.empty" : {
+ "comment" : "Empty state for today's observed-region list.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No regions observed today."
+ }
+ }
+ }
+ },
+ "menuBar.today.title" : {
+ "comment" : "Heading above today's observed regions.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Today"
+ }
+ }
+ }
+ },
+ "menuBar.unavailable.appGroup" : {
+ "comment" : "Explains that the helper cannot access Where's shared App Group.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Where’s shared summary isn’t available. Open Where and try again."
+ }
+ }
+ }
+ },
+ "menuBar.unavailable.description" : {
+ "comment" : "Explains how to create the first menu bar summary.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Open Where to publish today’s summary."
+ }
+ }
+ }
+ },
+ "menuBar.unavailable.failureTitle" : {
+ "comment" : "Heading when the shared menu bar summary cannot be read.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Summary unavailable"
+ }
+ }
+ }
+ },
+ "menuBar.unavailable.title" : {
+ "comment" : "Heading when the app has not published a menu bar summary.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No summary yet"
+ }
+ }
+ }
+ },
+ "menuBar.unavailable.unreadable" : {
+ "comment" : "Explains that the existing shared summary could not be decoded.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "The shared summary couldn’t be read. Open Where to publish it again."
+ }
+ }
+ }
+ },
+ "menuBar.updated" : {
+ "comment" : "Label before the relative age of the published summary.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Updated"
+ }
+ }
+ }
+ },
+ "menuBar.yearToDate.empty" : {
+ "comment" : "Empty state for the year-to-date day-count list.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No days recorded this year."
+ }
+ }
+ }
+ },
+ "menuBar.yearToDate.title" : {
+ "comment" : "Heading above the top year-to-date region day counts.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Year to Date"
+ }
+ }
+ }
+ }
+ },
+ "version" : "1.1"
+}
\ No newline at end of file
diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarApp.swift b/Where/WhereMenuBar/Sources/WhereMenuBarApp.swift
new file mode 100644
index 000000000..d6eaadac4
--- /dev/null
+++ b/Where/WhereMenuBar/Sources/WhereMenuBarApp.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+
+@main
+struct WhereMenuBarApp: App {
+ @NSApplicationDelegateAdaptor(WhereMenuBarAppDelegate.self)
+ private var appDelegate
+
+ var body: some Scene {
+ Settings {
+ EmptyView()
+ }
+ }
+}
diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarAppDelegate.swift b/Where/WhereMenuBar/Sources/WhereMenuBarAppDelegate.swift
new file mode 100644
index 000000000..01a035402
--- /dev/null
+++ b/Where/WhereMenuBar/Sources/WhereMenuBarAppDelegate.swift
@@ -0,0 +1,133 @@
+import AppKit
+import CoreFoundation
+import SwiftUI
+import WhereSurface
+
+@MainActor
+final class WhereMenuBarAppDelegate: NSObject, NSApplicationDelegate {
+ private let model: WhereMenuBarModel
+ private var popover: NSPopover?
+ private var statusItem: NSStatusItem?
+
+ override init() {
+ do {
+ model = try WhereMenuBarModel(reader: WhereSurfaceStore.shared())
+ } catch let error as WhereSurfaceStore.AppGroupUnavailableError {
+ model = WhereMenuBarModel(appGroupUnavailable: error)
+ } catch {
+ assertionFailure("Unexpected WhereSurfaceStore error: \(error)")
+ model = WhereMenuBarModel(
+ appGroupUnavailable: WhereSurfaceStore.AppGroupUnavailableError(),
+ )
+ }
+ super.init()
+ }
+
+ func applicationDidFinishLaunching(_: Notification) {
+ NSApp.setActivationPolicy(.accessory)
+ configurePopover()
+ configureStatusItem()
+ startObservingSurfaceChanges()
+ }
+
+ func applicationWillTerminate(_: Notification) {
+ stopObservingSurfaceChanges()
+ }
+
+ fileprivate func surfaceDidChange() {
+ model.refresh()
+ }
+
+ private func configurePopover() {
+ let popover = NSPopover()
+ popover.behavior = .transient
+ popover.contentViewController = NSHostingController(
+ rootView: WhereMenuBarView(model: model),
+ )
+ self.popover = popover
+ }
+
+ private func configureStatusItem() {
+ let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
+ guard let button = statusItem.button else {
+ assertionFailure("NSStatusItem did not vend a button")
+ self.statusItem = statusItem
+ return
+ }
+
+ let accessibilityLabel = String(localized: .menuBarAccessibilityLabel)
+ if let image = NSImage(
+ systemSymbolName: "location.fill",
+ accessibilityDescription: accessibilityLabel,
+ ) {
+ image.isTemplate = true
+ button.image = image
+ button.imagePosition = .imageOnly
+ } else {
+ assertionFailure("The location.fill system symbol is unavailable")
+ button.title = "●"
+ button.imagePosition = .noImage
+ }
+ button.setAccessibilityLabel(accessibilityLabel)
+ button.target = self
+ button.action = #selector(togglePopover)
+ self.statusItem = statusItem
+ }
+
+ @objc
+ private func togglePopover() {
+ guard
+ let button = statusItem?.button,
+ let popover
+ else {
+ return
+ }
+
+ if popover.isShown {
+ popover.performClose(nil)
+ } else {
+ model.refresh()
+ popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
+ NSApp.activate()
+ }
+ }
+
+ private func startObservingSurfaceChanges() {
+ stopObservingSurfaceChanges()
+ CFNotificationCenterAddObserver(
+ CFNotificationCenterGetDarwinNotifyCenter(),
+ Unmanaged.passUnretained(self).toOpaque(),
+ whereMenuBarSurfaceChanged,
+ WhereSurfaceChangeNotification.name as CFString,
+ nil,
+ .deliverImmediately,
+ )
+ }
+
+ private func stopObservingSurfaceChanges() {
+ CFNotificationCenterRemoveObserver(
+ CFNotificationCenterGetDarwinNotifyCenter(),
+ Unmanaged.passUnretained(self).toOpaque(),
+ CFNotificationName(
+ rawValue: WhereSurfaceChangeNotification.name as CFString,
+ ),
+ nil,
+ )
+ }
+}
+
+private func whereMenuBarSurfaceChanged(
+ _: CFNotificationCenter?,
+ observer: UnsafeMutableRawPointer?,
+ _: CFNotificationName?,
+ _: UnsafeRawPointer?,
+ _: CFDictionary?,
+) {
+ guard let observer else { return }
+ let delegate = Unmanaged
+ .fromOpaque(observer)
+ .takeUnretainedValue()
+ Task { @MainActor in
+ delegate.surfaceDidChange()
+ }
+}
diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarDayCountRow.swift b/Where/WhereMenuBar/Sources/WhereMenuBarDayCountRow.swift
new file mode 100644
index 000000000..6653bccb0
--- /dev/null
+++ b/Where/WhereMenuBar/Sources/WhereMenuBarDayCountRow.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+import WhereSurface
+
+struct WhereMenuBarDayCountRow: View {
+ let dayCount: WhereSurfaceSnapshot.DayCount
+
+ var body: some View {
+ HStack {
+ WhereMenuBarRegionRow(region: dayCount.region)
+ Spacer()
+ Text(dayCount.days, format: .number)
+ .monospacedDigit()
+ Text(dayCount.days == 1 ? .menuBarDay : .menuBarDays)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarModel.swift b/Where/WhereMenuBar/Sources/WhereMenuBarModel.swift
new file mode 100644
index 000000000..ac8e243b2
--- /dev/null
+++ b/Where/WhereMenuBar/Sources/WhereMenuBarModel.swift
@@ -0,0 +1,81 @@
+import Foundation
+import Observation
+import WhereSurface
+
+/// Keeps the helper's last successfully decoded glance payload.
+///
+/// A failed advisory refresh never replaces loaded content with an empty state;
+/// the popover continues to show that snapshot with its original generation
+/// date and makes the refresh failure visible.
+@MainActor
+@Observable
+final class WhereMenuBarModel {
+ enum UnavailableReason: Equatable {
+ case notPublished
+ case unreadable
+ case appGroupUnavailable
+ }
+
+ enum State: Equatable {
+ case unavailable(UnavailableReason)
+ case loaded(
+ generatedAt: Date,
+ snapshot: WhereSurfaceSnapshot,
+ refreshFailed: Bool,
+ )
+ }
+
+ private let reader: (any WhereSurfaceReading)?
+ private(set) var state: State
+
+ init(reader: any WhereSurfaceReading) {
+ self.reader = reader
+ state = .unavailable(.notPublished)
+ refresh()
+ }
+
+ /// Builds an honest unavailable model when the App Group entitlement
+ /// cannot be resolved. That configuration cannot recover during this
+ /// process lifetime, so there is no reader to retry.
+ init(appGroupUnavailable _: WhereSurfaceStore.AppGroupUnavailableError) {
+ reader = nil
+ state = .unavailable(.appGroupUnavailable)
+ }
+
+ func refresh() {
+ guard let reader else { return }
+ do {
+ guard let document = try reader.read() else {
+ handleUnavailable(.notPublished)
+ return
+ }
+ guard
+ let generatedAt = document.generatedAt,
+ let snapshot = document.surface
+ else {
+ handleUnavailable(.notPublished)
+ return
+ }
+ state = .loaded(
+ generatedAt: generatedAt,
+ snapshot: snapshot,
+ refreshFailed: false,
+ )
+ } catch {
+ handleUnavailable(.unreadable)
+ }
+ }
+
+ private func handleUnavailable(_ reason: UnavailableReason) {
+ switch state {
+ case let .loaded(generatedAt, snapshot, _):
+ state = .loaded(
+ generatedAt: generatedAt,
+ snapshot: snapshot,
+ refreshFailed: true,
+ )
+ case .unavailable:
+ state = .unavailable(reason)
+ }
+ }
+}
diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarRegionRow.swift b/Where/WhereMenuBar/Sources/WhereMenuBarRegionRow.swift
new file mode 100644
index 000000000..e25884eee
--- /dev/null
+++ b/Where/WhereMenuBar/Sources/WhereMenuBarRegionRow.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+import WhereSurface
+
+struct WhereMenuBarRegionRow: View {
+ let region: WhereSurfaceSnapshot.Region
+
+ var body: some View {
+ HStack {
+ if let emoji = region.emoji, emoji.isEmpty == false {
+ Text(verbatim: emoji)
+ .accessibilityHidden(true)
+ } else {
+ Image(systemName: region.symbolName ?? "location.fill")
+ .accessibilityHidden(true)
+ }
+ Text(verbatim: region.name)
+ }
+ }
+}
diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarSnapshotView.swift b/Where/WhereMenuBar/Sources/WhereMenuBarSnapshotView.swift
new file mode 100644
index 000000000..51a6f5736
--- /dev/null
+++ b/Where/WhereMenuBar/Sources/WhereMenuBarSnapshotView.swift
@@ -0,0 +1,75 @@
+import SwiftUI
+import WhereSurface
+
+struct WhereMenuBarSnapshotView: View {
+ @Environment(\.openURL) private var openURL
+
+ let generatedAt: Date
+ let snapshot: WhereSurfaceSnapshot
+ let refreshFailed: Bool
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(.menuBarTodayTitle)
+ .font(.headline)
+ Spacer()
+ // The helper can outlive the host app across midnight. Keep
+ // the artifact's logical day visible so stale rows never
+ // masquerade as observations for the new day.
+ Text(snapshot.day, format: .dateTime.month(.abbreviated).day())
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ if snapshot.todayRegions.isEmpty {
+ Text(.menuBarTodayEmpty)
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(snapshot.todayRegions) { region in
+ WhereMenuBarRegionRow(region: region)
+ }
+ }
+
+ Divider()
+
+ Text(.menuBarYearToDateTitle)
+ .font(.headline)
+ if snapshot.yearToDate.isEmpty {
+ Text(.menuBarYearToDateEmpty)
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(snapshot.yearToDate) { dayCount in
+ WhereMenuBarDayCountRow(dayCount: dayCount)
+ }
+ }
+
+ Divider()
+
+ HStack {
+ Text(.menuBarUpdated)
+ Text(generatedAt, style: .relative)
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ if refreshFailed {
+ Label(
+ .menuBarRefreshFailed,
+ systemImage: "exclamationmark.triangle",
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Button(
+ .menuBarOpenWhere,
+ systemImage: "arrow.up.forward.app",
+ action: openWhere,
+ )
+ }
+ }
+
+ private func openWhere() {
+ openURL(WhereSurfaceStore.openWhereURL)
+ }
+}
diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarUnavailableView.swift b/Where/WhereMenuBar/Sources/WhereMenuBarUnavailableView.swift
new file mode 100644
index 000000000..b1360c248
--- /dev/null
+++ b/Where/WhereMenuBar/Sources/WhereMenuBarUnavailableView.swift
@@ -0,0 +1,46 @@
+import SwiftUI
+import WhereSurface
+
+struct WhereMenuBarUnavailableView: View {
+ @Environment(\.openURL) private var openURL
+
+ let reason: WhereMenuBarModel.UnavailableReason
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Label(title, systemImage: "location.slash")
+ .font(.headline)
+ Text(description)
+ .foregroundStyle(.secondary)
+ Button(
+ .menuBarOpenWhere,
+ systemImage: "arrow.up.forward.app",
+ action: openWhere,
+ )
+ }
+ }
+
+ private func openWhere() {
+ openURL(WhereSurfaceStore.openWhereURL)
+ }
+
+ private var title: LocalizedStringResource {
+ switch reason {
+ case .notPublished:
+ .menuBarUnavailableTitle
+ case .unreadable, .appGroupUnavailable:
+ .menuBarUnavailableFailureTitle
+ }
+ }
+
+ private var description: LocalizedStringResource {
+ switch reason {
+ case .notPublished:
+ .menuBarUnavailableDescription
+ case .unreadable:
+ .menuBarUnavailableUnreadable
+ case .appGroupUnavailable:
+ .menuBarUnavailableAppGroup
+ }
+ }
+}
diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarView.swift b/Where/WhereMenuBar/Sources/WhereMenuBarView.swift
new file mode 100644
index 000000000..119f8ceb2
--- /dev/null
+++ b/Where/WhereMenuBar/Sources/WhereMenuBarView.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+import WhereSurface
+
+struct WhereMenuBarView: View {
+ let model: WhereMenuBarModel
+
+ var body: some View {
+ Group {
+ switch model.state {
+ case let .unavailable(reason):
+ WhereMenuBarUnavailableView(reason: reason)
+ case let .loaded(generatedAt, snapshot, refreshFailed):
+ WhereMenuBarSnapshotView(
+ generatedAt: generatedAt,
+ snapshot: snapshot,
+ refreshFailed: refreshFailed,
+ )
+ }
+ }
+ .frame(width: 320)
+ .padding()
+ }
+}
diff --git a/Where/WhereShareExtension/AGENTS.md b/Where/WhereShareExtension/AGENTS.md
index c4d68bf4b..91bfae544 100644
--- a/Where/WhereShareExtension/AGENTS.md
+++ b/Where/WhereShareExtension/AGENTS.md
@@ -1,8 +1,9 @@
# WhereShareExtension – Module Shape
-The **Where** share extension: a Share-sheet action that writes shared content
-(PDFs, images, Wallet passes, emails, links) into the app's store as a new
-`Evidence`. See [`README.md`](README.md) for the data path and design.
+The **Where** iOS/iPadOS and Mac Catalyst share extension: a Share-sheet action
+that writes shared content (PDFs, images, Wallet passes, emails, links) into the
+app's store as a new `Evidence`. See [`README.md`](README.md) for the data path
+and design.
This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature
[`Where/AGENTS.md`](../AGENTS.md). Read those first.
diff --git a/Where/WhereShareExtension/README.md b/Where/WhereShareExtension/README.md
index ebeaf6171..46d675155 100644
--- a/Where/WhereShareExtension/README.md
+++ b/Where/WhereShareExtension/README.md
@@ -1,8 +1,9 @@
# WhereShareExtension
-The **Where** share extension: a Share-sheet action that saves shared content —
-a boarding pass, a PDF receipt, a screenshot, a forwarded reservation email, a
-Wallet ticket — into Where as a new piece of [`Evidence`](../WhereCore/Sources/Evidence/Evidence.swift).
+The **Where** iOS/iPadOS and Mac Catalyst share extension: a Share-sheet action
+that saves shared content — a boarding pass, a PDF receipt, a screenshot, a
+forwarded reservation email, a Wallet ticket — into Where as a new piece of
+[`Evidence`](../WhereCore/Sources/Evidence/Evidence.swift).
Pick "Where" from any app's Share sheet, confirm the kind / date / note in the
compose sheet, and tap **Save**. The attachment bytes and metadata are written
diff --git a/Where/WhereShareExtension/WhereShareExtension-MacCatalyst.entitlements b/Where/WhereShareExtension/WhereShareExtension-MacCatalyst.entitlements
new file mode 100644
index 000000000..3c728f043
--- /dev/null
+++ b/Where/WhereShareExtension/WhereShareExtension-MacCatalyst.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ group.com.stuff.where
+
+
+
diff --git a/Where/WhereSurface/AGENTS.md b/Where/WhereSurface/AGENTS.md
new file mode 100644
index 000000000..485c08fbf
--- /dev/null
+++ b/Where/WhereSurface/AGENTS.md
@@ -0,0 +1,30 @@
+# WhereSurface – Module Shape
+
+WhereSurface is the Foundation-only, read-only glance contract shared with
+processes that must not open Where's store. See [`README.md`](README.md). This
+file complements the root [`AGENTS.md`](../../AGENTS.md) and feature
+[`Where/AGENTS.md`](../AGENTS.md).
+
+## Scope & dependencies
+
+- Depend only on Foundation/CoreFoundation; never import WhereCore, RegionKit,
+ SwiftData, WidgetKit, SwiftUI, CloudKit, or location frameworks.
+- Keep `WhereSurfaceStore` read-only. The Where app is the only writer of
+ `widget-snapshot.json`.
+- Carry presentation-ready names and ordering across the boundary; consumers
+ never duplicate domain aggregation or region ranking.
+
+## Invariants
+
+- Keep `generatedAt` and `surface` optional in `WhereSurfaceDocument` so
+ snapshots from older app versions decode.
+- Coordinate every artifact read and atomic replacement through
+ `WhereSurfaceFileCoordinator`.
+- Treat `WhereSurfaceChangeNotification` as advisory and the JSON file as
+ authoritative.
+- Preserve the last successfully decoded payload when a later refresh fails.
+
+## Testing
+
+Swift Testing lives in [`Tests/`](Tests). Pin additive wire compatibility and
+read behavior without resolving the real App Group container.
diff --git a/Where/WhereSurface/README.md b/Where/WhereSurface/README.md
new file mode 100644
index 000000000..095f7a5bc
--- /dev/null
+++ b/Where/WhereSurface/README.md
@@ -0,0 +1,34 @@
+# WhereSurface
+
+**WhereSurface** is the Foundation-only contract between the Where app and
+small store-free glance processes such as the native macOS menu bar helper.
+
+The app remains the sole owner of SwiftData and CloudKit. It publishes a
+presentation-ready `WhereSurfaceSnapshot` inside the existing
+`widget-snapshot.json` App Group artifact. A helper reads that file through
+`WhereSurfaceStore`, renders the supplied order and localized names, and never
+links `WhereCore`, `RegionKit`, SwiftData, CloudKit, or location services.
+
+## Public API
+
+- `WhereSurfaceSnapshot` carries today's observed regions and the top
+ year-to-date day counts.
+- `WhereSurfaceDocument` decodes only `generatedAt` and `surface` from the
+ larger widget JSON document. Both are optional for compatibility with older
+ app versions.
+- `WhereSurfaceStore` resolves `group.com.stuff.where` and provides read-only
+ access to `widget-snapshot.json`.
+- `WhereSurfaceFileCoordinator` coordinates every artifact read and atomic
+ replacement across the app, widget, and helper processes.
+- `WhereSurfaceChangeNotification` is an advisory Darwin notification. The
+ JSON file is always authoritative.
+
+Consumers keep the last good value if a later read fails. The helper does not
+launch the app automatically; `WhereSurfaceStore.openWhereURL` is offered only
+for an explicit user action.
+
+## Testing
+
+`WhereSurfaceTests` covers wire compatibility, Foundation-only decoding,
+coordinated file access, and reads. The app's WhereCore tests cover
+construction, ranking, and publication of the payload from real domain data.
diff --git a/Where/WhereSurface/Sources/WhereSurfaceChangeNotification.swift b/Where/WhereSurface/Sources/WhereSurfaceChangeNotification.swift
new file mode 100644
index 000000000..c100a1e07
--- /dev/null
+++ b/Where/WhereSurface/Sources/WhereSurfaceChangeNotification.swift
@@ -0,0 +1,20 @@
+import CoreFoundation
+import Foundation
+
+/// The advisory Darwin notification posted after replacing the glance file.
+///
+/// The file remains authoritative: a receiver always re-reads it and may also
+/// refresh at launch. Darwin delivery is intentionally only a low-latency hint.
+public enum WhereSurfaceChangeNotification {
+ public static let name = "com.stuff.where.surface.changed"
+
+ public static func post() {
+ CFNotificationCenterPostNotification(
+ CFNotificationCenterGetDarwinNotifyCenter(),
+ CFNotificationName(rawValue: name as CFString),
+ nil,
+ nil,
+ true,
+ )
+ }
+}
diff --git a/Where/WhereSurface/Sources/WhereSurfaceDocument.swift b/Where/WhereSurface/Sources/WhereSurfaceDocument.swift
new file mode 100644
index 000000000..0f42079d2
--- /dev/null
+++ b/Where/WhereSurface/Sources/WhereSurfaceDocument.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+/// The helper-facing overlay decoded from `widget-snapshot.json`.
+///
+/// The file also carries widget-specific fields. `Codable` deliberately
+/// ignores those unknown keys, allowing a Foundation-only process to read the
+/// glance payload without linking WhereCore or RegionKit. Both properties are
+/// optional so files published before this overlay existed still decode.
+public struct WhereSurfaceDocument: Codable, Hashable, Sendable {
+ public let generatedAt: Date?
+ public let surface: WhereSurfaceSnapshot?
+
+ public init(generatedAt: Date?, surface: WhereSurfaceSnapshot?) {
+ self.generatedAt = generatedAt
+ self.surface = surface
+ }
+}
diff --git a/Where/WhereSurface/Sources/WhereSurfaceFileCoordinator.swift b/Where/WhereSurface/Sources/WhereSurfaceFileCoordinator.swift
new file mode 100644
index 000000000..0c43d653d
--- /dev/null
+++ b/Where/WhereSurface/Sources/WhereSurfaceFileCoordinator.swift
@@ -0,0 +1,76 @@
+import Foundation
+
+/// Coordinates access to Where's App Group artifact across process boundaries.
+///
+/// Each call creates an `NSFileCoordinator` for that operation. It serializes
+/// reads and writes with participating widget and helper processes; atomic writes
+/// additionally keep the authoritative JSON from ever being partially written.
+public struct WhereSurfaceFileCoordinator: Sendable {
+ public init() {}
+
+ /// Read the coordinated contents of `fileURL`, returning `nil` when the file
+ /// has not been published yet.
+ public func read(from fileURL: URL) throws -> Data? {
+ let coordinator = NSFileCoordinator(filePresenter: nil)
+ var coordinationError: NSError?
+ var accessError: (any Error)?
+ var data: Data?
+
+ coordinator.coordinate(
+ readingItemAt: fileURL,
+ options: .withoutChanges,
+ error: &coordinationError,
+ ) { coordinatedURL in
+ do {
+ data = try Data(contentsOf: coordinatedURL)
+ } catch let error as NSError where Self.isMissingFile(error) {
+ data = nil
+ } catch {
+ accessError = error
+ }
+ }
+
+ if let coordinationError {
+ guard Self.isMissingFile(coordinationError) else {
+ throw coordinationError
+ }
+ return nil
+ }
+ if let accessError {
+ throw accessError
+ }
+ return data
+ }
+
+ /// Atomically update `fileURL` while holding a coordinated write claim.
+ public func write(_ data: Data, to fileURL: URL) throws {
+ let coordinator = NSFileCoordinator(filePresenter: nil)
+ var coordinationError: NSError?
+ var accessError: (any Error)?
+
+ coordinator.coordinate(
+ writingItemAt: fileURL,
+ // Foundation reserves `.forReplacing` for replacing the coordinated
+ // item, not an atomic update of that item's contents.
+ options: [],
+ error: &coordinationError,
+ ) { coordinatedURL in
+ do {
+ try data.write(to: coordinatedURL, options: .atomic)
+ } catch {
+ accessError = error
+ }
+ }
+
+ if let coordinationError {
+ throw coordinationError
+ }
+ if let accessError {
+ throw accessError
+ }
+ }
+
+ private static func isMissingFile(_ error: NSError) -> Bool {
+ error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError
+ }
+}
diff --git a/Where/WhereSurface/Sources/WhereSurfaceReading.swift b/Where/WhereSurface/Sources/WhereSurfaceReading.swift
new file mode 100644
index 000000000..d3d369ab1
--- /dev/null
+++ b/Where/WhereSurface/Sources/WhereSurfaceReading.swift
@@ -0,0 +1,6 @@
+/// A read-only boundary for the glance artifact shared with store-free
+/// processes.
+public protocol WhereSurfaceReading: Sendable {
+ /// Returns `nil` when the app has never published an artifact.
+ func read() throws -> WhereSurfaceDocument?
+}
diff --git a/Where/WhereSurface/Sources/WhereSurfaceSnapshot.swift b/Where/WhereSurface/Sources/WhereSurfaceSnapshot.swift
new file mode 100644
index 000000000..9d6c833cf
--- /dev/null
+++ b/Where/WhereSurface/Sources/WhereSurfaceSnapshot.swift
@@ -0,0 +1,63 @@
+import Foundation
+
+/// Presentation-ready data for Where's store-free glance surfaces.
+///
+/// The app builds this from its authoritative report and publishes it inside
+/// `widget-snapshot.json`. Consumers render the supplied names and ordering;
+/// they never open the user's store or repeat region aggregation.
+public struct WhereSurfaceSnapshot: Codable, Hashable, Sendable {
+ /// A display-ready region shared by today's presence and year-to-date rows.
+ public struct Region: Codable, Hashable, Identifiable, Sendable {
+ /// The region's stable data identifier.
+ public let id: String
+ /// The localized name resolved by the publishing app.
+ public let name: String
+ /// A user-selected emoji, when the region has one.
+ public let emoji: String?
+ /// A user-selected SF Symbol name, when the region has one.
+ public let symbolName: String?
+
+ public init(id: String, name: String, emoji: String?, symbolName: String?) {
+ self.id = id
+ self.name = name
+ self.emoji = emoji
+ self.symbolName = symbolName
+ }
+ }
+
+ /// One ranked year-to-date region total.
+ public struct DayCount: Codable, Hashable, Identifiable, Sendable {
+ public var id: String {
+ region.id
+ }
+
+ public let region: Region
+ public let days: Int
+
+ public init(region: Region, days: Int) {
+ self.region = region
+ self.days = days
+ }
+ }
+
+ /// The logical day represented by `todayRegions`.
+ public let day: Date
+ /// Regions observed on `day`, already in canonical display order.
+ public let todayRegions: [Region]
+ /// The Gregorian calendar year represented by `yearToDate`.
+ public let year: Int
+ /// The top year-to-date day counts, already ranked for display.
+ public let yearToDate: [DayCount]
+
+ public init(
+ day: Date,
+ todayRegions: [Region],
+ year: Int,
+ yearToDate: [DayCount],
+ ) {
+ self.day = day
+ self.todayRegions = todayRegions
+ self.year = year
+ self.yearToDate = yearToDate
+ }
+}
diff --git a/Where/WhereSurface/Sources/WhereSurfaceStore.swift b/Where/WhereSurface/Sources/WhereSurfaceStore.swift
new file mode 100644
index 000000000..ab3fc775e
--- /dev/null
+++ b/Where/WhereSurface/Sources/WhereSurfaceStore.swift
@@ -0,0 +1,43 @@
+import Foundation
+
+/// Resolves and reads Where's App Group glance artifact.
+///
+/// This boundary is intentionally read-only. The app remains the only writer;
+/// widgets and the menu bar helper decode the app's last successful publish.
+public struct WhereSurfaceStore: Sendable, WhereSurfaceReading {
+ /// Thrown when the process does not have access to Where's App Group.
+ public struct AppGroupUnavailableError: Error {
+ public init() {}
+ }
+
+ public static let appGroupIdentifier = "group.com.stuff.where"
+ public static let snapshotFileName = "widget-snapshot.json"
+
+ public static var openWhereURL: URL {
+ guard let url = URL(string: "where://open") else {
+ preconditionFailure("The static Where URL is invalid")
+ }
+ return url
+ }
+
+ private let directory: URL
+
+ public init(directory: URL) {
+ self.directory = directory
+ }
+
+ public static func shared() throws -> WhereSurfaceStore {
+ guard let container = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: appGroupIdentifier,
+ ) else {
+ throw AppGroupUnavailableError()
+ }
+ return WhereSurfaceStore(directory: container)
+ }
+
+ public func read() throws -> WhereSurfaceDocument? {
+ let fileURL = directory.appending(path: Self.snapshotFileName)
+ guard let data = try WhereSurfaceFileCoordinator().read(from: fileURL) else { return nil }
+ return try JSONDecoder().decode(WhereSurfaceDocument.self, from: data)
+ }
+}
diff --git a/Where/WhereSurface/Tests/WhereSurfaceChangeNotificationTests.swift b/Where/WhereSurface/Tests/WhereSurfaceChangeNotificationTests.swift
new file mode 100644
index 000000000..ebec553e9
--- /dev/null
+++ b/Where/WhereSurface/Tests/WhereSurfaceChangeNotificationTests.swift
@@ -0,0 +1,8 @@
+import Testing
+import WhereSurface
+
+struct WhereSurfaceChangeNotificationTests {
+ @Test func notificationNameIsStableAcrossProcesses() {
+ #expect(WhereSurfaceChangeNotification.name == "com.stuff.where.surface.changed")
+ }
+}
diff --git a/Where/WhereSurface/Tests/WhereSurfaceDocumentTests.swift b/Where/WhereSurface/Tests/WhereSurfaceDocumentTests.swift
new file mode 100644
index 000000000..57558569b
--- /dev/null
+++ b/Where/WhereSurface/Tests/WhereSurfaceDocumentTests.swift
@@ -0,0 +1,53 @@
+import Foundation
+import Testing
+import WhereSurface
+
+struct WhereSurfaceDocumentTests {
+ @Test func oldWidgetDocumentDecodesWithoutGlanceFields() throws {
+ let data = Data(
+ """
+ {
+ "day": 0,
+ "year": 2026,
+ "dayRegions": ["us-CA"],
+ "totals": ["us-CA", 1]
+ }
+ """.utf8,
+ )
+
+ let document = try JSONDecoder().decode(WhereSurfaceDocument.self, from: data)
+
+ #expect(document.generatedAt == nil)
+ #expect(document.surface == nil)
+ }
+
+ @Test func widgetOnlyKeysAreIgnored() throws {
+ let region = WhereSurfaceSnapshot.Region(
+ id: "us-CA",
+ name: "California",
+ emoji: nil,
+ symbolName: nil,
+ )
+ let surface = WhereSurfaceSnapshot(
+ day: Date(timeIntervalSinceReferenceDate: 10),
+ todayRegions: [region],
+ year: 2026,
+ yearToDate: [.init(region: region, days: 42)],
+ )
+ let encoder = JSONEncoder()
+ let document = WhereSurfaceDocument(
+ generatedAt: Date(timeIntervalSinceReferenceDate: 20),
+ surface: surface,
+ )
+ var object = try #require(
+ JSONSerialization.jsonObject(with: encoder.encode(document)) as? [String: Any],
+ )
+ object["dayRegions"] = ["us-NY"]
+ object["totals"] = ["us-NY": 9]
+ let data = try JSONSerialization.data(withJSONObject: object)
+
+ let decoded = try JSONDecoder().decode(WhereSurfaceDocument.self, from: data)
+
+ #expect(decoded == document)
+ }
+}
diff --git a/Where/WhereSurface/Tests/WhereSurfaceFileCoordinatorTests.swift b/Where/WhereSurface/Tests/WhereSurfaceFileCoordinatorTests.swift
new file mode 100644
index 000000000..8da3fa798
--- /dev/null
+++ b/Where/WhereSurface/Tests/WhereSurfaceFileCoordinatorTests.swift
@@ -0,0 +1,47 @@
+import Foundation
+import Testing
+import WhereSurface
+
+struct WhereSurfaceFileCoordinatorTests {
+ @Test func missingFileReturnsNil() throws {
+ let directory = try makeTemporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let fileURL = directory.appending(path: "missing.json")
+
+ let data = try WhereSurfaceFileCoordinator().read(from: fileURL)
+
+ #expect(data == nil)
+ }
+
+ @Test func writeThenReadRoundTrips() throws {
+ let directory = try makeTemporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let fileURL = directory.appending(path: "snapshot.json")
+ let expected = Data("coordinated".utf8)
+ let coordinator = WhereSurfaceFileCoordinator()
+
+ try coordinator.write(expected, to: fileURL)
+
+ #expect(try coordinator.read(from: fileURL) == expected)
+ }
+
+ @Test func writeReplacesExistingContents() throws {
+ let directory = try makeTemporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let fileURL = directory.appending(path: "snapshot.json")
+ let coordinator = WhereSurfaceFileCoordinator()
+ try coordinator.write(Data("old".utf8), to: fileURL)
+ let replacement = Data("new".utf8)
+
+ try coordinator.write(replacement, to: fileURL)
+
+ #expect(try coordinator.read(from: fileURL) == replacement)
+ }
+
+ private func makeTemporaryDirectory() throws -> URL {
+ let directory = FileManager.default.temporaryDirectory
+ .appending(path: "WhereSurfaceFileCoordinatorTests-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ return directory
+ }
+}
diff --git a/Where/WhereSurface/Tests/WhereSurfaceSnapshotTests.swift b/Where/WhereSurface/Tests/WhereSurfaceSnapshotTests.swift
new file mode 100644
index 000000000..83ea6dc92
--- /dev/null
+++ b/Where/WhereSurface/Tests/WhereSurfaceSnapshotTests.swift
@@ -0,0 +1,26 @@
+import Foundation
+import Testing
+import WhereSurface
+
+struct WhereSurfaceSnapshotTests {
+ @Test func codableRoundTripPreservesPresentationData() throws {
+ let california = WhereSurfaceSnapshot.Region(
+ id: "us-CA",
+ name: "California",
+ emoji: "🌴",
+ symbolName: "sun.max.fill",
+ )
+ let snapshot = WhereSurfaceSnapshot(
+ day: Date(timeIntervalSinceReferenceDate: 10),
+ todayRegions: [california],
+ year: 2026,
+ yearToDate: [.init(region: california, days: 132)],
+ )
+
+ let data = try JSONEncoder().encode(snapshot)
+ let decoded = try JSONDecoder().decode(WhereSurfaceSnapshot.self, from: data)
+
+ #expect(decoded == snapshot)
+ #expect(decoded.yearToDate.first?.id == "us-CA")
+ }
+}
diff --git a/Where/WhereSurface/Tests/WhereSurfaceStoreTests.swift b/Where/WhereSurface/Tests/WhereSurfaceStoreTests.swift
new file mode 100644
index 000000000..3a71026db
--- /dev/null
+++ b/Where/WhereSurface/Tests/WhereSurfaceStoreTests.swift
@@ -0,0 +1,57 @@
+import Foundation
+import Testing
+import WhereSurface
+
+struct WhereSurfaceStoreTests {
+ @Test func missingArtifactReturnsNil() throws {
+ let directory = try makeTemporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ let document = try WhereSurfaceStore(directory: directory).read()
+
+ #expect(document == nil)
+ }
+
+ @Test func readsTheSurfaceOverlayFromWidgetJSON() throws {
+ let directory = try makeTemporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let expected = WhereSurfaceDocument(
+ generatedAt: Date(timeIntervalSinceReferenceDate: 20),
+ surface: WhereSurfaceSnapshot(
+ day: Date(timeIntervalSinceReferenceDate: 10),
+ todayRegions: [],
+ year: 2026,
+ yearToDate: [],
+ ),
+ )
+ let data = try JSONEncoder().encode(expected)
+ try data.write(
+ to: directory.appending(path: WhereSurfaceStore.snapshotFileName),
+ options: .atomic,
+ )
+
+ let document = try WhereSurfaceStore(directory: directory).read()
+
+ #expect(document == expected)
+ }
+
+ @Test func malformedArtifactThrows() throws {
+ let directory = try makeTemporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ try Data("not json".utf8).write(
+ to: directory.appending(path: WhereSurfaceStore.snapshotFileName),
+ )
+ let store = WhereSurfaceStore(directory: directory)
+
+ #expect(throws: DecodingError.self) {
+ try store.read()
+ }
+ }
+
+ private func makeTemporaryDirectory() throws -> URL {
+ let directory = FileManager.default.temporaryDirectory
+ .appending(path: "WhereSurfaceStoreTests-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ return directory
+ }
+}
diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md
index 9f3cc3dcc..2a8d78741 100644
--- a/Where/WhereUI/AGENTS.md
+++ b/Where/WhereUI/AGENTS.md
@@ -4,7 +4,7 @@ WhereUI is the SwiftUI layer of the Where feature: the screens, the shared
components and widget views, and the `@Observable` view models that
orchestrate `WhereCore` for them (`WhereModel`, the `WhereSession`
coordinator, and the scoped `YearReportModel` / `ResolveModel` /
-`BackupModel` / `RemindersSettingsModel`). Layering, localization, preview,
+`BackupModel` / `RemindersSettingsModel` / `DevicesSettingsModel`). Layering, localization, preview,
and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md)
— read that and the root [`AGENTS.md`](../../AGENTS.md) first.
@@ -16,6 +16,9 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md)
- Composition is the one exception: `WhereScope` and `WhereModel` decide which
world the app is logged in to and assemble it. That's launch wiring, not
domain logic — see [Scopes and the launch](../AGENTS.md#scopes-and-the-launch).
+- Keep onboarding's existing-iCloud path write-free beyond its completion and
+ local-recording intent: it opens the real scope, seeds no regions, and
+ resolves the gate while CloudKit imports continue.
- Flyover infrastructure stays under `#if DEBUG` in
[`Sources/Developer/Flyover`](Sources/Developer/Flyover), while each
represented screen declares a DEBUG-only `WhereFlyoverProviding` extension
@@ -54,6 +57,20 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md)
days. Views don't read `\.isCapturingSnapshot` to branch themselves; capture
handling stays inside the shared component.
+## Navigation shell
+
+`MainTabs` owns the scene-scoped report lifecycle, while `MainView` selects
+`PhoneMainTabs` on iPhone and `MainSplitView` on iPad and Mac Catalyst. Both
+surfaces route the same `MainSection` identities to the existing Locations,
+Your Year, and Settings screens.
+
+- Select the shell by device family through `MainInterfaceStyle`, never the
+ current size class.
+- Keep `MainSplitView` as the stable `NavigationSplitView` root and let the
+ system collapse its columns.
+- Preserve every section's stack and local presentation state when sidebar
+ selection changes.
+
## Design system — `WhereStylesheet`
All appearance tokens — geometry, fonts, colors, motion — live in
diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md
index 7e3425a04..634ab3895 100644
--- a/Where/WhereUI/README.md
+++ b/Where/WhereUI/README.md
@@ -21,11 +21,17 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's
- **`RootView`** — the app root: the typed launch plan (via
[`LifecycleKit`](../../Shared/LifecycleKit), rendered by
[`LifecycleKitUI`](../../Shared/LifecycleKitUI)'s container) gated in front of
- `MainTabs`, the Liquid Glass tab bar over three tabs — Locations, Your Year,
- 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
+ `MainTabs`, the scene-scoped owner of the adaptive logged-in shell.
+ `MainView` presents a Liquid Glass `PhoneMainTabs` on iPhone and a two-column
+ `MainSplitView` on iPad and Mac Catalyst. Both expose the same three sections
+ — Locations, Your Year, Settings — while the split shell keeps its
+ `NavigationSplitView` root as the window resizes and lets the system collapse
+ its columns. Its page-backed detail host retains each section's navigation
+ and presentation state while sidebar selection changes. 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
development tools as separate sections), and bundled-data provenance, each
vended by whoever owns it rather than listed in the view; it renders an
@@ -62,13 +68,17 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's
`endSession()`, `resetPreferences()`).
- **`WhereSession`** — the always-on coordinator: tracking + location
authorization state and the intents that drive them (`requestPermission()`,
- `startTracking()` / `stopTracking()`, `refreshWidgetSnapshot()`). It holds no
- presentation state of its own.
+ per-device recording changes, `startTracking()` / `stopTracking()`,
+ `refreshWidgetSnapshot()`). A management-only session exposes no current
+ recording device and never requests or starts local location services, while
+ retaining remote-device management. It holds no presentation state of its
+ own.
- **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
+ **`DevicesSettingsModel`** (synced installation names, policy, status, and
+ archival). Each orchestrates `WhereServices`; none reimplements Core rules.
### Reusable views & styling
@@ -81,12 +91,18 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's
tracked-region set + appearances before resolving the gate. The intro also
offers **Restore from a backup**, which opens the store, imports a backup
(`.replace`), and skips the manual pick/customize steps straight to the
- location ask; and **Explore a demo**, which builds a throwaway in-memory
+ location ask; **Join Existing iCloud Data**, which opens the real scope
+ without seeding regions or enabling local recording and enters while remote
+ imports continue; and **Explore a demo**, which builds a throwaway in-memory
world behind a captioned launch splash and enters it.
- **`RegionPickerView` / `RegionCustomizeView`** — the shared primary-region
picker (segmented map/list) and per-region color/emoji/icon customization,
backed by `PrimaryRegionSelectionModel`. Reused by onboarding and the Settings
`RegionsSettingsView` editor.
+- **`DevicesSettingsView`** — Settings’ per-installation automatic-recording
+ controls. It distinguishes desired policy from acknowledged physical state,
+ labels the current installation, permits synced nicknames, and archives only
+ remote devices while preserving their history.
- **Widget views** — the shared renderers the **WhereWidgets** extension draws
with: `TodayWidgetView`, `YearTotalsWidgetView`, and the accessory family
(`TodayInlineAccessoryView`, `TodayCircularAccessoryView`,
@@ -119,8 +135,8 @@ target's dependencies in [`Package.swift`](../../Package.swift):
## Quick start
The app target is deliberately tiny — it builds the model + launch runner at
-startup (so CoreLocation is wired for background relaunch) and hands them to
-`RootView`:
+startup (wiring CoreLocation for background relaunch on participating
+iPhone/iPad hosts and skipping it on Catalyst) and hands them to `RootView`:
```swift
import SwiftUI
diff --git a/Where/WhereUI/SnapshotTests/DevicesSettingsViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/DevicesSettingsViewSnapshotTests.swift
new file mode 100644
index 000000000..d916ab976
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/DevicesSettingsViewSnapshotTests.swift
@@ -0,0 +1,10 @@
+import SnapshotKitTesting
+import Testing
+@testable import WhereUI
+
+@MainActor
+struct DevicesSettingsViewSnapshotTests {
+ @Test func devices() async {
+ await assertSnapshots(of: DevicesSettingsView.self)
+ }
+}
diff --git a/Where/WhereUI/SnapshotTests/MacSummaryWidgetViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/MacSummaryWidgetViewSnapshotTests.swift
new file mode 100644
index 000000000..0fa0e7cf4
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/MacSummaryWidgetViewSnapshotTests.swift
@@ -0,0 +1,10 @@
+import SnapshotKitTesting
+import Testing
+@testable import WhereUI
+
+@MainActor
+struct MacSummaryWidgetViewSnapshotTests {
+ @Test func macSummaryWidget() async {
+ await assertSnapshots(of: MacSummaryWidgetView.self)
+ }
+}
diff --git a/Where/WhereUI/SnapshotTests/MainViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/MainViewSnapshotTests.swift
new file mode 100644
index 000000000..be44cf33b
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/MainViewSnapshotTests.swift
@@ -0,0 +1,10 @@
+import SnapshotKitTesting
+import Testing
+@testable import WhereUI
+
+@MainActor
+struct MainViewSnapshotTests {
+ @Test func mainView() async {
+ await assertSnapshots(of: MainView.self)
+ }
+}
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png
new file mode 100644
index 000000000..a16c8d66c
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:09a12fb64def83240d8a3b1cbbfc2a55858f977e72950608aa055d2b45c28b27
+size 324417
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png
new file mode 100644
index 000000000..0af61264e
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:59a6b267ce592ab9356f90d1ffb0906e7e20efb0fa337585daaa3ef05882d45e
+size 597111
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png
new file mode 100644
index 000000000..1c10088eb
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:04b0b6cf1451ad9630df71766d12266d358c40ff57ddece1f8867db82ac33015
+size 513002
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png
new file mode 100644
index 000000000..37d13eb30
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:49513eca25b556d6dd0f9275ff90f327628d06714c5503976275d99b4bf71d8d
+size 322596
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png
new file mode 100644
index 000000000..469f2b98b
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:464f6e96d7ac8d5012d4c95ca9052e506dade4f4959812ca3e6ce5e6e33e9a0e
+size 334169
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png
new file mode 100644
index 000000000..bcbd4585a
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ddeaab4b0a4394bf94028afd91c8ab552b357fe2a79b2978f079d5a036b909cd
+size 194032
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png
new file mode 100644
index 000000000..ac8e6ea94
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cd7d236fe010e337254848e25a4a131d714f3b3ac5121f08bea377fa69c3e4f3
+size 427149
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png
new file mode 100644
index 000000000..a3681ccdb
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a0dd307c74e6f5dc1ec772ecc507f2ef5a86daefb304148b96732608e920d571
+size 225100
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png
new file mode 100644
index 000000000..9873de0bb
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:170fcbcab16a05f4c6aeed9b22ad69a8f95dd97ecaba5f43b58f3d6ab7e8b1f3
+size 193974
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png
new file mode 100644
index 000000000..1c1e2b2ad
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:085b4071ea092f287de76235dbf4fcc098307c89af1b1e6638b1e51a1d9f330c
+size 197879
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall.png
new file mode 100644
index 000000000..03e3ff76b
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0a9b31c40512d793f8f1c792cf21684a02cf64e371bffbcda7f11e5ef39045df
+size 40348
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall_dark.png
new file mode 100644
index 000000000..96f69d4e1
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall_dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c20484c4d713103b5a732584f745756531438b710034dcd42d44a20f001df86c
+size 40611
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium.png
new file mode 100644
index 000000000..cc709ae9f
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d8c201c52fedf3e52cf7873f1cc01d5d1a9bfa00d8f2c449727cf277973131c0
+size 63858
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium_dark.png
new file mode 100644
index 000000000..c979516ae
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium_dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:990992b9b96f8b50f7dec22e39448af60ea74675daddf6ea226a0c3b71a5327c
+size 64238
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad.png
new file mode 100644
index 000000000..8225d7b0e
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2cf2d45683538893c38f21ad334e824fc1a446fb506cc4696d9f146dc4a35d6a
+size 2266513
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad_dark.png
new file mode 100644
index 000000000..e9b99d707
--- /dev/null
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad_dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d4b06be03f2701686af9fe650c61778c5abc661483cd1651884bd143b784a4c8
+size 2935628
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad.png
index 995db43fa..fad3a3706 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:9b66d218b4a3a18ea79280099a62f1675114c0a2d451d91b87a4f3673da5b543
-size 1033547
+oid sha256:11c2ead13ee496531d39864f6b9f0692681687fea95866beb9a60065c67932c4
+size 1047941
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_accessibility.png
index 50633f909..72c702e83 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_accessibility.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_accessibility.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6f4f8492e5e2ec3d6459d86b081e0de5523742cfb2c37849b436218c74600ee1
-size 2031769
+oid sha256:f486b1c1b03c2ac55da0e297ccaef1941f52107389b8263d33ef939e8e398e3d
+size 2057272
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_ax5.png
index 7c18bd96d..20570ec68 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_ax5.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_ax5.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3f5d040ca6202e9b47def5b7dca42b73a7273853a4c9d327a5a78c807f0fc7da
-size 1318137
+oid sha256:b55797c98a221d085d9d904a85617ca697c628cd17aba4d323f38c61c2bea81f
+size 1376932
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_contrast.png
index 1a91f836a..53323de31 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_contrast.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_contrast.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:bc0e031e06c44a3a065f1dda94b5f2d8efece1ed820296fe2882ebd41096c03d
-size 988788
+oid sha256:9eafb3309b46f5a2ac6b0a1036f78784912d40d211696ed5c27e6ea0e21e1fb3
+size 1006297
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_dark.png
index d028495de..5e95d74eb 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_dark.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_dark.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:812f7b15f3141668127a29331e01848dbdcab7be1534ef02cf198d672efb867f
-size 1018593
+oid sha256:0d2291836f59f94af6556731e9de5c6ec8ee742e0dd658524425c06bf1dcaf4c
+size 1048518
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone.png
index b863acc64..ee0a6a0f1 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:348746d342faccc2a37075960a746eff3434e86a750d607b0b896eb73be0c568
-size 533542
+oid sha256:2f2622544a18f36cb0dfae2d94f0d37d64d353cabef35c4d445366ca70322e92
+size 546679
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_accessibility.png
index 8de3fd03f..c71e3e187 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_accessibility.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_accessibility.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:273941952b4ee47a3ec724cc31fdad99346fa238d8af17065f9a6bd3af84d24b
-size 912480
+oid sha256:bd97f05072809799e2f688cc628eb9ff233e8dc9a764cc20b2a86f1a06cfcd45
+size 937214
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_ax5.png
index 8408dae8e..4a1442ebf 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_ax5.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_ax5.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8018f827540c6297e3ce05aae29f7522384c2dc1bff01e70e445a6b4b97eaf63
-size 724927
+oid sha256:70e1157199a23ff3750552c147a576d570ca1adc0a4e47ba8712a7c55037f69a
+size 760374
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_contrast.png
index 0c5c29367..a75ae060f 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_contrast.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_contrast.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0d984f769764e1ce225d1cc9370fd621f360abfd7aeae528715ba8f1175936d9
-size 512481
+oid sha256:1cc0006b3c628325fd4bb7c0f61e78100058908f20e5fd8094b5a016c940b382
+size 526556
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_dark.png
index 2642c1f78..f813b2e2c 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_dark.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_dark.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b6bfd6d81cbb9b8c8f9a24d568083fa34cc7341478667be58ef3411b06579ce4
-size 521892
+oid sha256:82bc8f6a67f78a1650b8a4ceaa6df4e4cb2a5e0c652e25dbd50aa7df4068d0b3
+size 534708
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png
index 80c3bc7f8..7fe26c320 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a4b7de340cb9e6efa065ab7a42b92a729f95b43dd779dc52c66afa6c8ccd5b10
-size 555343
+oid sha256:28aa85046aac8fc67a899d7dafae6ad9b0e4797978607b98ea6a5d325588d72f
+size 554768
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_ax5.png
index 0cb8375d4..f97a5932a 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_ax5.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_ax5.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5f4fac0fc3de277c0f916373e6632e736d4517f6d20ad82e6dab900071931f15
-size 443099
+oid sha256:56f7624a69ed6fc663c97a47b5c83355a391dd2345de5bff0ca6029a7af72527
+size 444562
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png
index c310a69b6..ab91df7ee 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f68b3e0546d2940a0d45067b8a6d54908c7f47b04efd74b6156a5306359e41cb
-size 232386
+oid sha256:dc1ecd82c0f9e6d7a20894ecbb4f10e2f4f753f37ef2a0aafad028c5eb30ff27
+size 230280
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png
index d660a6c17..d3a67f089 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2592f6cdc44017d2c76228566b0eded57ce8a299006d3977760d82128259f9a1
-size 416443
+oid sha256:731da1feea6c00745a2a15f210c48894a738e385c216fc798f20456ca00bf1d4
+size 419847
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_ax5.png
index 643a38795..4fbfbb756 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_ax5.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_ax5.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:78a668217041fcb4e90ad6766a9f86f4f9f580b26c05169730cf3787b5e9a4bf
-size 279027
+oid sha256:138b281912746f4a89b0ae54cf9a7785b2775585d97b136bc13083434bd77e0a
+size 279288
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png
index 69a5fbc32..f79bc892a 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:75f2ebbb3904dfa29cddb285f6d0f2b60e4971b948bdef87baae4a2e9a2b933c
-size 221907
+oid sha256:b4fd5d80ec09a7c7bde1021c2fe5796b81ede2dc6d65656f93221cf3dbabbdc9
+size 222594
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png
index 31f0a8642..773bb3732 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:fe553dd669e74c924ebee2bcecdb24119018b44a29d54b445b08cba74c384d14
-size 249355
+oid sha256:3d28bf82364b9168b339eb9b97a3a00c18f1b6d4a6be150bf930c37e9142cd66
+size 219532
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png
index 419ce328e..760b1dc72 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b7ef710986474e5bf35073d6d0eaf85b9539194a7d23fbd1882c8ff440ac95d4
-size 233575
+oid sha256:6eb5d75f3487193f7e00dbaae8e755031b41b24345bbc3d3f3b0e60bf8be615b
+size 230211
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png
index 564b1daf0..0f5c10eed 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:50abca073ac4b54970cc7390ae5f8aeb62a88ab0e46dc869c41912a70055d70f
-size 240670
+oid sha256:08102b36a5229a8ededc69254e49dbd5617d3e6201cb0e25a1f7d27ec27fe9ed
+size 242062
diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png
index 416ea0323..1ad6d839a 100644
--- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png
+++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8ac665a90276a8f84203344be46838263c524bf59dc8629853a4f8c081ff94cf
-size 265977
+oid sha256:43b807e1206cc97aede390a76823fb490782a9bfa3e1725e13aa7889b50ddd44
+size 254460
diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift
index 3fd0adb56..aeda0357e 100644
--- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift
+++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift
@@ -88,7 +88,7 @@
AddEvidenceView.flyoverData,
LoggedDaysView.flyoverData,
RegionsSettingsView.flyoverData,
- LocationSettingsView.flyoverData,
+ DevicesSettingsView.flyoverData,
AlertsSettingsView.flyoverData,
AppearanceSettingsView.flyoverData,
AppIconView.flyoverData,
@@ -114,6 +114,7 @@
TodayWidgetView.flyoverData,
TodayInlineAccessoryView.flyoverData,
TodayCircularAccessoryView.flyoverData,
+ MacSummaryWidgetView.flyoverData,
YearTotalsWidgetView.flyoverData,
YearTotalsRectangularAccessoryView.flyoverData,
]
diff --git a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift
new file mode 100644
index 000000000..b4068cbd0
--- /dev/null
+++ b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift
@@ -0,0 +1,84 @@
+import Foundation
+import UIKit
+import WhereCore
+
+/// Resolves local recording participation at the app composition boundary.
+///
+/// On a participating iPhone or iPad, the first available
+/// `identifierForVendor` (or a generated fallback before first unlock) is
+/// persisted immediately. Every later launch reuses that choice, so a
+/// pre-unlock headless wake cannot register one identity and the foreground
+/// launch silently switch to another. Catalyst returns management-only before
+/// reading or writing an identity.
+@MainActor
+enum CurrentRecordingDeviceProvider {
+ private enum Key: String {
+ case recordingDeviceID = "where.recordingDeviceID"
+ }
+
+ static func supportsLocalRecording(idiom: UIUserInterfaceIdiom) -> Bool {
+ #if targetEnvironment(macCatalyst)
+ return false
+ #else
+ switch idiom {
+ case .phone, .pad: true
+ case .unspecified, .tv, .carPlay, .mac, .vision: false
+ @unknown default: false
+ }
+ #endif
+ }
+
+ static func participation(
+ defaults: UserDefaults,
+ idiom: UIUserInterfaceIdiom,
+ ) -> RecordingParticipation {
+ guard supportsLocalRecording(idiom: idiom) else { return .managementOnly }
+
+ let device = UIDevice.current
+ let id: UUID
+ if let stored = defaults.string(forKey: Key.recordingDeviceID.rawValue)
+ .flatMap(UUID.init(uuidString:))
+ {
+ id = stored
+ } else {
+ id = device.identifierForVendor ?? UUID()
+ defaults.set(id.uuidString, forKey: Key.recordingDeviceID.rawValue)
+ }
+
+ let kind: RecordingDeviceKind = switch idiom {
+ case .phone: .phone
+ case .pad: .tablet
+ case .unspecified, .tv, .carPlay, .mac, .vision: .other
+ @unknown default: .other
+ }
+ return .recording(
+ device: CurrentRecordingDevice(
+ id: RecordingDeviceID(rawValue: id),
+ systemName: device.model,
+ kind: kind,
+ ),
+ defaultEnabledForNewInstallation: idiom == .phone,
+ )
+ }
+
+ /// Demo mode mirrors the host's physical-recording capability without
+ /// minting a real installation identity. A management-only host stays
+ /// management-only even inside its throwaway demo world.
+ static func demoParticipation(
+ supportsLocalRecording: Bool,
+ ) -> RecordingParticipation {
+ guard supportsLocalRecording else { return .managementOnly }
+ return .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: true,
+ )
+ }
+
+ static var demoParticipationForCurrentHost: RecordingParticipation {
+ demoParticipation(
+ supportsLocalRecording: supportsLocalRecording(
+ idiom: UIDevice.current.userInterfaceIdiom,
+ ),
+ )
+ }
+}
diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift
index 66184d3ee..956860976 100644
--- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift
+++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift
@@ -1,6 +1,7 @@
import LifecycleKit
import PeriscopeCore
import SwiftUI
+import UIKit
import UserNotifications
import WhereCore
@@ -237,12 +238,18 @@ public final class WhereBootstrap: WhereScopeAssembling {
private static let logger = WhereLog.root(WhereLaunchLog.self)
private var locationSource: CoreLocationSource?
+ private let userInterfaceIdiom: UIUserInterfaceIdiom
- public init() {}
+ public init() {
+ userInterfaceIdiom = UIDevice.current.userInterfaceIdiom
+ }
/// Install the `CLLocationManager` + delegate right away, without touching
/// the store. Idempotent.
public func prepareLocation() {
+ guard CurrentRecordingDeviceProvider.supportsLocalRecording(
+ idiom: userInterfaceIdiom,
+ ) else { return }
guard locationSource == nil else { return }
locationSource = CoreLocationSource()
}
@@ -264,7 +271,15 @@ public final class WhereBootstrap: WhereScopeAssembling {
/// `.failed`, so without this line the failure would leave no trace
/// anywhere.
public func makeServices() async throws -> WhereServices {
- let source = locationSource ?? CoreLocationSource()
+ let participation = CurrentRecordingDeviceProvider.participation(
+ defaults: .standard,
+ idiom: userInterfaceIdiom,
+ )
+ let source: any LocationSource = if participation.supportsLocalRecording {
+ locationSource ?? CoreLocationSource()
+ } else {
+ IdleLocationSource()
+ }
locationSource = nil
do {
let store = try await Task.detached(priority: .userInitiated) {
@@ -273,6 +288,7 @@ public final class WhereBootstrap: WhereScopeAssembling {
let services = try await WhereServices.make(
store: store,
locationSource: source,
+ recordingParticipation: participation,
// The real world's seams, named here because this is the only
// place that wants them: the demo scope builds the same stack
// out of no-ops, and every test and preview gets no-ops by
diff --git a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift
index 373da80a9..d0ba15c76 100644
--- a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift
+++ b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift
@@ -6,10 +6,16 @@ import PeriscopeCore
enum OnboardingViewLog: LogEvent {
case regionCommitFailed(description: String)
case backupRestoreFailed(description: String)
+ /// Opening the real scope for the explicit iCloud join path failed. The
+ /// intro remains available with a retry action.
+ case joinExistingDataFailed(description: String)
/// The user declined (or is restricted from) location access at the
/// onboarding ask. Expected, not a failure: tracking stays
/// intended-but-inactive and Settings offers the route to grant it.
case locationPermissionDenied
+ /// Persisting the user's explicit current-device recording choice failed,
+ /// so the gate cannot safely continue under an older synced policy.
+ case recordingChoiceFailed(description: String)
/// Opening the user's store failed, so onboarding can't hand the launch a
/// world to run in. Fails the gate, landing on the failure surface.
case scopeCreationFailed(description: String)
@@ -21,9 +27,11 @@ enum OnboardingViewLog: LogEvent {
var level: LogLevel {
switch self {
- case .regionCommitFailed, .backupRestoreFailed, .demoBuildFailed: .warning
+ case .regionCommitFailed, .backupRestoreFailed, .joinExistingDataFailed,
+ .demoBuildFailed:
+ .warning
case .locationPermissionDenied: .info
- case .scopeCreationFailed: .error
+ case .recordingChoiceFailed, .scopeCreationFailed: .error
}
}
@@ -33,8 +41,12 @@ enum OnboardingViewLog: LogEvent {
"Failed to commit onboarding region picks: \(description)"
case let .backupRestoreFailed(description):
"Onboarding backup restore failed: \(description)"
+ case let .joinExistingDataFailed(description):
+ "Joining existing iCloud data failed: \(description)"
case .locationPermissionDenied:
"Location access declined during onboarding"
+ case let .recordingChoiceFailed(description):
+ "Failed to persist the onboarding recording choice: \(description)"
case let .scopeCreationFailed(description):
"Failed to open the store during onboarding: \(description)"
case let .demoBuildFailed(description):
diff --git a/Where/WhereUI/Sources/Logging/WhereSessionLog.swift b/Where/WhereUI/Sources/Logging/WhereSessionLog.swift
index d9850d5c0..6c4f39b2e 100644
--- a/Where/WhereUI/Sources/Logging/WhereSessionLog.swift
+++ b/Where/WhereUI/Sources/Logging/WhereSessionLog.swift
@@ -24,6 +24,7 @@ enum WhereSessionLog: LogEvent {
case permissionGranted(status: String)
case trackingEnabled
case stoppedBackgroundTracking
+ case recordingReconcileFailed(description: String)
case remindersUnauthorized
case summaryUnauthorized
case issueAlertsUnauthorized
@@ -35,7 +36,8 @@ enum WhereSessionLog: LogEvent {
var level: LogLevel {
switch self {
case .whenInUseOnly, .locationAccessDenied, .remindersUnauthorized,
- .summaryUnauthorized, .issueAlertsUnauthorized, .regionStylesLoadFailed:
+ .summaryUnauthorized, .issueAlertsUnauthorized, .regionStylesLoadFailed,
+ .recordingReconcileFailed:
.warning
case .backgroundTrackingStarted, .backgroundTrackingStopped, .permissionGranted,
.trackingEnabled, .stoppedBackgroundTracking, .erasedSession:
@@ -59,6 +61,8 @@ enum WhereSessionLog: LogEvent {
"Tracking enabled with background authorization"
case .stoppedBackgroundTracking:
"Stopped background tracking"
+ case let .recordingReconcileFailed(description):
+ "Failed to reconcile device recording policy: \(description)"
case .remindersUnauthorized:
"Logging reminders enabled but notifications not authorized"
case .summaryUnauthorized:
diff --git a/Where/WhereUI/Sources/MainInterfaceStyle.swift b/Where/WhereUI/Sources/MainInterfaceStyle.swift
new file mode 100644
index 000000000..81d35fb85
--- /dev/null
+++ b/Where/WhereUI/Sources/MainInterfaceStyle.swift
@@ -0,0 +1,21 @@
+#if canImport(UIKit)
+ import UIKit
+#endif
+
+/// The logged-in shell chosen from the device family, not the current window
+/// width, so an iPad keeps its split-view navigation as the window resizes.
+enum MainInterfaceStyle {
+ case tabs
+ case split
+
+ @MainActor
+ static var current: Self {
+ #if targetEnvironment(macCatalyst)
+ .split
+ #elseif canImport(UIKit)
+ UIDevice.current.userInterfaceIdiom == .pad ? .split : .tabs
+ #else
+ .split
+ #endif
+ }
+}
diff --git a/Where/WhereUI/Sources/MainSection.swift b/Where/WhereUI/Sources/MainSection.swift
new file mode 100644
index 000000000..72f079a02
--- /dev/null
+++ b/Where/WhereUI/Sources/MainSection.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+
+/// A stable identity shared by the phone tab bar and the iPad/Mac sidebar.
+enum MainSection: Hashable, CaseIterable, Identifiable {
+ case locations
+ case year
+ case settings
+
+ var id: Self {
+ self
+ }
+
+ var title: String {
+ switch self {
+ case .locations:
+ String(localized: .tabLocations)
+ case .year:
+ String(localized: .tabYear)
+ case .settings:
+ String(localized: .tabSettings)
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .locations:
+ "location.fill"
+ case .year:
+ "calendar"
+ case .settings:
+ "gearshape.fill"
+ }
+ }
+}
diff --git a/Where/WhereUI/Sources/MainSplitView.swift b/Where/WhereUI/Sources/MainSplitView.swift
new file mode 100644
index 000000000..79852f8c8
--- /dev/null
+++ b/Where/WhereUI/Sources/MainSplitView.swift
@@ -0,0 +1,47 @@
+import SwiftUI
+
+/// The regular-device logged-in interface. The split view remains the root as
+/// an iPad or Catalyst window resizes, letting the system collapse its columns
+/// without replacing the navigation model.
+struct MainSplitView: View {
+ let report: YearReportModel
+
+ @State private var selection: MainSection? = .locations
+
+ var body: some View {
+ NavigationSplitView {
+ List(MainSection.allCases, selection: $selection) { section in
+ Label(section.title, systemImage: section.systemImage)
+ .tag(section)
+ }
+ } detail: {
+ // A page-style TabView keeps every section's NavigationStack and
+ // local presentation state alive while the sidebar changes the
+ // visible section. Its own tab chrome is intentionally hidden.
+ TabView(selection: $selection) {
+ Tab(value: MainSection?.some(.locations)) {
+ LocationsView(report: report)
+ }
+
+ Tab(value: MainSection?.some(.year)) {
+ YearView(report: report)
+ }
+
+ Tab(value: MainSection?.some(.settings)) {
+ SettingsView(report: report)
+ }
+ }
+ .tabViewStyle(.page(indexDisplayMode: .never))
+ }
+ .navigationSplitViewStyle(.balanced)
+ }
+}
+
+#if DEBUG
+ #Preview {
+ MainSplitView(report: PreviewSupport.loadedYearReportModel())
+ .environment(PreviewSupport.loadedModel())
+ .environment(PreviewSupport.loadedSession())
+ .whereBroadwayRoot()
+ }
+#endif
diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift
index 484cccc5c..1fcf74b65 100644
--- a/Where/WhereUI/Sources/MainTabs.swift
+++ b/Where/WhereUI/Sources/MainTabs.swift
@@ -1,28 +1,18 @@
import SwiftUI
import WhereCore
-/// The logged-in tab bar — the launch *destination* once the runner reaches
-/// `.ready`, not a launch step. Owns the scene-scoped ``YearReportModel`` as
-/// `@State` and drives its store-change subscription from `scenePhase` (active →
-/// subscribe + pull, background → cancel — closing the headless-relaunch rescan
-/// leak).
+/// The adaptive logged-in shell — the launch *destination* once the runner
+/// reaches `.ready`, not a launch step. Owns the scene-scoped
+/// ``YearReportModel`` as `@State` and drives its store-change subscription from
+/// `scenePhase` (active → subscribe + pull, background → cancel — closing the
+/// headless-relaunch rescan leak).
///
-/// Three fixed tabs — Locations, Your Year, Settings. Elsewhere is folded into
-/// Locations (an entry card) and Resolve into a Locations toolbar button; the
-/// data screens (attachments, logged days, regions) live in the Settings "Data"
-/// group. The tabs receive the report by explicit init injection (compile-
-/// checked wiring); the always-on `WhereSession` coordinator stays in the
-/// environment.
+/// Phones keep the three-tab interface while iPad and Mac Catalyst use the same
+/// three sections in a two-column split view. The sections receive the report by
+/// explicit init injection (compile-checked wiring); the always-on
+/// `WhereSession` coordinator stays in the environment.
struct MainTabs: View {
- /// Identity for the tab-bar selection.
- private enum TabID: Hashable {
- case locations
- case year
- case settings
- }
-
@State private var report: YearReportModel
- @State private var selection: TabID = .locations
@Environment(\.scenePhase) private var scenePhase
/// Build the scene's report model from the coordinator's service layer.
@@ -39,53 +29,29 @@ struct MainTabs: View {
}
var body: some View {
- TabView(selection: $selection) {
- Tab(
- String(localized: .tabLocations),
- systemImage: "location.fill",
- value: TabID.locations,
- ) {
- LocationsView(report: report)
- .reportingDeveloperTabBarInset()
+ MainView(report: report, interfaceStyle: .current)
+ // Subscribe + pull once the scene is on screen, and again whenever it
+ // returns to the foreground; cancel the subscription on background so a
+ // backgrounded scene drives no refreshes.
+ .task { await report.activate() }
+ .onChange(of: scenePhase) { _, newPhase in
+ switch newPhase {
+ case .active:
+ Task { await report.activate() }
+ case .background:
+ report.deactivate()
+ case .inactive:
+ break
+ @unknown default:
+ break
+ }
}
-
- Tab(String(localized: .tabYear), systemImage: "calendar", value: TabID.year) {
- YearView(report: report)
- .reportingDeveloperTabBarInset()
- }
-
- Tab(
- String(localized: .tabSettings),
- systemImage: "gearshape.fill",
- value: TabID.settings,
- ) {
- SettingsView(report: report)
- .reportingDeveloperTabBarInset()
- }
- }
- // Keep the tab bar fixed — don't minimize it as content scrolls.
- .tabBarMinimizeBehavior(.never)
- // Subscribe + pull once the scene is on screen, and again whenever it
- // returns to the foreground; cancel the subscription on background so a
- // backgrounded scene drives no refreshes.
- .task { await report.activate() }
- .onChange(of: scenePhase) { _, newPhase in
- switch newPhase {
- case .active:
- Task { await report.activate() }
- case .background:
- report.deactivate()
- case .inactive:
- break
- @unknown default:
- break
- }
- }
}
}
#if DEBUG
private struct MainTabsPreview: View {
+ private let model = PreviewSupport.loadedModel()
private let session = PreviewSupport.loadedSession()
var body: some View {
@@ -94,6 +60,7 @@ struct MainTabs: View {
initialReport: PreviewSupport.sampleReport(),
selectedYear: PreviewSupport.year,
)
+ .environment(model)
.environment(session)
.whereBroadwayRoot()
}
diff --git a/Where/WhereUI/Sources/MainView.swift b/Where/WhereUI/Sources/MainView.swift
new file mode 100644
index 000000000..d2b3d11d6
--- /dev/null
+++ b/Where/WhereUI/Sources/MainView.swift
@@ -0,0 +1,65 @@
+import SnapshotKit
+import SwiftUI
+
+/// Selects the device-family-appropriate presentation of the logged-in
+/// sections while keeping report ownership in ``MainTabs``.
+struct MainView: View {
+ let report: YearReportModel
+ let interfaceStyle: MainInterfaceStyle
+
+ var body: some View {
+ switch interfaceStyle {
+ case .tabs:
+ PhoneMainTabs(report: report)
+ case .split:
+ MainSplitView(report: report)
+ }
+ }
+}
+
+#if DEBUG
+ extension MainView: SnapshotProviding {
+ static var snapshots: [SnapshotCase] {
+ whereSnapshot(
+ name: "Split",
+ configurations: SnapshotConfiguration.combinations(
+ devices: [.iPad],
+ colorSchemes: [.light, .dark],
+ ),
+ settle: .settledAtLeast(minDuration: 1.0),
+ ) {
+ MainView(
+ report: PreviewSupport.loadedYearReportModel(),
+ interfaceStyle: .split,
+ )
+ .environment(PreviewSupport.loadedModel())
+ .environment(PreviewSupport.loadedSession())
+ // SnapshotKit's fixed iPad frame still runs inside the
+ // checkout's iPhone simulator. Supply the regular-width trait
+ // that a real iPad/Catalyst window contributes so this
+ // reference actually guards the two-column presentation.
+ .environment(\.horizontalSizeClass, .regular)
+ }
+ }
+ }
+
+ #Preview("Phone tabs") {
+ MainView(
+ report: PreviewSupport.loadedYearReportModel(),
+ interfaceStyle: .tabs,
+ )
+ .environment(PreviewSupport.loadedModel())
+ .environment(PreviewSupport.loadedSession())
+ .whereBroadwayRoot()
+ }
+
+ #Preview("iPad and Mac split") {
+ MainView(
+ report: PreviewSupport.loadedYearReportModel(),
+ interfaceStyle: .split,
+ )
+ .environment(PreviewSupport.loadedModel())
+ .environment(PreviewSupport.loadedSession())
+ .whereBroadwayRoot()
+ }
+#endif
diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift
index a4d4281f6..728c86f83 100644
--- a/Where/WhereUI/Sources/Model/WhereModel.swift
+++ b/Where/WhereUI/Sources/Model/WhereModel.swift
@@ -142,6 +142,40 @@ public final class WhereModel {
Self.logger { .onboardingCompleted }
}
+ /// Persist the local recording choice made during onboarding as both the
+ /// launch seed and an explicit synced policy for this installation.
+ ///
+ /// The policy write comes first so a restored policy cannot win after the
+ /// onboarding gate resolves. A management-only process has no local
+ /// recording identity, so it retains only the preference seed.
+ func applyOnboardingRecordingChoice(
+ _ enabled: Bool,
+ in scope: WhereScope,
+ ) async throws {
+ if let currentDeviceID = scope.services.recording.currentDevice?.id {
+ _ = try await scope.services.recording.setEnabled(
+ enabled,
+ for: currentDeviceID,
+ initialEnabled: enabled,
+ )
+ }
+ scope.preferences.wantsTracking = enabled
+ }
+
+ /// Join the user's existing iCloud-backed world without seeding regions or
+ /// enrolling this installation in automatic recording.
+ ///
+ /// Opening the scope starts SwiftData's normal CloudKit synchronization;
+ /// before onboarding completes, the current installation gets an explicit
+ /// synced off policy so an older enabled row cannot start local recording.
+ /// The user can then enter the app while remote imports continue to arrive
+ /// through the ordinary store-change stream.
+ func joinExistingData() async throws {
+ let scope = try await resolveScope()
+ try await applyOnboardingRecordingChoice(false, in: scope)
+ completeOnboarding()
+ }
+
public static var currentYear: Int {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
diff --git a/Where/WhereUI/Sources/Model/WhereScope.swift b/Where/WhereUI/Sources/Model/WhereScope.swift
index 9d6d58b63..c16498464 100644
--- a/Where/WhereUI/Sources/Model/WhereScope.swift
+++ b/Where/WhereUI/Sources/Model/WhereScope.swift
@@ -173,9 +173,10 @@ public final class WhereScope {
/// Every collaborator that would touch the device outside the store is a
/// no-op: the schedulers never ask for notification permission, the widget
/// refresher never writes the shared snapshot file, and the location
- /// source is scripted, so demo mode prompts for nothing. The scripted
- /// source reports `.always` and answers a one-shot fix from New York, so
- /// the app behaves as it would for a user who has granted everything.
+ /// source is scripted, so demo mode prompts for nothing. On a participating
+ /// iPhone or iPad, that source reports `.always` and answers a one-shot fix
+ /// from New York; Catalyst keeps the same management-only participation as
+ /// its real scope and relies on the already-seeded demo history.
///
/// Building this is the slow part of entering demo mode (seeding a year),
/// which is why the entry point shows an interstitial while it runs.
@@ -203,9 +204,12 @@ public final class WhereScope {
let store = try await Task.detached(priority: .userInitiated) {
try SwiftDataStore.inMemory()
}.value
+ let recordingParticipation =
+ CurrentRecordingDeviceProvider.demoParticipationForCurrentHost
let services = try await WhereServices.make(
store: store,
locationSource: locationSource,
+ recordingParticipation: recordingParticipation,
aggregator: aggregator,
// Authorized, like the location source is: the demo presents a user
// who has granted everything, so the alerts screen shows its real
@@ -223,10 +227,11 @@ public final class WhereScope {
.seed(into: services)
let preferences = WherePreferences(store: InMemoryKeyValueStore())
- // Onboarded and tracking, so the demo opens on the logged-in app with
- // live tracking shown rather than on a first-run prompt. These are the
- // demo's own preferences: the user's real ones are untouched, which is
- // what makes quitting mid-demo return to onboarding.
+ // Onboarded with tracking intent, so a participating host's demo opens
+ // with live tracking shown rather than on a first-run prompt. A
+ // management-only host ignores that local intent. These are the demo's
+ // own preferences: the user's real ones are untouched, which is what
+ // makes quitting mid-demo return to onboarding.
preferences.hasOnboarded = true
preferences.wantsTracking = true
diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift
index ce4065473..56414d244 100644
--- a/Where/WhereUI/Sources/Model/WhereSession.swift
+++ b/Where/WhereUI/Sources/Model/WhereSession.swift
@@ -46,6 +46,20 @@ public final class WhereSession {
/// (authorization + the user's intent), not just the last button tap.
public private(set) var isTracking = false
+ /// Stable installation identity used by the Devices settings screen to mark
+ /// the current row and prevent archiving it. Nil for a management-only
+ /// session.
+ public var currentRecordingDeviceID: RecordingDeviceID? {
+ services.recording.currentDevice?.id
+ }
+
+ /// Whether this installation can contribute automatic locations. Catalyst
+ /// sessions are management-only: they still edit synced device policies,
+ /// but expose no current row and never touch local location services.
+ public var supportsLocalRecording: Bool {
+ services.recording.participation.supportsLocalRecording
+ }
+
/// The latest known location authorization status, kept live via
/// `LocationIngestor.authorizationUpdates()`.
public private(set) var authorizationStatus: LocationAuthorizationStatus = .notDetermined
@@ -106,10 +120,16 @@ public final class WhereSession {
private var warnedIssueAlertsUnauthorized = false
/// Persisted user intent to track in the background. Effective tracking is
- /// this AND `.always` authorization; we default to `true` so that, once the
- /// user grants Always, tracking resumes automatically on every launch.
+ /// this AND `.always` authorization. A missing value resolves against the
+ /// composition policy for a new installation, except an already-onboarded
+ /// installation retains the historical on-by-default behavior.
private var wantsTracking: Bool {
- get { preferences.wantsTracking }
+ get {
+ preferences.wantsTracking(
+ defaultForNewInstallation:
+ services.recording.participation.defaultEnabledForNewInstallation,
+ )
+ }
set { preferences.wantsTracking = newValue }
}
@@ -295,6 +315,11 @@ public final class WhereSession {
for await _ in services.dataChangeUpdates() {
guard let self else { break }
await seedRegionStyles()
+ // A CloudKit policy change for this installation arrives through
+ // the same store signal. Reconcile it even if the Devices screen
+ // is not open, so a left-behind device physically stops as soon
+ // as it receives the command.
+ await reconcileTracking()
}
}
}
@@ -304,14 +329,27 @@ public final class WhereSession {
/// launch step (see `WhereLaunch.plan(for:)`).
func reconcileTracking() async {
let wasTracking = isTracking
- if wantsTracking, authorizationStatus.allowsBackgroundTracking {
- await services.ingestor.start()
- isTracking = true
- if !wasTracking { Self.logger { .backgroundTrackingStarted } }
- } else {
- await services.ingestor.stop()
- isTracking = false
- if wasTracking { Self.logger { .backgroundTrackingStopped } }
+ do {
+ guard let configuration = try await services.recording.reconcile(
+ initialEnabled: wantsTracking,
+ authorization: authorizationStatus,
+ ) else {
+ isTracking = false
+ return
+ }
+ // Keep the legacy local preference as the migration seed/fallback,
+ // but synced policy is authoritative once the device exists.
+ wantsTracking = configuration.isEnabled
+ isTracking = configuration.device.status == .recording
+ if isTracking, !wasTracking {
+ Self.logger { .backgroundTrackingStarted }
+ } else if !isTracking, wasTracking {
+ Self.logger { .backgroundTrackingStopped }
+ }
+ } catch {
+ Self.logger(attachments: [.error(error, name: "recording-reconcile-error")]) {
+ .recordingReconcileFailed(description: error.localizedDescription)
+ }
}
}
@@ -324,6 +362,7 @@ public final class WhereSession {
/// on persist. A launch step (see `WhereLaunch.plan(for:)`); also runs on
/// every foreground.
func captureTodayIfNeeded() async {
+ guard supportsLocalRecording else { return }
guard wantsTracking, authorizationStatus.allowsForegroundFix else { return }
await services.ingestor.captureTodayIfNeeded(now: now())
}
@@ -332,6 +371,7 @@ public final class WhereSession {
/// access" button. Drives the system prompt when possible, then syncs the
/// status and reconciles tracking so the UI reflects the outcome.
public func requestPermission() async {
+ guard supportsLocalRecording else { return }
do {
try await services.ingestor.requestPermission()
permissionDenied = false
@@ -353,25 +393,97 @@ public final class WhereSession {
/// When-In-Use is granted the indicator guides the user to Settings; on a
/// hard denial the Settings alert is surfaced.
public func startTracking() async {
- wantsTracking = true
+ guard let currentRecordingDeviceID else { return }
do {
- try await services.ingestor.requestPermission()
- permissionDenied = false
+ _ = try await setRecordingEnabled(true, for: currentRecordingDeviceID)
} catch {
- permissionDenied = true
+ Self.logger(attachments: [.error(error, name: "recording-enable-error")]) {
+ .recordingReconcileFailed(description: error.localizedDescription)
+ }
}
- await syncAuthorization()
- await reconcileTracking()
- if authorizationStatus.allowsBackgroundTracking {
+ }
+
+ public func stopTracking() async {
+ guard let currentRecordingDeviceID else { return }
+ do {
+ _ = try await setRecordingEnabled(false, for: currentRecordingDeviceID)
+ } catch {
+ Self.logger(attachments: [.error(error, name: "recording-disable-error")]) {
+ .recordingReconcileFailed(description: error.localizedDescription)
+ }
+ }
+ }
+
+ /// Current synced device list, registering this installation on first use.
+ public func recordingDevices() async throws -> [RecordingDeviceConfiguration] {
+ try await services.recording.devices(initialEnabled: wantsTracking)
+ }
+
+ /// Set automatic recording for any installation. The current device also
+ /// runs the permission flow and updates the session's live tracking mirror.
+ @discardableResult
+ public func setRecordingEnabled(
+ _ enabled: Bool,
+ for deviceID: RecordingDeviceID,
+ ) async throws -> [RecordingDeviceConfiguration] {
+ var devices = try await services.recording.setEnabled(
+ enabled,
+ for: deviceID,
+ initialEnabled: wantsTracking,
+ )
+ guard deviceID == currentRecordingDeviceID else { return devices }
+
+ var permissionRequestFailed = false
+ if enabled {
+ do {
+ try await services.ingestor.requestPermission()
+ } catch {
+ permissionRequestFailed = true
+ }
+ await syncAuthorization()
+ _ = try await services.recording.reconcile(
+ initialEnabled: wantsTracking,
+ authorization: authorizationStatus,
+ )
+ // The permission prompt is an actor suspension point. Re-read after
+ // it because a later Off action may have won while the prompt was
+ // visible; reconciliation honors that latest policy rather than
+ // appending another On event.
+ devices = try await services.recording.devices(initialEnabled: wantsTracking)
+ }
+
+ guard let current = devices.first(where: { $0.id == deviceID }) else {
+ return devices
+ }
+ wantsTracking = current.isEnabled
+ isTracking = current.device.status == .recording
+ permissionDenied = current.isEnabled && permissionRequestFailed
+ if current.isEnabled, isTracking {
Self.logger { .trackingEnabled }
+ } else if !current.isEnabled {
+ Self.logger { .stoppedBackgroundTracking }
}
+ return devices
}
- public func stopTracking() async {
- wantsTracking = false
- await services.ingestor.stop()
- isTracking = false
- Self.logger { .stoppedBackgroundTracking }
+ public func renameRecordingDevice(
+ _ deviceID: RecordingDeviceID,
+ to nickname: String,
+ ) async throws -> [RecordingDeviceConfiguration] {
+ try await services.recording.rename(
+ deviceID,
+ to: nickname,
+ initialEnabled: wantsTracking,
+ )
+ }
+
+ public func archiveRecordingDevice(
+ _ deviceID: RecordingDeviceID,
+ ) async throws -> [RecordingDeviceConfiguration] {
+ try await services.recording.archive(
+ deviceID,
+ initialEnabled: wantsTracking,
+ )
}
/// Push the persisted reminder intent to the reminder reconciler and warn if
@@ -452,20 +564,29 @@ public final class WhereSession {
/// so the reset step parks the launcher in `.failed` rather than silently
/// half-erasing.
public func eraseSession() async throws {
- try await services.reset()
- isTracking = false
- Self.logger { .erasedSession }
- }
+ let authorizationObserver = authorizationTask
+ let dataObserver = regionStyleTask
+ authorizationTask = nil
+ regionStyleTask = nil
+ authorizationObserver?.cancel()
+ dataObserver?.cancel()
+ await authorizationObserver?.value
+ await dataObserver?.value
- /// Drives the background-tracking `Toggle`. Reads the live `isTracking`
- /// state; assigning kicks off the matching async start/stop so the view can
- /// bind straight to it (`$session.trackingEnabled`) instead of building a
- /// closure-based `Binding`. `isTracking` stays the single source of truth.
- public var trackingEnabled: Bool {
- get { isTracking }
- set {
- Task { newValue ? await startTracking() : await stopTracking() }
+ do {
+ try await services.reset()
+ } catch {
+ // A failed reset deliberately retains this session so the user can
+ // retry. Restore its live observers along with Core's operation
+ // gate rather than leaving the surviving UI stale.
+ isTracking = false
+ await reconcileTracking()
+ observeAuthorizationChanges()
+ observeRegionStyleChanges()
+ throw error
}
+ isTracking = false
+ Self.logger { .erasedSession }
}
}
diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift
index c1acc0e03..6c86d3f05 100644
--- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift
+++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift
@@ -17,8 +17,10 @@ import WhereCore
/// to the real scope (`WhereModel.resolveScope()`, which performs the app's one
/// store open), commits the picked regions + appearances to it, persists
/// `hasOnboarded`, and resolves the `LifecycleGateHandle` so the launch
-/// continues. The steps after the gate then build the session, seed region
-/// styling, and pick up whatever permission was granted.
+/// continues. The explicit existing-iCloud path opens that same scope without
+/// seeding regions or enabling local recording. The steps after the gate then
+/// build the session, seed region styling, and pick up whatever permission was
+/// granted.
public struct OnboardingView: View {
// The model is onboarding's whole world: it persists the app-level
// `hasOnboarded` flag and vends the scope this flow creates. There is no
@@ -62,7 +64,7 @@ public struct OnboardingView: View {
self.gate = gate
}
- private let pages = OnboardingPage.all
+ private let pages = OnboardingPage.currentPlatform
public var body: some View {
Group {
@@ -109,6 +111,8 @@ public struct OnboardingView: View {
// shared app-icon loading treatment (as first-load / scan / summary
// do) rather than an inline spinner.
AppIconLoadingView(caption: String(localized: .onboardingRestoring))
+ } else if intro.isJoiningExistingData {
+ AppIconLoadingView(caption: String(localized: .onboardingJoiningExistingData))
} else {
introPages
}
@@ -138,8 +142,13 @@ public struct OnboardingView: View {
failureTitle,
isPresented: $intro.isShowingFailure,
presenting: intro.failure,
- ) { _ in
- Button(String(localized: .commonOk), role: .cancel) {}
+ ) { failure in
+ if failure.flow == .joinExistingData {
+ Button(String(localized: .commonRetry)) { joinExistingData() }
+ Button(String(localized: .commonCancel), role: .cancel) {}
+ } else {
+ Button(String(localized: .commonOk), role: .cancel) {}
+ }
} message: { failure in
// Formatted here rather than stored: the state keeps the error
// itself, so nothing has to decide how to say it before it's shown.
@@ -152,6 +161,7 @@ public struct OnboardingView: View {
private var failureTitle: String {
switch intro.failure?.flow {
case .restoreBackup: String(localized: .onboardingRestoreErrorTitle)
+ case .joinExistingData: String(localized: .onboardingJoinExistingErrorTitle)
case .demo: String(localized: .onboardingDemoErrorTitle)
case nil: ""
}
@@ -196,11 +206,19 @@ public struct OnboardingView: View {
// the restore's progress replaces the intro with the loading view.
Button(String(localized: .onboardingRestoreBackup)) { showImporter = true }
.controlSize(.large)
+ .tint(.primary)
+
+ Button(String(localized: .onboardingJoinExistingData)) {
+ joinExistingData()
+ }
+ .controlSize(.large)
+ .tint(.primary)
// And anyone can look around first, without handing over a
// location permission or leaving anything on their device.
Button(String(localized: .onboardingTryDemo)) { enterDemoMode() }
.controlSize(.large)
+ .tint(.primary)
}
.disabled(intro.isBuildingDemo)
}
@@ -227,7 +245,13 @@ public struct OnboardingView: View {
RegionCustomizeView(
model: selection,
onBack: { phase = .pickRegions },
- onFinish: { phase = .location },
+ onFinish: {
+ #if targetEnvironment(macCatalyst)
+ finish(enableLocation: false)
+ #else
+ phase = .location
+ #endif
+ },
)
}
}
@@ -298,8 +322,24 @@ public struct OnboardingView: View {
gate.fail(error)
return
}
+ do {
+ // This is the explicit synced policy for this installation,
+ // not just the preference seed. A restored policy may already
+ // exist for the preserved installation id, and the user's new
+ // choice must supersede it before the gate resolves.
+ try await model.applyOnboardingRecordingChoice(
+ enableLocation,
+ in: scope,
+ )
+ } catch {
+ Self.logger(attachments: [.error(error, name: "recording-choice-error")]) {
+ .recordingChoiceFailed(description: error.localizedDescription)
+ }
+ gate.fail(error)
+ return
+ }
if enableLocation {
- await enableTracking(in: scope)
+ await requestTrackingPermission(in: scope)
}
// Only commit when the user actually picked regions in the manual
// flow. The restore path reaches here with an empty selection (it
@@ -324,13 +364,11 @@ public struct OnboardingView: View {
}
}
- /// Record the tracking intent and drive the system prompt, so it maps 1:1
- /// to the tap that asked for it. Only these two halves happen here: the
- /// `sync-auth` and `reconcile-tracking` steps run as soon as the gate
- /// resolves, and they are what read the granted authorization back and
- /// actually start GPS.
- private func enableTracking(in scope: WhereScope) async {
- scope.preferences.wantsTracking = true
+ /// Drive the system prompt so it maps 1:1 to the tap that asked for it.
+ /// The synced choice is already committed; `sync-auth` and
+ /// `reconcile-tracking` run as soon as the gate resolves, read the granted
+ /// authorization back, and actually start GPS.
+ private func requestTrackingPermission(in scope: WhereScope) async {
do {
try await scope.services.ingestor.requestPermission()
} catch {
@@ -375,6 +413,28 @@ public struct OnboardingView: View {
}
}
+ /// Open the real iCloud-backed scope without writing onboarding region
+ /// defaults or requesting location. A failed open stays on the intro and
+ /// offers an explicit retry; success resolves the launch gate immediately
+ /// while CloudKit imports continue through the live store.
+ private func joinExistingData() {
+ guard !intro.isJoiningExistingData else { return }
+ intro.activity = .joiningExistingData
+ Task {
+ do {
+ try await model.joinExistingData()
+ gate.complete()
+ } catch is CancellationError {
+ intro.activity = .browsing
+ } catch {
+ intro.activity = .failed(.init(flow: .joinExistingData, error: error))
+ Self.logger(attachments: [.error(error, name: "join-existing-error")]) {
+ .joinExistingDataFailed(description: error.localizedDescription)
+ }
+ }
+ }
+ }
+
// MARK: - Restore from backup
private func handleRestoreSelection(_ result: Result) {
@@ -388,8 +448,9 @@ public struct OnboardingView: View {
/// Import the chosen backup (a fresh install, so `.replace` mirrors the file
/// exactly), then skip the manual pick/customize steps straight to the
- /// location ask. Restoring is the user committing to their real data, so
- /// this is one of the two places the store gets opened. On failure —
+ /// location ask on a participating iPhone/iPad or finish immediately on
+ /// management-only Catalyst. Restoring is the user committing to their real
+ /// data, so this is one of the two places the store gets opened. On failure —
/// including a store that won't open — surface an alert and stay in the
/// intro, where they can retry or continue manually.
private func restore(from url: URL) {
@@ -400,7 +461,11 @@ public struct OnboardingView: View {
let scope = try await model.resolveScope()
_ = try await scope.services.backup.importBackup(from: url, strategy: .replace)
intro.activity = .browsing
- phase = .location
+ #if targetEnvironment(macCatalyst)
+ finish(enableLocation: false)
+ #else
+ phase = .location
+ #endif
} catch {
intro.activity = .failed(.init(flow: .restoreBackup, error: error))
Self.logger(attachments: [.error(error, name: "restore-error")]) {
@@ -414,10 +479,10 @@ public struct OnboardingView: View {
/// What the onboarding intro is doing, and how it went.
///
/// One value rather than a pair of "is running" flags beside a loose error:
-/// restoring a backup and building a demo each take over the whole screen, so
-/// only one can be underway, and a failure always belongs to whichever one
-/// produced it. As separate properties, "restoring *and* building" and "failed
-/// with no error" were both spellable.
+/// restoring, joining iCloud data, and building a demo each take over the whole
+/// screen, so only one can be underway, and a failure always belongs to
+/// whichever one produced it. As separate properties, "restoring *and*
+/// building" and "failed with no error" were both spellable.
///
/// `@Observable` for the same reason `SaveErrorAlertState` is: the activity
/// stays the single source of truth while `isShowingFailure` gives
@@ -429,6 +494,7 @@ final class OnboardingIntroState {
enum Activity {
case browsing
case restoringBackup
+ case joiningExistingData
case buildingDemo
case failed(Failure)
}
@@ -437,10 +503,11 @@ final class OnboardingIntroState {
/// message, so the view formats it where it presents it — and anything
/// else that wants to inspect it still can.
struct Failure {
- /// Which of the intro's two ways forward failed, since they say
+ /// Which of the intro's long-running ways forward failed, since they say
/// different things about it.
- enum Flow {
+ enum Flow: Equatable {
case restoreBackup
+ case joinExistingData
case demo
}
@@ -460,6 +527,11 @@ final class OnboardingIntroState {
return false
}
+ var isJoiningExistingData: Bool {
+ if case .joiningExistingData = activity { return true }
+ return false
+ }
+
var failure: Failure? {
if case let .failed(failure) = activity { return failure }
return nil
@@ -481,26 +553,43 @@ struct OnboardingPage: Identifiable {
let title: String
let description: String
- static let all: [OnboardingPage] = [
- OnboardingPage(
- id: "welcome",
- symbol: "globe.americas.fill",
- title: String(localized: .onboardingWelcomeTitle),
- description: String(localized: .onboardingWelcomeDescription),
- ),
- OnboardingPage(
- id: "automatic",
- symbol: "location.fill.viewfinder",
- title: String(localized: .onboardingAutomaticTitle),
- description: String(localized: .onboardingAutomaticDescription),
- ),
- OnboardingPage(
- id: "privacy",
- symbol: "lock.shield.fill",
- title: String(localized: .onboardingPrivacyTitle),
- description: String(localized: .onboardingPrivacyDescription),
- ),
- ]
+ static func pages(supportsRecording: Bool) -> [OnboardingPage] {
+ var pages = [
+ OnboardingPage(
+ id: "welcome",
+ symbol: "globe.americas.fill",
+ title: String(localized: .onboardingWelcomeTitle),
+ description: String(localized: .onboardingWelcomeDescription),
+ ),
+ ]
+ if supportsRecording {
+ pages.append(
+ OnboardingPage(
+ id: "automatic",
+ symbol: "location.fill.viewfinder",
+ title: String(localized: .onboardingAutomaticTitle),
+ description: String(localized: .onboardingAutomaticDescription),
+ ),
+ )
+ }
+ pages.append(
+ OnboardingPage(
+ id: "privacy",
+ symbol: "lock.shield.fill",
+ title: String(localized: .onboardingPrivacyTitle),
+ description: String(localized: .onboardingPrivacyDescription),
+ ),
+ )
+ return pages
+ }
+
+ static var currentPlatform: [OnboardingPage] {
+ #if targetEnvironment(macCatalyst)
+ pages(supportsRecording: false)
+ #else
+ pages(supportsRecording: true)
+ #endif
+ }
}
#if DEBUG
diff --git a/Where/WhereUI/Sources/PhoneMainTabs.swift b/Where/WhereUI/Sources/PhoneMainTabs.swift
new file mode 100644
index 000000000..a275f40d5
--- /dev/null
+++ b/Where/WhereUI/Sources/PhoneMainTabs.swift
@@ -0,0 +1,51 @@
+import SwiftUI
+
+/// The compact-width logged-in interface, preserving Where's three fixed
+/// iPhone tabs.
+struct PhoneMainTabs: View {
+ let report: YearReportModel
+
+ @State private var selection = MainSection.locations
+
+ var body: some View {
+ TabView(selection: $selection) {
+ Tab(
+ MainSection.locations.title,
+ systemImage: MainSection.locations.systemImage,
+ value: MainSection.locations,
+ ) {
+ LocationsView(report: report)
+ .reportingDeveloperTabBarInset()
+ }
+
+ Tab(
+ MainSection.year.title,
+ systemImage: MainSection.year.systemImage,
+ value: MainSection.year,
+ ) {
+ YearView(report: report)
+ .reportingDeveloperTabBarInset()
+ }
+
+ Tab(
+ MainSection.settings.title,
+ systemImage: MainSection.settings.systemImage,
+ value: MainSection.settings,
+ ) {
+ SettingsView(report: report)
+ .reportingDeveloperTabBarInset()
+ }
+ }
+ // Keep the tab bar fixed — don't minimize it as content scrolls.
+ .tabBarMinimizeBehavior(.never)
+ }
+}
+
+#if DEBUG
+ #Preview {
+ PhoneMainTabs(report: PreviewSupport.loadedYearReportModel())
+ .environment(PreviewSupport.loadedModel())
+ .environment(PreviewSupport.loadedSession())
+ .whereBroadwayRoot()
+ }
+#endif
diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift
index 0d9651822..a148659df 100644
--- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift
+++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift
@@ -115,6 +115,56 @@
WhereSession(services: previewServices(), preferences: previewPreferences())
}
+ /// Current + left-behind device rows for the Devices screen. The iPad's
+ /// off policy is intentionally unacknowledged so previews pin the
+ /// cross-device "waiting" state as well as the current happy path.
+ public static func recordingDeviceConfigurations() -> [RecordingDeviceConfiguration] {
+ let currentPolicyID = UUID(
+ uuidString: "10000000-0000-0000-0000-000000000001",
+ )!
+ let remoteAppliedPolicyID = UUID(
+ uuidString: "20000000-0000-0000-0000-000000000001",
+ )!
+ let remoteLatestPolicyID = UUID(
+ uuidString: "20000000-0000-0000-0000-000000000002",
+ )!
+ let remoteID = RecordingDeviceID(
+ rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!,
+ )
+ return [
+ RecordingDeviceConfiguration(
+ device: RecordingDevice(
+ id: CurrentRecordingDevice.preview.id,
+ systemName: "iPhone",
+ nickname: "My iPhone",
+ kind: .phone,
+ registeredAt: referenceNow.addingTimeInterval(-90 * 24 * 60 * 60),
+ lastSeenAt: referenceNow,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: currentPolicyID,
+ status: .recording,
+ ),
+ isEnabled: true,
+ latestPolicyChangeID: currentPolicyID,
+ ),
+ RecordingDeviceConfiguration(
+ device: RecordingDevice(
+ id: remoteID,
+ systemName: "iPad",
+ nickname: "Home iPad",
+ kind: .tablet,
+ registeredAt: referenceNow.addingTimeInterval(-60 * 24 * 60 * 60),
+ lastSeenAt: referenceNow.addingTimeInterval(-2 * 24 * 60 * 60),
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: remoteAppliedPolicyID,
+ status: .recording,
+ ),
+ isEnabled: false,
+ latestPolicyChangeID: remoteLatestPolicyID,
+ ),
+ ]
+ }
+
// MARK: - Settings models (reminders / backup sub-screens)
/// A reminders/summary editing model over in-memory services, for the
@@ -586,6 +636,9 @@
.europeanUnion: 4,
.other: 2,
],
+ appearances: [:],
+ generatedAt: day,
+ surface: nil,
)
}
}
diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings
index 2acc78fbd..0db4140ee 100644
--- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings
+++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings
@@ -333,6 +333,17 @@
}
}
},
+ "common.retry" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Retry"
+ }
+ }
+ }
+ },
"common.save" : {
"extractionState" : "manual",
"localizations" : {
@@ -1564,6 +1575,42 @@
}
}
},
+ "onboarding.joinExistingData" : {
+ "comment" : "Button on the onboarding intro that opens the user's existing iCloud-backed Where data without creating starter data or enabling location.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Join Existing iCloud Data"
+ }
+ }
+ }
+ },
+ "onboarding.joinExistingError.title" : {
+ "comment" : "Alert title shown when the app can't open the user's existing iCloud-backed data during onboarding.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Couldn't Join iCloud Data"
+ }
+ }
+ }
+ },
+ "onboarding.joiningExistingData" : {
+ "comment" : "Loading caption shown while onboarding opens the user's existing iCloud-backed data.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Joining your iCloud data…"
+ }
+ }
+ }
+ },
"onboarding.location.description" : {
"extractionState" : "manual",
"localizations" : {
@@ -1653,7 +1700,7 @@
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Restore from a backup"
+ "value" : "Restore Backup"
}
}
}
@@ -3679,6 +3726,248 @@
}
}
},
+ "settings.devices.archive" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Archive Device"
+ }
+ }
+ }
+ },
+ "settings.devices.archive.confirm.message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Automatic recording will be turned off from now on and this device will be hidden. Its existing history is kept."
+ }
+ }
+ }
+ },
+ "settings.devices.archive.confirm.title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Archive this device?"
+ }
+ }
+ }
+ },
+ "settings.devices.automaticRecording" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Automatic Recording"
+ }
+ }
+ }
+ },
+ "settings.devices.current.footer" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This device applies changes immediately. Always location access is required for background recording."
+ }
+ }
+ }
+ },
+ "settings.devices.empty.description" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Open Where on an iPhone or iPad using the same iCloud account to add a recording device."
+ }
+ }
+ }
+ },
+ "settings.devices.empty.title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No Recording Devices"
+ }
+ }
+ }
+ },
+ "settings.devices.error.title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Couldn’t Update Devices"
+ }
+ }
+ }
+ },
+ "settings.devices.grant" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Grant location access"
+ }
+ }
+ }
+ },
+ "settings.devices.keywords.name" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "device, name, nickname, iphone, ipad"
+ }
+ }
+ }
+ },
+ "settings.devices.keywords.recording" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "location, gps, tracking, background, automatic, device, travel"
+ }
+ }
+ }
+ },
+ "settings.devices.lastActive" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Last Active"
+ }
+ }
+ }
+ },
+ "settings.devices.loadFailed" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Devices Unavailable"
+ }
+ }
+ }
+ },
+ "settings.devices.name" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Device Name"
+ }
+ }
+ }
+ },
+ "settings.devices.remote.footer" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Turning recording off hides new locations from the cutoff immediately. The device physically stops when it next syncs."
+ }
+ }
+ }
+ },
+ "settings.devices.status" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Status"
+ }
+ }
+ }
+ },
+ "settings.devices.status.off" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Off"
+ }
+ }
+ }
+ },
+ "settings.devices.status.pending" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Waiting for Device"
+ }
+ }
+ }
+ },
+ "settings.devices.status.permissionRequired" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Location Access Needed"
+ }
+ }
+ }
+ },
+ "settings.devices.status.recording" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recording"
+ }
+ }
+ }
+ },
+ "settings.devices.thisDevice" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This Device"
+ }
+ }
+ }
+ },
+ "settings.devices.title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Devices"
+ }
+ }
+ }
+ },
"settings.eraseYear.title" : {
"extractionState" : "manual",
"localizations" : {
@@ -4000,80 +4289,109 @@
}
}
},
- "settings.keywords.tracking" : {
+ "settings.keywords.year" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "location, gps, tracking, background, permission"
+ "value" : "year, report, calendar"
}
}
}
},
- "settings.keywords.year" : {
+ "settings.loggedDays.row" : {
+ "comment" : "Row title for the hand-logged-days screen in the Settings Data group.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "year, report, calendar"
+ "value" : "Logged Days"
}
}
}
},
- "settings.location.footer" : {
+ "settings.menuBar.approval.footer" : {
+ "comment" : "Shown below the Mac menu bar login-item controls when macOS still requires approval.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Where watches for visits and big moves to figure out which region you're in. It needs Always access and a little patience."
+ "value" : "Allow Where in Login Items in System Settings to finish enabling it."
}
}
}
},
- "settings.location.grant" : {
+ "settings.menuBar.enabled" : {
+ "comment" : "Toggle label for the native Mac menu bar companion.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Grant location access"
+ "value" : "Show Where in the menu bar"
}
}
}
},
- "settings.location.header" : {
+ "settings.menuBar.error.title" : {
+ "comment" : "Alert title when registering or unregistering the Mac menu bar login item fails.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Location"
+ "value" : "Couldn’t Update Menu Bar Item"
}
}
}
},
- "settings.location.toggle" : {
+ "settings.menuBar.footer" : {
+ "comment" : "Description of the glance data shown by the Mac menu bar companion.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Track in the background"
+ "value" : "Shows today’s observed regions and year-to-date day counts without opening Where."
}
}
}
},
- "settings.loggedDays.row" : {
- "comment" : "Row title for the hand-logged-days screen in the Settings Data group.",
+ "settings.menuBar.header" : {
+ "comment" : "Header for Mac-specific menu bar settings.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Logged Days"
+ "value" : "Menu Bar"
+ }
+ }
+ }
+ },
+ "settings.menuBar.openLoginItems" : {
+ "comment" : "Button that opens the macOS Login Items settings pane.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Open Login Items Settings"
+ }
+ }
+ }
+ },
+ "settings.menuBar.unavailable.footer" : {
+ "comment" : "Shown when the embedded Mac menu bar helper cannot be found.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "The menu bar companion isn’t available in this build."
}
}
}
diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift
new file mode 100644
index 000000000..25c9aef96
--- /dev/null
+++ b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift
@@ -0,0 +1,63 @@
+import Foundation
+import Observation
+import WhereCore
+
+/// Editable presentation state for one synced recording device.
+@MainActor
+@Observable
+final class DeviceSettingsRowModel: Identifiable {
+ let id: RecordingDeviceID
+ let systemName: String
+ let kind: RecordingDeviceKind
+ let isCurrent: Bool
+
+ var nickname: String
+ private(set) var confirmedNickname: String
+ var isEnabled: Bool
+ private(set) var confirmedIsEnabled: Bool
+ var status: RecordingDeviceStatus
+ var lastSeenAt: Date
+ var isPending: Bool
+ var isBusy = false
+
+ init(configuration: RecordingDeviceConfiguration, isCurrent: Bool) {
+ id = configuration.id
+ systemName = configuration.device.systemName
+ kind = configuration.device.kind
+ self.isCurrent = isCurrent
+ let nickname = configuration.device.nickname ?? ""
+ self.nickname = nickname
+ confirmedNickname = nickname
+ isEnabled = configuration.isEnabled
+ confirmedIsEnabled = configuration.isEnabled
+ status = configuration.device.status
+ lastSeenAt = configuration.device.lastSeenAt
+ isPending = configuration.isPending
+ }
+
+ var displayName: String {
+ let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? systemName : trimmed
+ }
+
+ var systemImage: String {
+ switch kind {
+ case .phone: "iphone"
+ case .tablet: "ipad"
+ case .other: "apple.logo"
+ }
+ }
+
+ func update(from configuration: RecordingDeviceConfiguration) {
+ let updatedNickname = configuration.device.nickname ?? ""
+ if nickname == confirmedNickname {
+ nickname = updatedNickname
+ }
+ confirmedNickname = updatedNickname
+ isEnabled = configuration.isEnabled
+ confirmedIsEnabled = configuration.isEnabled
+ status = configuration.device.status
+ lastSeenAt = configuration.device.lastSeenAt
+ isPending = configuration.isPending
+ }
+}
diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift
new file mode 100644
index 000000000..81ed32e34
--- /dev/null
+++ b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift
@@ -0,0 +1,193 @@
+import SwiftUI
+import WhereCore
+
+/// Form section for one device. It binds directly to the row model and sends
+/// async effects through the owning Devices model.
+struct DeviceSettingsSection: View {
+ let model: DevicesSettingsModel
+ @Bindable var row: DeviceSettingsRowModel
+
+ @Environment(WhereSession.self) private var session
+ @Environment(\.openURL) private var openURL
+ @State private var isConfirmingArchive = false
+ @FocusState private var isEditingNickname: Bool
+
+ var body: some View {
+ Section {
+ Toggle(
+ String(localized: .settingsDevicesAutomaticRecording),
+ isOn: $row.isEnabled,
+ )
+ .settingsRow(DevicesSettingsView.Item.automaticRecording)
+ .disabled(row.isBusy)
+ .onChange(of: row.isEnabled) { oldValue, newValue in
+ guard oldValue != newValue else { return }
+ Task {
+ await model.setEnabled(
+ newValue,
+ row: row,
+ )
+ }
+ }
+
+ TextField(String(localized: .settingsDevicesName), text: $row.nickname)
+ .settingsRow(DevicesSettingsView.Item.deviceName)
+ .disabled(row.isBusy)
+ .focused($isEditingNickname)
+ .onSubmit {
+ isEditingNickname = false
+ }
+ .onChange(of: isEditingNickname) { wasEditing, isEditing in
+ guard wasEditing, !isEditing else { return }
+ commitNickname()
+ }
+ .onDisappear {
+ commitNickname()
+ }
+
+ LabeledContent(String(localized: .settingsDevicesStatus)) {
+ Label(statusTitle, systemImage: statusSymbol)
+ .foregroundStyle(statusStyle)
+ }
+
+ LabeledContent(String(localized: .settingsDevicesLastActive)) {
+ Text(
+ row.lastSeenAt,
+ format: .dateTime
+ .month(.abbreviated)
+ .day()
+ .year()
+ .hour()
+ .minute(),
+ )
+ .foregroundStyle(.secondary)
+ }
+
+ if row.isCurrent {
+ LocationStatusRow(
+ status: session.authorizationStatus,
+ isTracking: session.isTracking,
+ )
+
+ if showGrantButton {
+ Button {
+ Task { await model.requestPermission() }
+ } label: {
+ Label(
+ String(localized: .settingsDevicesGrant),
+ systemImage: "location.magnifyingglass",
+ )
+ }
+ }
+
+ if showOpenSettingsButton {
+ Button {
+ openSystemSettings(openURL)
+ } label: {
+ Label(
+ String(localized: .settingsPermissionAlertOpenSettings),
+ systemImage: "gear",
+ )
+ }
+ }
+ } else {
+ Button(
+ String(localized: .settingsDevicesArchive),
+ systemImage: "archivebox",
+ role: .destructive,
+ ) {
+ isConfirmingArchive = true
+ }
+ .disabled(row.isBusy)
+ .confirmationDialog(
+ String(localized: .settingsDevicesArchiveConfirmTitle),
+ isPresented: $isConfirmingArchive,
+ titleVisibility: .visible,
+ ) {
+ Button(String(localized: .settingsDevicesArchive), role: .destructive) {
+ Task { await model.archive(row) }
+ }
+ } message: {
+ Text(String(localized: .settingsDevicesArchiveConfirmMessage))
+ }
+ }
+ } header: {
+ Label {
+ HStack {
+ Text(row.displayName)
+ if row.isCurrent {
+ Text(String(localized: .settingsDevicesThisDevice))
+ .foregroundStyle(.secondary)
+ }
+ }
+ } icon: {
+ Image(systemName: row.systemImage)
+ }
+ } footer: {
+ if row.isCurrent {
+ Text(String(localized: .settingsDevicesCurrentFooter))
+ } else {
+ Text(String(localized: .settingsDevicesRemoteFooter))
+ }
+ }
+ }
+
+ private var statusTitle: String {
+ if row.isPending {
+ return String(localized: .settingsDevicesStatusPending)
+ }
+ switch row.status {
+ case .recording: return String(localized: .settingsDevicesStatusRecording)
+ case .off: return String(localized: .settingsDevicesStatusOff)
+ case .permissionRequired:
+ return String(localized: .settingsDevicesStatusPermissionRequired)
+ }
+ }
+
+ private func commitNickname() {
+ Task { await model.rename(row) }
+ }
+
+ private var statusSymbol: String {
+ if row.isPending { return "clock.arrow.trianglehead.counterclockwise.rotate.90" }
+ return switch row.status {
+ case .recording: "location.fill"
+ case .off: "location.slash"
+ case .permissionRequired: "exclamationmark.triangle"
+ }
+ }
+
+ private var statusStyle: HierarchicalShapeStyle {
+ row.status == .recording && !row.isPending ? .primary : .secondary
+ }
+
+ private var showGrantButton: Bool {
+ guard row.isEnabled else { return false }
+ return switch session.authorizationStatus {
+ case .notDetermined, .whenInUse: true
+ case .restricted, .denied, .always: false
+ }
+ }
+
+ private var showOpenSettingsButton: Bool {
+ guard row.isEnabled else { return false }
+ return switch session.authorizationStatus {
+ case .denied, .restricted, .whenInUse: true
+ case .notDetermined, .always: false
+ }
+ }
+}
+
+#if DEBUG
+ #Preview {
+ let session = PreviewSupport.loadedSession()
+ let model = DevicesSettingsModel(
+ session: session,
+ configurations: PreviewSupport.recordingDeviceConfigurations(),
+ )
+ Form {
+ DeviceSettingsSection(model: model, row: model.rows[0])
+ }
+ .environment(session)
+ }
+#endif
diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift
new file mode 100644
index 000000000..92a8c408a
--- /dev/null
+++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift
@@ -0,0 +1,145 @@
+import Foundation
+import Observation
+import WhereCore
+
+/// View-scoped Devices settings state. All mutations await the serialized Core
+/// controller and restore the last confirmed value when a write fails.
+@MainActor
+@Observable
+final class DevicesSettingsModel {
+ enum LoadState {
+ case idle
+ case loading
+ case loaded
+ case failed(String)
+ }
+
+ private let session: WhereSession
+ private(set) var state: LoadState = .idle
+ private(set) var rows: [DeviceSettingsRowModel] = []
+ var errorMessage: String?
+
+ var isShowingError: Bool {
+ get { errorMessage != nil }
+ set {
+ if !newValue { errorMessage = nil }
+ }
+ }
+
+ init(session: WhereSession) {
+ self.session = session
+ }
+
+ #if DEBUG
+ init(
+ session: WhereSession,
+ configurations: [RecordingDeviceConfiguration],
+ ) {
+ self.session = session
+ state = .loaded
+ apply(configurations)
+ }
+ #endif
+
+ /// Load once, then stay current with local commits and CloudKit imports
+ /// until the owning view disappears and SwiftUI cancels the task.
+ func run() async {
+ await load(showLoading: true)
+ for await _ in session.services.dataChangeUpdates() {
+ if Task.isCancelled { return }
+ await load(showLoading: false)
+ }
+ }
+
+ func retry() async {
+ await load(showLoading: true)
+ }
+
+ func setEnabled(
+ _ enabled: Bool,
+ row: DeviceSettingsRowModel,
+ ) async {
+ guard !row.isBusy, enabled != row.confirmedIsEnabled else { return }
+ row.isBusy = true
+ defer { row.isBusy = false }
+ do {
+ let configurations = try await session.setRecordingEnabled(enabled, for: row.id)
+ apply(configurations)
+ } catch {
+ row.isEnabled = row.confirmedIsEnabled
+ surface(error)
+ }
+ }
+
+ func rename(_ row: DeviceSettingsRowModel) async {
+ let nickname = row.nickname.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard nickname != row.confirmedNickname else {
+ row.nickname = nickname
+ return
+ }
+ guard !row.isBusy else { return }
+ row.isBusy = true
+ defer { row.isBusy = false }
+ do {
+ let configurations = try await session.renameRecordingDevice(
+ row.id,
+ to: nickname,
+ )
+ row.nickname = nickname
+ apply(configurations)
+ } catch {
+ row.nickname = row.confirmedNickname
+ surface(error)
+ await load(showLoading: false)
+ }
+ }
+
+ func archive(_ row: DeviceSettingsRowModel) async {
+ guard !row.isCurrent, !row.isBusy else { return }
+ row.isBusy = true
+ defer { row.isBusy = false }
+ do {
+ let configurations = try await session.archiveRecordingDevice(row.id)
+ apply(configurations)
+ } catch {
+ surface(error)
+ }
+ }
+
+ func requestPermission() async {
+ await session.requestPermission()
+ await load(showLoading: false)
+ }
+
+ private func load(showLoading: Bool) async {
+ if showLoading { state = .loading }
+ do {
+ try await apply(session.recordingDevices())
+ state = .loaded
+ } catch {
+ if rows.isEmpty {
+ state = .failed(error.localizedDescription)
+ } else {
+ surface(error)
+ }
+ }
+ }
+
+ private func apply(_ configurations: [RecordingDeviceConfiguration]) {
+ let existing = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) })
+ rows = configurations.map { configuration in
+ if let row = existing[configuration.id] {
+ row.update(from: configuration)
+ return row
+ }
+ return DeviceSettingsRowModel(
+ configuration: configuration,
+ isCurrent: session.currentRecordingDeviceID == configuration.id,
+ )
+ }
+ }
+
+ private func surface(_ error: any Error) {
+ errorMessage = error.localizedDescription
+ }
+}
diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift
new file mode 100644
index 000000000..dde34a333
--- /dev/null
+++ b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift
@@ -0,0 +1,187 @@
+import SnapshotKit
+import SwiftUI
+import WhereCore
+
+/// Synced device-management screen. Each installation has its own automatic
+/// recording intent, editable nickname, acknowledgement state, and last check-in.
+struct DevicesSettingsView: View {
+ var focus: SettingsFocus?
+
+ @Environment(WhereSession.self) private var session
+ @Environment(\.openURL) private var openURL
+ @State private var model: DevicesSettingsModel
+ #if targetEnvironment(macCatalyst)
+ @State private var menuBar = MenuBarSettingsModel()
+ #endif
+ private let loadsLiveData: Bool
+
+ init(session: WhereSession, focus: SettingsFocus? = nil) {
+ self.focus = focus
+ _model = State(initialValue: DevicesSettingsModel(session: session))
+ loadsLiveData = true
+ }
+
+ #if DEBUG
+ init(
+ session: WhereSession,
+ configurations: [RecordingDeviceConfiguration],
+ focus: SettingsFocus? = nil,
+ ) {
+ self.focus = focus
+ _model = State(
+ initialValue: DevicesSettingsModel(
+ session: session,
+ configurations: configurations,
+ ),
+ )
+ loadsLiveData = false
+ }
+ #endif
+
+ var body: some View {
+ @Bindable var session = session
+ @Bindable var model = model
+ #if targetEnvironment(macCatalyst)
+ @Bindable var menuBar = menuBar
+ #endif
+ SettingsFocusScope(focus: focus) {
+ Form {
+ #if targetEnvironment(macCatalyst)
+ MenuBarSettingsSection(model: menuBar)
+ #endif
+ switch model.state {
+ case .idle, .loading:
+ Section {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ }
+ case let .failed(message):
+ Section {
+ ContentUnavailableView(
+ String(localized: .settingsDevicesLoadFailed),
+ systemImage: "exclamationmark.icloud",
+ description: Text(message),
+ )
+ Button(String(localized: .commonRetry)) {
+ Task { await model.retry() }
+ }
+ }
+ case .loaded:
+ if model.rows.isEmpty {
+ Section {
+ ContentUnavailableView(
+ String(localized: .settingsDevicesEmptyTitle),
+ systemImage: "iphone",
+ description: Text(
+ String(localized: .settingsDevicesEmptyDescription),
+ ),
+ )
+ }
+ } else {
+ ForEach(model.rows) { row in
+ DeviceSettingsSection(model: model, row: row)
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle(String(localized: .settingsDevicesTitle))
+ .navigationBarTitleDisplayMode(.inline)
+ .task {
+ guard loadsLiveData else { return }
+ await model.run()
+ }
+ .alert(
+ String(localized: .settingsDevicesErrorTitle),
+ isPresented: $model.isShowingError,
+ presenting: model.errorMessage,
+ ) { _ in
+ Button(String(localized: .commonOk), role: .cancel) {}
+ } message: { message in
+ Text(message)
+ }
+ .alert(
+ String(localized: .settingsPermissionAlertTitle),
+ isPresented: $session.permissionDenied,
+ ) {
+ Button(String(localized: .settingsPermissionAlertOpenSettings)) {
+ openSystemSettings(openURL)
+ }
+ Button(String(localized: .settingsPermissionAlertNotNow), role: .cancel) {}
+ } message: {
+ Text(String(localized: .settingsPermissionAlertMessage))
+ }
+ }
+}
+
+extension DevicesSettingsView: SettingsSection {
+ static var destination: SettingsDestination {
+ .devices
+ }
+
+ enum Item: SettingsItem {
+ case automaticRecording
+ case deviceName
+
+ var title: String {
+ switch self {
+ case .automaticRecording:
+ String(localized: .settingsDevicesAutomaticRecording)
+ case .deviceName:
+ String(localized: .settingsDevicesName)
+ }
+ }
+
+ var keywords: [String] {
+ switch self {
+ case .automaticRecording:
+ splitKeywords(String(localized: .settingsDevicesKeywordsRecording))
+ case .deviceName:
+ splitKeywords(String(localized: .settingsDevicesKeywordsName))
+ }
+ }
+ }
+}
+
+#if DEBUG
+ extension DevicesSettingsView: SnapshotProviding {
+ static var snapshots: [SnapshotCase] {
+ let session = PreviewSupport.loadedSession()
+ whereSnapshot(
+ name: "Default",
+ configurations: .screenDefaults,
+ onReadyToSnapshot: { await session.start() },
+ ) {
+ NavigationStack {
+ DevicesSettingsView(
+ session: session,
+ configurations: PreviewSupport.recordingDeviceConfigurations(),
+ )
+ }
+ .environment(session)
+ .task { await session.start() }
+ }
+ }
+ }
+
+ #Preview {
+ DevicesSettingsView.snapshotPreviews
+ }
+#endif
+
+#if DEBUG
+ extension DevicesSettingsView: WhereFlyoverProviding {
+ static let flyoverData = WhereFlyoverData.hosted(
+ DevicesSettingsView.self,
+ title: "Devices",
+ ) { world in
+ DevicesSettingsView(
+ session: world.session,
+ configurations: PreviewSupport.recordingDeviceConfigurations(),
+ )
+ }
+ }
+#endif
diff --git a/Where/WhereUI/Sources/Settings/LocationSettingsView.swift b/Where/WhereUI/Sources/Settings/LocationSettingsView.swift
deleted file mode 100644
index 4c01a13ae..000000000
--- a/Where/WhereUI/Sources/Settings/LocationSettingsView.swift
+++ /dev/null
@@ -1,137 +0,0 @@
-import SwiftUI
-import WhereCore
-
-/// Settings drill-in for location permission and background tracking: the live
-/// status row, the tracking toggle, and the grant / open-Settings affordances
-/// that depend on the current authorization.
-struct LocationSettingsView: View {
- var focus: SettingsFocus?
-
- @Environment(WhereSession.self) private var session
- @Environment(\.openURL) private var openURL
-
- var body: some View {
- @Bindable var session = session
- SettingsFocusScope(focus: focus) {
- Form {
- Section {
- LocationStatusRow(
- status: session.authorizationStatus,
- isTracking: session.isTracking,
- )
-
- Toggle(isOn: $session.trackingEnabled) {
- Label(
- String(localized: .settingsLocationToggle),
- systemImage: "location.fill",
- )
- }
- .settingsRow(Item.tracking)
-
- if showGrantButton {
- Button {
- Task { await session.requestPermission() }
- } label: {
- Label(
- String(localized: .settingsLocationGrant),
- systemImage: "location.magnifyingglass",
- )
- }
- }
-
- if showOpenSettingsButton {
- Button {
- openSystemSettings(openURL)
- } label: {
- Label(
- String(localized: .settingsPermissionAlertOpenSettings),
- systemImage: "gear",
- )
- }
- }
- } header: {
- Text(String(localized: .settingsLocationHeader))
- } footer: {
- Text(String(localized: .settingsLocationFooter))
- }
- }
- }
- .navigationTitle(String(localized: .settingsLocationHeader))
- .navigationBarTitleDisplayMode(.inline)
- // `session.permissionDenied` is only ever raised by the Grant button /
- // tracking toggle on this screen (an external Settings-app toggle flows
- // through the authorization observer, which never sets it), so the alert
- // belongs here rather than on the always-mounted settings root.
- .alert(
- String(localized: .settingsPermissionAlertTitle),
- isPresented: $session.permissionDenied,
- ) {
- Button(String(localized: .settingsPermissionAlertOpenSettings)) {
- openSystemSettings(openURL)
- }
- Button(String(localized: .settingsPermissionAlertNotNow), role: .cancel) {}
- } message: {
- Text(String(localized: .settingsPermissionAlertMessage))
- }
- }
-
- /// Re-requesting only helps before the user has made a final decision.
- private var showGrantButton: Bool {
- switch session.authorizationStatus {
- case .notDetermined, .whenInUse: true
- case .restricted, .denied, .always: false
- }
- }
-
- /// Once access is denied/restricted (or stuck at When-In-Use), the only way
- /// forward is the Settings app.
- private var showOpenSettingsButton: Bool {
- switch session.authorizationStatus {
- case .denied, .restricted, .whenInUse: true
- case .notDetermined, .always: false
- }
- }
-}
-
-extension LocationSettingsView: SettingsSection {
- static var destination: SettingsDestination {
- .location
- }
-
- enum Item: SettingsItem {
- case tracking
-
- var title: String {
- switch self {
- case .tracking: String(localized: .settingsLocationToggle)
- }
- }
-
- var keywords: [String] {
- switch self {
- case .tracking: splitKeywords(String(localized: .settingsKeywordsTracking))
- }
- }
- }
-}
-
-#if DEBUG
- #Preview {
- NavigationStack {
- LocationSettingsView()
- .environment(PreviewSupport.loadedSession())
- }
- .whereBroadwayRoot()
- }
-#endif
-
-#if DEBUG
- extension LocationSettingsView: WhereFlyoverProviding {
- static let flyoverData = WhereFlyoverData.hosted(
- LocationSettingsView.self,
- title: "Location Settings",
- ) { _ in
- LocationSettingsView()
- }
- }
-#endif
diff --git a/Where/WhereUI/Sources/Settings/MenuBarSettingsModel.swift b/Where/WhereUI/Sources/Settings/MenuBarSettingsModel.swift
new file mode 100644
index 000000000..341234205
--- /dev/null
+++ b/Where/WhereUI/Sources/Settings/MenuBarSettingsModel.swift
@@ -0,0 +1,100 @@
+#if targetEnvironment(macCatalyst)
+ import Observation
+ import ServiceManagement
+
+ /// Mirrors the embedded login item's real Service Management state and
+ /// applies the user's enable/disable request.
+ @MainActor
+ @Observable
+ final class MenuBarSettingsModel {
+ /// The states Settings needs to distinguish without exposing
+ /// `SMAppService` to the view.
+ enum Status: Equatable {
+ case disabled
+ case enabled
+ case requiresApproval
+ case unavailable
+
+ var isRegistered: Bool {
+ switch self {
+ case .enabled, .requiresApproval:
+ true
+ case .disabled, .unavailable:
+ false
+ }
+ }
+ }
+
+ private let service = SMAppService.loginItem(identifier: "com.stuff.where.menubar")
+
+ private(set) var status: Status
+ private(set) var isApplying = false
+ private(set) var errorMessage: String?
+ var isEnabled: Bool
+
+ var isShowingError: Bool {
+ get { errorMessage != nil }
+ set {
+ if !newValue {
+ errorMessage = nil
+ }
+ }
+ }
+
+ init() {
+ let status = Self.status(for: service.status)
+ self.status = status
+ isEnabled = status.isRegistered
+ }
+
+ func refresh() {
+ status = Self.status(for: service.status)
+ isEnabled = status.isRegistered
+ }
+
+ func applyRequestedState() async {
+ let serviceStatus = Self.status(for: service.status)
+ guard isEnabled != serviceStatus.isRegistered else {
+ status = serviceStatus
+ return
+ }
+
+ isApplying = true
+ defer {
+ isApplying = false
+ refresh()
+ }
+
+ do {
+ if isEnabled {
+ try service.register()
+ } else {
+ try await service.unregister()
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func openLoginItemsSettings() {
+ SMAppService.openSystemSettingsLoginItems()
+ }
+
+ private static func status(for status: SMAppService.Status) -> Status {
+ switch status {
+ case .notRegistered:
+ .disabled
+ case .enabled:
+ .enabled
+ case .requiresApproval:
+ .requiresApproval
+ case .notFound:
+ .unavailable
+ @unknown default:
+ .unavailable
+ }
+ }
+ }
+#endif
diff --git a/Where/WhereUI/Sources/Settings/MenuBarSettingsSection.swift b/Where/WhereUI/Sources/Settings/MenuBarSettingsSection.swift
new file mode 100644
index 000000000..9b83fd3f1
--- /dev/null
+++ b/Where/WhereUI/Sources/Settings/MenuBarSettingsSection.swift
@@ -0,0 +1,65 @@
+#if targetEnvironment(macCatalyst)
+ import SwiftUI
+
+ /// Mac-only control for the native, embedded menu-bar login item.
+ struct MenuBarSettingsSection: View {
+ @Bindable var model: MenuBarSettingsModel
+ @Environment(\.scenePhase) private var scenePhase
+
+ var body: some View {
+ Section {
+ Toggle(
+ String(localized: .settingsMenuBarEnabled),
+ isOn: $model.isEnabled,
+ )
+ .disabled(model.isApplying || model.status == .unavailable)
+
+ if model.status == .requiresApproval {
+ Button(
+ String(localized: .settingsMenuBarOpenLoginItems),
+ action: model.openLoginItemsSettings,
+ )
+ }
+ } header: {
+ Text(String(localized: .settingsMenuBarHeader))
+ } footer: {
+ Text(footer)
+ }
+ .task(id: model.isEnabled) {
+ await model.applyRequestedState()
+ }
+ .onChange(of: scenePhase) { _, phase in
+ if phase == .active {
+ model.refresh()
+ }
+ }
+ .alert(
+ String(localized: .settingsMenuBarErrorTitle),
+ isPresented: $model.isShowingError,
+ presenting: model.errorMessage,
+ ) { _ in
+ } message: { message in
+ Text(message)
+ }
+ }
+
+ private var footer: String {
+ switch model.status {
+ case .disabled, .enabled:
+ String(localized: .settingsMenuBarFooter)
+ case .requiresApproval:
+ String(localized: .settingsMenuBarApprovalFooter)
+ case .unavailable:
+ String(localized: .settingsMenuBarUnavailableFooter)
+ }
+ }
+ }
+
+ #if DEBUG
+ #Preview {
+ Form {
+ MenuBarSettingsSection(model: MenuBarSettingsModel())
+ }
+ }
+ #endif
+#endif
diff --git a/Where/WhereUI/Sources/Settings/SettingsRow.swift b/Where/WhereUI/Sources/Settings/SettingsRow.swift
index 9beee66ca..104ddd67b 100644
--- a/Where/WhereUI/Sources/Settings/SettingsRow.swift
+++ b/Where/WhereUI/Sources/Settings/SettingsRow.swift
@@ -104,13 +104,15 @@ struct SettingsFocusScope: View {
#if DEBUG
#Preview {
- SettingsFocusScope(focus: SettingsFocus(LocationSettingsView.Item.tracking)) {
+ SettingsFocusScope(
+ focus: SettingsFocus(DevicesSettingsView.Item.automaticRecording),
+ ) {
List {
Label(
- String(localized: .settingsLocationToggle),
+ String(localized: .settingsDevicesAutomaticRecording),
systemImage: "location.fill",
)
- .settingsRow(LocationSettingsView.Item.tracking)
+ .settingsRow(DevicesSettingsView.Item.automaticRecording)
}
}
.whereBroadwayRoot()
diff --git a/Where/WhereUI/Sources/Settings/SettingsSearch.swift b/Where/WhereUI/Sources/Settings/SettingsSearch.swift
index 0aefb1133..6c3d55c18 100644
--- a/Where/WhereUI/Sources/Settings/SettingsSearch.swift
+++ b/Where/WhereUI/Sources/Settings/SettingsSearch.swift
@@ -7,7 +7,7 @@ import SwiftUI
enum SettingsDestination: Hashable, CaseIterable {
case attachments
case loggedDays
- case location
+ case devices
case regions
case alerts
case appearance
@@ -21,7 +21,7 @@ enum SettingsDestination: Hashable, CaseIterable {
switch self {
case .attachments: String(localized: .settingsAttachmentsRow)
case .loggedDays: String(localized: .settingsLoggedDaysRow)
- case .location: String(localized: .settingsLocationHeader)
+ case .devices: String(localized: .settingsDevicesTitle)
case .regions: String(localized: .settingsRegionsSection)
case .alerts: String(localized: .settingsAlertsGroup)
case .appearance: String(localized: .settingsAppearanceGroup)
@@ -36,7 +36,7 @@ enum SettingsDestination: Hashable, CaseIterable {
switch self {
case .attachments: "paperclip"
case .loggedDays: "calendar.badge.plus"
- case .location: "location.fill"
+ case .devices: "iphone.and.arrow.forward"
case .regions: "map.fill"
case .alerts: "bell.badge"
case .appearance: "paintbrush.fill"
@@ -53,7 +53,7 @@ enum SettingsDestination: Hashable, CaseIterable {
switch self {
case .attachments: .indigo
case .loggedDays: .mint
- case .location: .blue
+ case .devices: .blue
case .regions: .green
case .alerts: .red
case .appearance: .purple
@@ -72,7 +72,7 @@ enum SettingsDestination: Hashable, CaseIterable {
var isAvailableInDemoMode: Bool {
switch self {
case .data, .appearance: false
- case .attachments, .loggedDays, .location, .regions, .alerts, .year, .about: true
+ case .attachments, .loggedDays, .devices, .regions, .alerts, .year, .about: true
}
}
@@ -82,7 +82,7 @@ enum SettingsDestination: Hashable, CaseIterable {
var isSheet: Bool {
switch self {
case .regions: true
- case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .data, .about:
+ case .attachments, .loggedDays, .devices, .alerts, .appearance, .year, .data, .about:
false
}
}
@@ -105,7 +105,7 @@ enum SettingsListSection: CaseIterable {
var destinations: [SettingsDestination] {
switch self {
case .userData: [.attachments, .loggedDays, .regions]
- case .tracking: [.location]
+ case .tracking: [.devices]
case .notifications: [.alerts]
case .display: [.appearance, .year]
case .storage: [.data]
@@ -115,7 +115,7 @@ enum SettingsListSection: CaseIterable {
}
/// A per-screen setting identity. Conformers are small, screen-local enums (e.g.
-/// `LocationSettingsView.Item`) that also carry their own localized search text,
+/// `DevicesSettingsView.Item`) that also carry their own localized search text,
/// so the search index is *derived* from the cases and can't drift from them.
protocol SettingsItem: Hashable, CaseIterable {
/// The setting's localized name, matched by search and shown in results.
@@ -209,7 +209,7 @@ enum SettingsCatalog {
static let results: [SettingsSearchResult] =
EvidenceListView.searchResults
+ LoggedDaysView.searchResults
- + LocationSettingsView.searchResults
+ + DevicesSettingsView.searchResults
+ RegionsSettingsView.searchResults
+ AlertsSettingsView.searchResults
+ AppearanceSettingsView.searchResults
diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift
index 5926012f8..f543c1850 100644
--- a/Where/WhereUI/Sources/Settings/SettingsView.swift
+++ b/Where/WhereUI/Sources/Settings/SettingsView.swift
@@ -5,15 +5,15 @@ import WhereCore
/// Settings tab: an iOS-Settings-style top-level list of icon rows that drill
/// into grouped sub-screens — a Data group at the top (attachments, logged days,
-/// regions), then location, alerts, appearance, report year, data management,
+/// regions), then devices, alerts, appearance, report year, data management,
/// and About — plus a search field that filters individual settings and
/// deep-links to the screen — and the row — containing each.
///
/// The top level owns nothing but navigation; behavior lives in the sub-screens
-/// (`LocationSettingsView`, `AlertsSettingsView`, …). The scene's report model and
+/// (`DevicesSettingsView`, `AlertsSettingsView`, …). The scene's report model and
/// the two view-scoped editing models (backup, reminders) are owned here and
-/// handed down; the `WhereSession` coordinator (location) and `WhereModel` (reset)
-/// come from the environment via the sub-screens.
+/// handed down; the `WhereSession` coordinator (recording/location) and
+/// `WhereModel` (reset) come from the environment via the sub-screens.
struct SettingsView: View {
let report: YearReportModel
@State private var backup: BackupModel
@@ -157,7 +157,7 @@ struct SettingsView: View {
switch destination {
case .regions:
showRegions = true
- case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .data, .about:
+ case .attachments, .loggedDays, .devices, .alerts, .appearance, .year, .data, .about:
assertionFailure("\(destination) is a push destination, not a sheet")
}
}
@@ -183,11 +183,13 @@ struct SettingsView: View {
/// for groups without a meaningful one-line summary.
private func subtitle(for destination: SettingsDestination) -> String? {
switch destination {
- case .location:
- LocationStatusRow.statusTitle(
- status: session.authorizationStatus,
- isTracking: session.isTracking,
- )
+ case .devices:
+ session.supportsLocalRecording
+ ? LocationStatusRow.statusTitle(
+ status: session.authorizationStatus,
+ isTracking: session.isTracking,
+ )
+ : nil
case .year:
report.selectedYear.formatted(.number.grouping(.never))
case .attachments, .loggedDays, .regions, .alerts, .appearance, .data, .about:
@@ -220,8 +222,8 @@ struct SettingsView: View {
EvidenceListView(report: report)
case .loggedDays:
LoggedDaysView(report: report)
- case .location:
- LocationSettingsView(focus: route.focus)
+ case .devices:
+ DevicesSettingsView(session: session, focus: route.focus)
case .regions:
// Regions is presented as a sheet (`isSheet`), so it's never
// routed here; this arm only keeps the switch exhaustive.
@@ -289,7 +291,7 @@ struct SettingsView: View {
.push(to: EvidenceListView.flyoverID),
.push(to: LoggedDaysView.flyoverID),
.modal(to: RegionsSettingsView.flyoverID),
- .push(to: LocationSettingsView.flyoverID),
+ .push(to: DevicesSettingsView.flyoverID),
.push(to: AlertsSettingsView.flyoverID),
.push(to: AppearanceSettingsView.flyoverID),
.push(to: VisibleYearSettingsView.flyoverID),
diff --git a/Where/WhereUI/Sources/Widgets/MacSummaryWidgetView.swift b/Where/WhereUI/Sources/Widgets/MacSummaryWidgetView.swift
new file mode 100644
index 000000000..f06be745c
--- /dev/null
+++ b/Where/WhereUI/Sources/Widgets/MacSummaryWidgetView.swift
@@ -0,0 +1,97 @@
+import SnapshotKit
+import SwiftUI
+import WhereCore
+
+/// Combined Mac widget content: today's observed regions beside year-to-date
+/// day counts, rendered entirely from the app-published snapshot.
+public struct MacSummaryWidgetView: View {
+ public enum Layout: Sendable {
+ case compact
+ case wide
+ }
+
+ private let snapshot: WidgetSnapshot
+ private let layout: Layout
+
+ public init(snapshot: WidgetSnapshot, layout: Layout) {
+ self.snapshot = snapshot
+ self.layout = layout
+ }
+
+ @Environment(\.stylesheet) private var stylesheet
+
+ public var body: some View {
+ switch layout {
+ case .compact:
+ VStack(spacing: stylesheet.spacing.small) {
+ TodayWidgetView(snapshot: snapshot)
+ Divider()
+ YearTotalsWidgetView(snapshot: snapshot, maxRows: 1)
+ }
+ case .wide:
+ HStack(spacing: stylesheet.spacing.medium) {
+ TodayWidgetView(snapshot: snapshot)
+ Divider()
+ YearTotalsWidgetView(snapshot: snapshot, maxRows: 3)
+ }
+ }
+ }
+}
+
+#if DEBUG
+ extension MacSummaryWidgetView: SnapshotProviding {
+ public static var snapshots: [SnapshotCase] {
+ let snapshot = PreviewSupport.sampleWidgetSnapshot(
+ dayRegions: [.california],
+ totals: [.california: 132, .newYork: 41, .canada: 9],
+ )
+ return [
+ whereSnapshot(
+ name: "Wide",
+ configurations: SnapshotConfiguration.combinations(
+ devices: [
+ .init(
+ name: "MacMedium",
+ size: .fixed(CGSize(width: 338, height: 158)),
+ ),
+ ],
+ colorSchemes: [.light, .dark],
+ ),
+ settle: .immediate,
+ ) {
+ MacSummaryWidgetView(snapshot: snapshot, layout: .wide)
+ },
+ whereSnapshot(
+ name: "Compact",
+ configurations: SnapshotConfiguration.combinations(
+ devices: [
+ .init(
+ name: "MacSmall",
+ size: .fixed(CGSize(width: 158, height: 158)),
+ ),
+ ],
+ colorSchemes: [.light, .dark],
+ ),
+ settle: .immediate,
+ ) {
+ MacSummaryWidgetView(snapshot: snapshot, layout: .compact)
+ },
+ ]
+ }
+ }
+
+ #Preview {
+ MacSummaryWidgetView.snapshotPreviews
+ }
+#endif
+
+#if DEBUG
+ extension MacSummaryWidgetView: WhereFlyoverProviding {
+ static let flyoverData = WhereFlyoverData.snapshots(
+ MacSummaryWidgetView.self,
+ title: "Mac Summary Widget",
+ viewport: .fixed(CGSize(width: 338, height: 158)),
+ navigationContainer: .none,
+ )
+ }
+#endif
diff --git a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift
new file mode 100644
index 000000000..471f9ecf7
--- /dev/null
+++ b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift
@@ -0,0 +1,75 @@
+import Foundation
+import Testing
+@testable import WhereUI
+
+@MainActor
+struct CurrentRecordingDeviceProviderTests {
+ @Test func phonePersistsOneInstallationIdentityAndDefaultsOn() throws {
+ let suiteName = "CurrentRecordingDeviceProviderTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let first = CurrentRecordingDeviceProvider.participation(
+ defaults: defaults,
+ idiom: .phone,
+ )
+ let second = CurrentRecordingDeviceProvider.participation(
+ defaults: defaults,
+ idiom: .phone,
+ )
+ let firstDevice = try #require(first.currentDevice)
+
+ #expect(first == second)
+ #expect(first.defaultEnabledForNewInstallation)
+ #expect(defaults.dictionaryRepresentation().values.contains {
+ ($0 as? String) == firstDevice.id.rawValue.uuidString
+ })
+ #expect(firstDevice.systemName.isEmpty == false)
+ }
+
+ @Test func tabletParticipatesButDefaultsOff() throws {
+ let suiteName = "CurrentRecordingDeviceProviderTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let participation = CurrentRecordingDeviceProvider.participation(
+ defaults: defaults,
+ idiom: .pad,
+ )
+
+ #expect(participation.currentDevice?.kind == .tablet)
+ #expect(participation.defaultEnabledForNewInstallation == false)
+ }
+
+ @Test func macIsManagementOnlyAndDoesNotMintAnIdentity() throws {
+ let suiteName = "CurrentRecordingDeviceProviderTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let participation = CurrentRecordingDeviceProvider.participation(
+ defaults: defaults,
+ idiom: .mac,
+ )
+
+ #expect(participation == .managementOnly)
+ let persistedDefaults = defaults.persistentDomain(forName: suiteName) ?? [:]
+ #expect(persistedDefaults.isEmpty)
+ }
+
+ @Test func demoKeepsAManagementOnlyHostManagementOnly() {
+ let participation = CurrentRecordingDeviceProvider.demoParticipation(
+ supportsLocalRecording: false,
+ )
+
+ #expect(participation == .managementOnly)
+ }
+
+ #if targetEnvironment(macCatalyst)
+ @Test func catalystDemoUsesManagementOnlyParticipationForItsCurrentHost() {
+ #expect(
+ CurrentRecordingDeviceProvider.demoParticipationForCurrentHost
+ == .managementOnly,
+ )
+ }
+ #endif
+}
diff --git a/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift
new file mode 100644
index 000000000..a4c800ce2
--- /dev/null
+++ b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift
@@ -0,0 +1,96 @@
+import Foundation
+import Testing
+import WhereCore
+@testable import WhereUI
+
+@MainActor
+struct DeviceSettingsRowModelTests {
+ private static let id = RecordingDeviceID(
+ rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!,
+ )
+ private static let policyID = UUID(
+ uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB",
+ )!
+ private static let date = Date(timeIntervalSinceReferenceDate: 100)
+
+ @Test func presentsNicknameKindAndAcknowledgement() {
+ let row = DeviceSettingsRowModel(
+ configuration: configuration(
+ nickname: "Home iPad",
+ status: .recording,
+ appliedPolicyID: Self.policyID,
+ ),
+ isCurrent: false,
+ )
+
+ #expect(row.displayName == "Home iPad")
+ #expect(row.systemImage == "ipad")
+ #expect(row.isPending == false)
+ }
+
+ @Test func updateKeepsEditableObjectIdentityAndAppliesRemoteState() {
+ let row = DeviceSettingsRowModel(
+ configuration: configuration(
+ nickname: nil,
+ status: .recording,
+ appliedPolicyID: Self.policyID,
+ ),
+ isCurrent: false,
+ )
+ row.update(from: configuration(
+ nickname: "Desk",
+ status: .off,
+ appliedPolicyID: nil,
+ ))
+
+ #expect(row.id == Self.id)
+ #expect(row.displayName == "Desk")
+ #expect(row.confirmedNickname == "Desk")
+ #expect(row.status == .off)
+ #expect(row.isPending)
+ #expect(row.confirmedIsEnabled == false)
+ }
+
+ @Test func syncedRefreshDoesNotOverwriteAnUnsavedNickname() {
+ let row = DeviceSettingsRowModel(
+ configuration: configuration(
+ nickname: "Home",
+ status: .off,
+ appliedPolicyID: Self.policyID,
+ ),
+ isCurrent: false,
+ )
+ row.nickname = "Home iPad"
+
+ row.update(from: configuration(
+ nickname: "Synced elsewhere",
+ status: .off,
+ appliedPolicyID: Self.policyID,
+ ))
+
+ #expect(row.nickname == "Home iPad")
+ #expect(row.confirmedNickname == "Synced elsewhere")
+ }
+
+ private func configuration(
+ nickname: String?,
+ status: RecordingDeviceStatus,
+ appliedPolicyID: UUID?,
+ ) -> RecordingDeviceConfiguration {
+ RecordingDeviceConfiguration(
+ device: RecordingDevice(
+ id: Self.id,
+ systemName: "iPad",
+ nickname: nickname,
+ kind: .tablet,
+ registeredAt: Self.date,
+ lastSeenAt: Self.date,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: appliedPolicyID,
+ status: status,
+ ),
+ isEnabled: status != .off,
+ latestPolicyChangeID: Self.policyID,
+ )
+ }
+}
diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift
new file mode 100644
index 000000000..84f3f2001
--- /dev/null
+++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift
@@ -0,0 +1,100 @@
+import Foundation
+import Testing
+@_spi(Testing) import WhereCore
+@testable import WhereUI
+
+@MainActor
+struct DevicesSettingsModelTests {
+ private static let now = Date(timeIntervalSinceReferenceDate: 1000)
+
+ private func makeSubject() throws -> (
+ model: DevicesSettingsModel,
+ session: WhereSession,
+ store: SwiftDataStore
+ ) {
+ let store = try SwiftDataStore.inMemory()
+ let services = WhereServices(
+ store: store,
+ locationSource: ScriptedLocationSource(authorizationStatus: .always),
+ recordingParticipation: .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: true,
+ ),
+ now: { Self.now },
+ )
+ let preferences = makePreferences()
+ preferences.wantsTracking = true
+ let session = WhereSession(services: services, preferences: preferences)
+ return (DevicesSettingsModel(session: session), session, store)
+ }
+
+ @Test func loadsTheCurrentDeviceAndAwaitsAToggle() async throws {
+ let subject = try makeSubject()
+ await subject.session.start()
+ await subject.model.retry()
+ let row = try #require(subject.model.rows.first)
+ #expect(row.isCurrent)
+ #expect(row.isEnabled)
+ #expect(row.status == .recording)
+
+ row.isEnabled = false
+ await subject.model.setEnabled(false, row: row)
+
+ #expect(row.isEnabled == false)
+ #expect(row.status == .off)
+ #expect(row.isPending == false)
+ #expect(subject.session.isTracking == false)
+ #expect(subject.session.preferences.wantsTracking == false)
+ }
+
+ @Test func renamesAndArchivesARemoteDevice() async throws {
+ let subject = try makeSubject()
+ await subject.session.start()
+ let remoteID = try RecordingDeviceID(
+ rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")),
+ )
+ try await subject.store.perform {
+ try await subject.store.setRecordingDevice(RecordingDevice(
+ id: remoteID,
+ systemName: "iPad",
+ nickname: nil,
+ kind: .tablet,
+ registeredAt: Self.now,
+ lastSeenAt: Self.now,
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: nil,
+ status: .off,
+ ))
+ }
+ await subject.model.retry()
+ let remote = try #require(subject.model.rows.first(where: { $0.id == remoteID }))
+
+ remote.nickname = "Home iPad"
+ await subject.model.rename(remote)
+ #expect(remote.displayName == "Home iPad")
+ #expect(try await subject.store.recordingDevices()
+ .first(where: { $0.id == remoteID })?.nickname == "Home iPad")
+
+ await subject.model.archive(remote)
+ #expect(subject.model.rows.contains(where: { $0.id == remoteID }) == false)
+ #expect(try await subject.store.recordingDevices()
+ .first(where: { $0.id == remoteID })?.archivedAt == Self.now)
+ }
+
+ @Test func repeatedNicknameCommitNormalizesWithoutChangingTheConfirmedName() async throws {
+ let subject = try makeSubject()
+ await subject.session.start()
+ await subject.model.retry()
+ let row = try #require(subject.model.rows.first)
+
+ row.nickname = "Pocket"
+ await subject.model.rename(row)
+ row.nickname = " Pocket "
+ await subject.model.rename(row)
+
+ #expect(row.nickname == "Pocket")
+ #expect(row.confirmedNickname == "Pocket")
+ #expect(try await subject.store.recordingDevices()
+ .first(where: { $0.id == row.id })?.nickname == "Pocket")
+ }
+}
diff --git a/Where/WhereUI/Tests/LocationSettingsViewTests.swift b/Where/WhereUI/Tests/LocationSettingsViewTests.swift
deleted file mode 100644
index da4cc4784..000000000
--- a/Where/WhereUI/Tests/LocationSettingsViewTests.swift
+++ /dev/null
@@ -1,15 +0,0 @@
-import SwiftUI
-import TestHostSupport
-import Testing
-@testable import WhereUI
-
-@MainActor
-struct LocationSettingsViewTests {
- @Test func hostsWithASession() throws {
- let rootView = NavigationStack { LocationSettingsView() }
- .environment(PreviewSupport.loadedSession())
- try show(UIHostingController(rootView: rootView)) { hosted in
- #expect(hosted.view != nil)
- }
- }
-}
diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift
index 89bb0fdd8..7da076556 100644
--- a/Where/WhereUI/Tests/OnboardingTests.swift
+++ b/Where/WhereUI/Tests/OnboardingTests.swift
@@ -1,9 +1,18 @@
+import Foundation
import Testing
@_spi(Testing) import WhereCore
-import WhereUI
+@testable import WhereUI
@MainActor
struct OnboardingModelTests {
+ @Test func managementOnlyOnboardingDoesNotAdvertiseAutomaticRecording() {
+ #expect(OnboardingPage.pages(supportsRecording: false).map(\.id) == ["welcome", "privacy"])
+ #expect(
+ OnboardingPage.pages(supportsRecording: true).map(\.id)
+ == ["welcome", "automatic", "privacy"],
+ )
+ }
+
@Test func hasOnboardedDefaultsFalse() {
let model = WhereModel(
preferences: makePreferences(),
@@ -31,4 +40,215 @@ struct OnboardingModelTests {
)
#expect(relaunched.hasOnboarded)
}
+
+ @Test func joiningExistingDataOpensTheRealScopeWithLocalRecordingDisabled() async throws {
+ let store = try SwiftDataStore.inMemory()
+ let services = WhereServices(
+ store: store,
+ locationSource: ScriptedLocationSource(authorizationStatus: .always),
+ )
+ let bootstrap = ScriptedBootstrap(services: services)
+ let preferences = makePreferences()
+ let model = WhereModel(
+ preferences: preferences,
+ makeBootstrap: { bootstrap },
+ logSystem: .isolated(),
+ )
+
+ try await model.joinExistingData()
+
+ #expect(model.activeScope != nil)
+ #expect(model.hasOnboarded)
+ #expect(preferences.wantsTracking == false)
+ #expect(bootstrap.makeServicesCount == 1)
+ let current = try #require(
+ try await services.recording.devices(initialEnabled: false).first,
+ )
+ #expect(current.isEnabled == false)
+ #expect(current.device.status == .off)
+ #expect(await services.ingestor.isActive == false)
+ #expect(try await store.allSamples().isEmpty)
+ }
+
+ @Test func joiningExistingDataOverridesAnEnabledSyncedCurrentDevice() async throws {
+ let now = Date(timeIntervalSince1970: 2_000_000_000)
+ let store = try SwiftDataStore.inMemory()
+ let enabledPolicyID = UUID()
+ try await store.perform {
+ try await store.setRecordingDevice(RecordingDevice(
+ id: CurrentRecordingDevice.preview.id,
+ systemName: CurrentRecordingDevice.preview.systemName,
+ nickname: nil,
+ kind: CurrentRecordingDevice.preview.kind,
+ registeredAt: now.addingTimeInterval(-120),
+ lastSeenAt: now.addingTimeInterval(-60),
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: enabledPolicyID,
+ status: .recording,
+ ))
+ try await store.addRecordingPolicyChange(RecordingPolicyChange(
+ id: enabledPolicyID,
+ deviceID: CurrentRecordingDevice.preview.id,
+ effectiveAt: now.addingTimeInterval(-60),
+ isEnabled: true,
+ ))
+ }
+ let services = WhereServices(
+ store: store,
+ locationSource: ScriptedLocationSource(authorizationStatus: .always),
+ now: { now },
+ )
+ let preferences = makePreferences()
+ let model = WhereModel(
+ preferences: preferences,
+ makeBootstrap: { ScriptedBootstrap(services: services) },
+ logSystem: .isolated(),
+ )
+
+ try await model.joinExistingData()
+
+ let current = try #require(
+ try await services.recording.devices(initialEnabled: false).first,
+ )
+ #expect(current.id == CurrentRecordingDevice.preview.id)
+ #expect(current.isEnabled == false)
+ #expect(current.device.status == .off)
+ #expect(await services.ingestor.isActive == false)
+ #expect(preferences.wantsTracking == false)
+ #expect(model.hasOnboarded)
+ }
+
+ @Test func notNowOverridesAnEnabledRestoredCurrentDevicePolicy() async throws {
+ let subject = try await makeRestoredPolicySubject(isEnabled: true)
+ let scope = try await subject.model.resolveScope()
+
+ try await subject.model.applyOnboardingRecordingChoice(false, in: scope)
+
+ let policies = try await subject.store.recordingPolicyChanges()
+ let latest = try #require(policies.max { lhs, rhs in
+ lhs.effectiveAt < rhs.effectiveAt
+ })
+ #expect(policies.count == 2)
+ #expect(latest.isEnabled == false)
+ #expect(subject.preferences.wantsTracking == false)
+ let current = try #require(
+ try await subject.services.recording.devices(initialEnabled: false).first,
+ )
+ #expect(current.isEnabled == false)
+ #expect(current.device.status == .off)
+ #expect(await subject.services.ingestor.isActive == false)
+ }
+
+ @Test func enablingOverridesADisabledRestoredCurrentDevicePolicy() async throws {
+ let subject = try await makeRestoredPolicySubject(isEnabled: false)
+ let scope = try await subject.model.resolveScope()
+
+ try await subject.model.applyOnboardingRecordingChoice(true, in: scope)
+
+ let policies = try await subject.store.recordingPolicyChanges()
+ let latest = try #require(policies.max { lhs, rhs in
+ lhs.effectiveAt < rhs.effectiveAt
+ })
+ #expect(policies.count == 2)
+ #expect(latest.isEnabled)
+ #expect(subject.preferences.wantsTracking)
+ let current = try #require(
+ try await subject.services.recording.devices(initialEnabled: true).first,
+ )
+ #expect(current.isEnabled)
+ #expect(current.device.status == .recording)
+ #expect(await subject.services.ingestor.isActive)
+ }
+
+ @Test func failedExistingDataJoinRemainsLoggedOutAndRetryable() async {
+ let preferences = makePreferences()
+ let model = WhereModel(
+ preferences: preferences,
+ makeBootstrap: { FailingBootstrap() },
+ logSystem: .isolated(),
+ )
+
+ do {
+ try await model.joinExistingData()
+ Issue.record("Expected joining existing data to fail.")
+ } catch is FailingBootstrap.AssemblyFailure {
+ // Expected. A later call uses the same still-unconsumed bootstrap,
+ // which is the retry path the onboarding alert exposes.
+ } catch {
+ Issue.record("Unexpected join error: \(error)")
+ }
+
+ #expect(model.activeScope == nil)
+ #expect(model.hasOnboarded == false)
+ #expect(preferences.wantsTracking)
+ }
+
+ @Test func introStateCarriesExistingDataJoinProgressAndFailure() {
+ let state = OnboardingIntroState()
+ state.activity = .joiningExistingData
+
+ #expect(state.isJoiningExistingData)
+ #expect(state.failure == nil)
+
+ state.activity = .failed(.init(
+ flow: .joinExistingData,
+ error: FailingBootstrap.AssemblyFailure(),
+ ))
+
+ #expect(state.isJoiningExistingData == false)
+ #expect(state.failure?.flow == .joinExistingData)
+ #expect(state.isShowingFailure)
+ }
+
+ private struct RestoredPolicySubject {
+ let model: WhereModel
+ let services: WhereServices
+ let store: SwiftDataStore
+ let preferences: WherePreferences
+ }
+
+ private func makeRestoredPolicySubject(
+ isEnabled: Bool,
+ ) async throws -> RestoredPolicySubject {
+ let now = Date(timeIntervalSince1970: 2_000_000_000)
+ let store = try SwiftDataStore.inMemory()
+ let policyID = UUID()
+ try await store.perform {
+ try await store.setRecordingDevice(RecordingDevice(
+ id: CurrentRecordingDevice.preview.id,
+ systemName: CurrentRecordingDevice.preview.systemName,
+ nickname: nil,
+ kind: CurrentRecordingDevice.preview.kind,
+ registeredAt: now.addingTimeInterval(-120),
+ lastSeenAt: now.addingTimeInterval(-60),
+ archivedAt: nil,
+ lastAppliedPolicyChangeID: policyID,
+ status: isEnabled ? .recording : .off,
+ ))
+ try await store.addRecordingPolicyChange(RecordingPolicyChange(
+ id: policyID,
+ deviceID: CurrentRecordingDevice.preview.id,
+ effectiveAt: now.addingTimeInterval(-60),
+ isEnabled: isEnabled,
+ ))
+ }
+ let services = WhereServices(
+ store: store,
+ locationSource: ScriptedLocationSource(authorizationStatus: .always),
+ now: { now },
+ )
+ let preferences = makePreferences()
+ preferences.wantsTracking = isEnabled
+ let model = WhereModel(
+ preferences: preferences,
+ makeBootstrap: { ScriptedBootstrap(services: services) },
+ logSystem: .isolated(),
+ )
+ return RestoredPolicySubject(
+ model: model,
+ services: services,
+ store: store,
+ preferences: preferences,
+ )
+ }
}
diff --git a/Where/WhereUI/Tests/SettingsSearchTests.swift b/Where/WhereUI/Tests/SettingsSearchTests.swift
index 647d23acc..8276564d8 100644
--- a/Where/WhereUI/Tests/SettingsSearchTests.swift
+++ b/Where/WhereUI/Tests/SettingsSearchTests.swift
@@ -41,11 +41,11 @@ struct SettingsSearchTests {
}
@Test func matchesOnKeyword() {
- // "gps" is a keyword for both the location-tracking and data-resolution
+ // "gps" is a keyword for both the device-recording and data-resolution
// settings, but not part of either title.
let results = SettingsCatalog.results(matching: "gps")
let destinations = Set(results.map(\.destination))
- #expect(destinations.contains(.location))
+ #expect(destinations.contains(.devices))
#expect(destinations.contains(.alerts))
}
@@ -64,6 +64,6 @@ struct SettingsSearchTests {
}
@Test func groupRouteHasNoFocus() {
- #expect(SettingsRoute(.location).focus == nil)
+ #expect(SettingsRoute(.devices).focus == nil)
}
}
diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift
index 2eb7ff1f5..ee7908a7b 100644
--- a/Where/WhereUI/Tests/Support/TestStore.swift
+++ b/Where/WhereUI/Tests/Support/TestStore.swift
@@ -89,6 +89,29 @@ actor TestStore: WhereStore {
try await backing.allSamples()
}
+ func recordingDevices() async throws -> [RecordingDevice] {
+ try await backing.recordingDevices()
+ }
+
+ func setRecordingDevice(_ device: RecordingDevice) async throws {
+ try await backing.setRecordingDevice(device)
+ }
+
+ func updateRecordingDevice(
+ _ id: RecordingDeviceID,
+ transform: @Sendable (RecordingDevice) -> RecordingDevice,
+ ) async throws -> RecordingDevice? {
+ try await backing.updateRecordingDevice(id, transform: transform)
+ }
+
+ func recordingPolicyChanges() async throws -> [RecordingPolicyChange] {
+ try await backing.recordingPolicyChanges()
+ }
+
+ func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws {
+ try await backing.addRecordingPolicyChange(change)
+ }
+
func write(evidence: Evidence, blob: Data?) async throws {
try await backing.write(evidence: evidence, blob: blob)
}
diff --git a/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift b/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift
index 6ef4f8f80..055741757 100644
--- a/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift
+++ b/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift
@@ -28,6 +28,8 @@ struct SwiftDataInspectorWiringTests {
"SDEvidence",
"SDLocationSample",
"SDManualDay",
+ "SDRecordingDevice",
+ "SDRecordingPolicyChange",
"SDTrackedRegion",
])
}
diff --git a/Where/WhereUI/Tests/WhereSessionTests.swift b/Where/WhereUI/Tests/WhereSessionTests.swift
index fad35eb56..82c6b43d1 100644
--- a/Where/WhereUI/Tests/WhereSessionTests.swift
+++ b/Where/WhereUI/Tests/WhereSessionTests.swift
@@ -158,7 +158,7 @@ private actor SpyWidgetRefresher: WidgetTimelineRefreshing {
publishedSnapshots.last
}
- func publish(_ snapshot: WidgetSnapshot) async {
+ func publish(_ snapshot: WidgetSnapshot) async throws {
publishedSnapshots.append(snapshot)
}
}
diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift
index 2ae75274e..c79fd44b5 100644
--- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift
+++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift
@@ -71,6 +71,61 @@ struct WhereSessionTrackingTests {
#expect(!session.permissionDenied)
}
+ @Test func newTabletInstallationDefaultsLocalRecordingOff() async throws {
+ let store = try SwiftDataStore.inMemory()
+ let services = WhereServices(
+ store: store,
+ locationSource: ScriptedLocationSource(authorizationStatus: .always),
+ recordingParticipation: .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: false,
+ ),
+ )
+ let session = WhereSession(services: services, preferences: makePreferences())
+
+ await session.start()
+
+ #expect(session.isTracking == false)
+ #expect(try await session.recordingDevices().first?.isEnabled == false)
+ }
+
+ @Test func migratedTabletInstallationKeepsLegacyRecordingIntent() async throws {
+ let preferences = makePreferences()
+ preferences.hasOnboarded = true
+ let services = try WhereServices(
+ store: SwiftDataStore.inMemory(),
+ locationSource: ScriptedLocationSource(authorizationStatus: .always),
+ recordingParticipation: .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: false,
+ ),
+ )
+ let session = WhereSession(services: services, preferences: preferences)
+
+ await session.start()
+
+ #expect(session.isTracking)
+ #expect(try await session.recordingDevices().first?.isEnabled == true)
+ }
+
+ @Test func managementOnlySessionNeverCreatesALocalRecordingDevice() async throws {
+ let store = try SwiftDataStore.inMemory()
+ let services = WhereServices(
+ store: store,
+ locationSource: ScriptedLocationSource(authorizationStatus: .always),
+ recordingParticipation: .managementOnly,
+ )
+ let session = WhereSession(services: services, preferences: makePreferences())
+
+ await session.start()
+
+ #expect(session.supportsLocalRecording == false)
+ #expect(session.currentRecordingDeviceID == nil)
+ #expect(session.isTracking == false)
+ #expect(try await store.recordingDevices().isEmpty)
+ #expect(try await store.recordingPolicyChanges().isEmpty)
+ }
+
@Test func stoppingTrackingPersistsAcrossLaunches() async throws {
let preferences = makePreferences()
let (session, _) = try makeSession(status: .always, preferences: preferences)
@@ -87,6 +142,42 @@ struct WhereSessionTrackingTests {
#expect(!relaunched.isTracking)
}
+ @Test func offWinsWhileAnEarlierEnableWaitsForPermission() async throws {
+ let source = SuspendedPermissionLocationSource()
+ let services = try WhereServices(
+ store: SwiftDataStore.inMemory(),
+ locationSource: source,
+ )
+ let preferences = makePreferences()
+ preferences.wantsTracking = false
+ let session = WhereSession(services: services, preferences: preferences)
+ let currentDeviceID = try #require(session.currentRecordingDeviceID)
+
+ let enabling = Task {
+ try await session.setRecordingEnabled(
+ true,
+ for: currentDeviceID,
+ )
+ }
+ await waitUntil { source.isAwaitingPermission }
+
+ _ = try await session.setRecordingEnabled(
+ false,
+ for: currentDeviceID,
+ )
+ source.resolvePermission(as: .always)
+ _ = try await enabling.value
+
+ let current = try #require(
+ try await session.recordingDevices()
+ .first(where: { $0.id == currentDeviceID }),
+ )
+ #expect(current.isEnabled == false)
+ #expect(current.device.status == .off)
+ #expect(session.isTracking == false)
+ #expect(preferences.wantsTracking == false)
+ }
+
@Test func grantingLaterStartsTrackingViaLiveUpdates() async throws {
let (session, source) = try makeSession(
status: .notDetermined,
@@ -186,3 +277,47 @@ struct WhereSessionTrackingTests {
#expect(await predicate(), "condition was not met before timeout")
}
}
+
+/// Permission seam that parks until the test resolves it, matching the
+/// suspension point of Core Location's real system prompt.
+private final class SuspendedPermissionLocationSource: LocationSource, @unchecked Sendable {
+ let sampleStream = AsyncStream { _ in }
+
+ var authorizationUpdates: AsyncStream {
+ AsyncStream { _ in }
+ }
+
+ private let lock = NSLock()
+ private var status = LocationAuthorizationStatus.notDetermined
+ private var permissionContinuation: CheckedContinuation?
+
+ var isAwaitingPermission: Bool {
+ lock.withLock { permissionContinuation != nil }
+ }
+
+ func start() async {}
+ func stop() async {}
+
+ func requestCurrentLocation() async -> LocationSample? {
+ nil
+ }
+
+ func currentAuthorization() async -> LocationAuthorizationStatus {
+ lock.withLock { status }
+ }
+
+ func requestPermission() async throws {
+ await withCheckedContinuation { continuation in
+ lock.withLock { permissionContinuation = continuation }
+ }
+ }
+
+ func resolvePermission(as status: LocationAuthorizationStatus) {
+ let continuation = lock.withLock {
+ self.status = status
+ defer { permissionContinuation = nil }
+ return permissionContinuation
+ }
+ continuation?.resume()
+ }
+}
diff --git a/Where/WhereUI/Tests/WidgetSnapshotRankingTests.swift b/Where/WhereUI/Tests/WidgetSnapshotRankingTests.swift
index 130606591..93c86cb74 100644
--- a/Where/WhereUI/Tests/WidgetSnapshotRankingTests.swift
+++ b/Where/WhereUI/Tests/WidgetSnapshotRankingTests.swift
@@ -8,7 +8,15 @@ struct WidgetSnapshotRankingTests {
dayRegions: Set,
totals: [Region: Int],
) -> WidgetSnapshot {
- WidgetSnapshot(day: .now, year: 2026, dayRegions: dayRegions, totals: totals)
+ WidgetSnapshot(
+ day: .now,
+ year: 2026,
+ dayRegions: dayRegions,
+ totals: totals,
+ appearances: [:],
+ generatedAt: nil,
+ surface: nil,
+ )
}
@Test func rankedTotalsOrderAndCapMatchTheApp() {
diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md
index 24797523c..374ac63b8 100644
--- a/Where/WhereWidgets/AGENTS.md
+++ b/Where/WhereWidgets/AGENTS.md
@@ -1,8 +1,9 @@
# WhereWidgets – Module Shape
-The **Where** widget extension: WidgetKit configurations that read a published
-`WidgetSnapshot` from the App Group and render via shared views in **WhereUI**.
-See [`README.md`](README.md) for the data path and widget list.
+The **Where** iOS/iPadOS and Mac Catalyst widget extension: WidgetKit
+configurations that read a published `WidgetSnapshot` from the App Group and
+render via shared views in **WhereUI**. See [`README.md`](README.md) for the
+data path and widget list.
This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature
[`Where/AGENTS.md`](../AGENTS.md). Read those first.
@@ -21,13 +22,17 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature
## Refresh contract
1. App commits a store change → `WidgetSnapshotPublisher` rebuilds the
- snapshot → writes JSON + `WidgetCenter.reloadAllTimelines()`.
+ snapshot → coordinates an atomic JSON write → posts the advisory
+ WhereSurface Darwin notification → calls `WidgetCenter.reloadAllTimelines()`.
2. The provider reads the JSON on each timeline request and schedules
`.after(nextMidnight)` so WidgetKit re-queries even without an app reload.
## Invariants
- **Read-only App Group access** — only the app writes `widget-snapshot.json`.
+- **The bundle is platform-curated.** iPhone/iPad expose the existing Today and
+ Day Counts configurations; Mac Catalyst exposes one combined summary
+ configuration in small and medium families.
- **No stale-day invalidation in the provider.** A snapshot whose `day` rolled
past today is still shown until the app republishes — intentional.
- In-widget strings come from WhereUI (shared views + `WhereFormat`); the
diff --git a/Where/WhereWidgets/README.md b/Where/WhereWidgets/README.md
index 8b82b4f1d..694a5cae7 100644
--- a/Where/WhereWidgets/README.md
+++ b/Where/WhereWidgets/README.md
@@ -14,8 +14,9 @@ WidgetKit configuration, the timeline provider, and family-specific layout.
| Widget | Kind | Families |
|--------|------|----------|
-| **Today** | `com.stuff.where.widgets.today` | small, inline, circular |
-| **Day Counts** | `com.stuff.where.widgets.yearTotals` | small, medium, rectangular |
+| **Today** (iPhone/iPad) | `com.stuff.where.widgets.today` | small, inline, circular |
+| **Day Counts** (iPhone/iPad) | `com.stuff.where.widgets.yearTotals` | small, medium, rectangular |
+| **Where Summary** (Mac) | `com.stuff.where.widgets.macSummary` | small, medium |
## Data flow
@@ -41,11 +42,13 @@ app never wakes.
## Installation
-`WhereWidgets` is a Tuist app-extension target in
+`WhereWidgets` is a multi-destination Tuist app-extension target in
[`Project.swift`](../../Project.swift) (bundle ID `com.stuff.where.widgets`).
It depends on **WhereCore**, **WhereUI**, **RegionKit** (for the `Region` model
its snapshot fixtures use), and **PeriscopeCore**. The main **Where** app embeds the
extension and shares the App Group entitlement.
+The bundle declaration exposes only the combined Today + year-to-date summary
+when compiled for Mac Catalyst.
## Previews
diff --git a/Where/WhereWidgets/Resources/Localizable.xcstrings b/Where/WhereWidgets/Resources/Localizable.xcstrings
index 2acc1aea5..1fea5f34a 100644
--- a/Where/WhereWidgets/Resources/Localizable.xcstrings
+++ b/Where/WhereWidgets/Resources/Localizable.xcstrings
@@ -1,6 +1,30 @@
{
"sourceLanguage" : "en",
"strings" : {
+ "widget.gallery.macSummary.description" : {
+ "comment" : "Widget gallery description for the Mac summary widget.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Today’s observed regions and year-to-date day counts."
+ }
+ }
+ }
+ },
+ "widget.gallery.macSummary.name" : {
+ "comment" : "Widget gallery name for the Mac summary widget.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Where Summary"
+ }
+ }
+ }
+ },
"widget.gallery.today.description" : {
"comment" : "Widget gallery description for the Today widget.",
"extractionState" : "manual",
diff --git a/Where/WhereWidgets/Sources/MacSummaryWidget.swift b/Where/WhereWidgets/Sources/MacSummaryWidget.swift
new file mode 100644
index 000000000..88c96737b
--- /dev/null
+++ b/Where/WhereWidgets/Sources/MacSummaryWidget.swift
@@ -0,0 +1,55 @@
+#if targetEnvironment(macCatalyst)
+ import SwiftUI
+ import WhereUI
+ import WidgetKit
+
+ /// Mac-only combined glance: today's regions and the leading year-to-date
+ /// day counts in one small or medium widget.
+ struct MacSummaryWidget: Widget {
+ static let kind = "com.stuff.where.widgets.macSummary"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: Self.kind, provider: WhereWidgetProvider()) { entry in
+ MacSummaryWidgetContent(entry: entry)
+ .whereBroadwayRoot(
+ regionStyles: RegionStyleResolver(
+ appearances: entry.snapshot.appearances,
+ ),
+ )
+ }
+ .configurationDisplayName(String(localized: .widgetGalleryMacSummaryName))
+ .description(String(localized: .widgetGalleryMacSummaryDescription))
+ .supportedFamilies([.systemSmall, .systemMedium])
+ }
+ }
+
+ private struct MacSummaryWidgetContent: View {
+ @Environment(\.widgetFamily) private var family
+
+ let entry: WhereWidgetEntry
+
+ var body: some View {
+ MacSummaryWidgetView(
+ snapshot: entry.snapshot,
+ layout: family == .systemSmall ? .compact : .wide,
+ )
+ .containerBackground(.background, for: .widget)
+ }
+ }
+
+ #if DEBUG
+ #Preview("Small", as: .systemSmall) {
+ MacSummaryWidget()
+ } timeline: {
+ WhereWidgetEntry.sample
+ WhereWidgetEntry.previewEmpty
+ }
+
+ #Preview("Medium", as: .systemMedium) {
+ MacSummaryWidget()
+ } timeline: {
+ WhereWidgetEntry.sample
+ WhereWidgetEntry.previewEmpty
+ }
+ #endif
+#endif
diff --git a/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift b/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift
index 4a33f6e7f..c88217f51 100644
--- a/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift
+++ b/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift
@@ -7,15 +7,27 @@ import WidgetKit
@main
struct WhereWidgetsBundle: WidgetBundle {
var body: some Widget {
- TodayWidget()
- YearTotalsWidget()
+ #if targetEnvironment(macCatalyst)
+ MacSummaryWidget()
+ #else
+ TodayWidget()
+ YearTotalsWidget()
+ #endif
}
}
#if DEBUG
- #Preview("Where widgets", as: .systemSmall) {
- TodayWidget()
- } timeline: {
- WhereWidgetEntry.sample
- }
+ #if targetEnvironment(macCatalyst)
+ #Preview("Where widgets", as: .systemMedium) {
+ MacSummaryWidget()
+ } timeline: {
+ WhereWidgetEntry.sample
+ }
+ #else
+ #Preview("Where widgets", as: .systemSmall) {
+ TodayWidget()
+ } timeline: {
+ WhereWidgetEntry.sample
+ }
+ #endif
#endif
diff --git a/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift b/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift
index 59ea9f054..76a7fb206 100644
--- a/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift
+++ b/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift
@@ -18,6 +18,9 @@ enum WidgetSnapshotFixtures {
year: calendar.component(.year, from: day),
dayRegions: dayRegions,
totals: totals,
+ appearances: [:],
+ generatedAt: referenceDate,
+ surface: nil,
)
}
diff --git a/Where/WhereWidgets/WhereWidgets-MacCatalyst.entitlements b/Where/WhereWidgets/WhereWidgets-MacCatalyst.entitlements
new file mode 100644
index 000000000..3c728f043
--- /dev/null
+++ b/Where/WhereWidgets/WhereWidgets-MacCatalyst.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ group.com.stuff.where
+
+
+