diff --git a/.bumper/RULES.md b/.bumper/RULES.md
index d4e60c62..98c466bf 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 cd54cc50..655d3c5b 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 783c1058..8aa980da 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 ea9b6fa7..aa5f2f68 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 1d10d162..469a885b 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 52e813a4..edfae2ff 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 6a52f459..6cf751f8 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 31fecea3..0aa314f0 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,10 +36,10 @@ 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")]),
])
@@ -47,6 +49,7 @@ let whereAppGroupEntitlements: Entitlements = .dictionary([
/// 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"),
@@ -57,6 +60,13 @@ let whereAppEntitlements: Entitlements = .dictionary([
),
])
+/// 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.
///
@@ -174,7 +184,7 @@ let project = Project(
targets: [
.target(
name: "Where",
- destinations: destinations,
+ destinations: whereDestinations,
product: .app,
bundleId: "com.stuff.where",
deploymentTargets: deployment,
@@ -182,6 +192,12 @@ let project = Project(
"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.
@@ -196,6 +212,19 @@ let project = Project(
]),
sources: ["Where/Where/Sources/**"],
resources: ["Where/Where/Resources/**"],
+ 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
@@ -227,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,
@@ -250,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,
@@ -285,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",
@@ -471,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",
@@ -622,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,
@@ -654,6 +758,7 @@ let project = Project(
"SnapshotKitTests",
"SnapshotKitTestingTests",
"RegionKitTests",
+ "WhereSurfaceTests",
"WhereCoreTests",
"WhereTests",
"WhereUITests",
@@ -678,6 +783,7 @@ let project = Project(
"SnapshotKitTests",
"SnapshotKitTestingTests",
"RegionKitTests",
+ "WhereSurfaceTests",
"WhereCoreTests",
"WhereTests",
"WhereUITests",
@@ -702,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/Where/AGENTS.md b/Where/AGENTS.md
index 3c886149..f4ac77e7 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,13 +14,14 @@ 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
@@ -68,6 +70,9 @@ Rules the code enforces and agents must preserve:
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.
@@ -175,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` —
@@ -191,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/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb
index 985c9916..e44e1556 100755
--- a/Where/Tools/upgrade-backup.rb
+++ b/Where/Tools/upgrade-backup.rb
@@ -24,10 +24,11 @@
# - Top level: ensures `dismissedIssues` / `trackedRegions` exist, synthesizes
# `primaryRegions` from the tracked ids (null appearance, listed order) when
# absent, adds empty device/policy tables, stamps legacy samples with null
-# device provenance, and sets `formatVersion` to 3 (the current version).
+# 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]
@@ -61,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
@@ -163,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)
@@ -184,6 +223,7 @@ def upgrade_manifest(manifest)
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 f0c6b435..3f43c5f8 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
@@ -48,9 +51,14 @@ layering, and the domain rules this target merely starts up.
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`), and remote-notification background mode
- together in `Project.swift`; widgets and the share extension stay App
- Group-only and never open a CloudKit container.
+ 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 f97f9385..e231f238 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,28 +30,32 @@ 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 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.
+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:
diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift
index 454d13ed..b140018d 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 00000000..9aaeabd5
--- /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 827c1702..12f9405b 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
@@ -87,9 +99,13 @@ internal shape.
`LocationIngestor.captureTodayIfNeeded(now:)`.
- **`DeviceRecordingController` owns automatic-recording policy and physical
GPS state.** Keep policy events append-only, serialize mutations across
- awaits, 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.
+ 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 41111e06..74084c2d 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
@@ -90,7 +94,11 @@ one it belongs to rather than to a god-object:
- **`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.
+ 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,
@@ -111,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
@@ -121,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
diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift
index 0b0eb970..1b724096 100644
--- a/Where/WhereCore/Sources/Backup/BackupArchive.swift
+++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift
@@ -18,9 +18,10 @@ public struct BackupArchive: Codable, Sendable, Hashable {
/// `BackupService.readArchive`, which rejects any other version).
///
/// v3 adds sample device provenance plus the synced recording-device and
- /// append-only policy tables. 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`).
+ /// 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 = 3
public let formatVersion: Int
diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift
index 91dc8555..c9f711b5 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
}
diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift
index 3bf4c410..6e16579f 100644
--- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift
+++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift
@@ -1,6 +1,6 @@
import Foundation
-/// Serializes the synced recording policy with this device's physical GPS
+/// Serializes synced recording policy with this process's optional physical GPS
/// lifecycle.
///
/// Policy writes take effect historically at their timestamp immediately on
@@ -9,7 +9,11 @@ import Foundation
public actor DeviceRecordingController {
private let store: any WhereStore
private let ingestor: LocationIngestor
- public nonisolated let currentDevice: CurrentRecordingDevice
+ 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
@@ -22,34 +26,39 @@ public actor DeviceRecordingController {
init(
store: any WhereStore,
ingestor: LocationIngestor,
- currentDevice: CurrentRecordingDevice,
+ participation: RecordingParticipation,
now: @escaping @Sendable () -> Date,
) {
self.store = store
self.ingestor = ingestor
- self.currentDevice = currentDevice
+ self.participation = participation
self.now = now
}
- /// Register this installation if needed, migrate its initial desired state
- /// from local preferences, then make physical monitoring match the latest
- /// synced policy and authorization.
+ /// 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 {
+ ) 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, current device first and then by most
- /// recent check-in.
+ /// 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() }
@@ -72,7 +81,6 @@ public actor DeviceRecordingController {
try requireActive()
try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled)
- let device = try await store.recordingDevices().first(where: { $0.id == deviceID })
let changes = try await store.recordingPolicyChanges()
let change = RecordingPolicyChange(
id: UUID(),
@@ -85,12 +93,12 @@ public actor DeviceRecordingController {
)
try await store.perform {
try await store.addRecordingPolicyChange(change)
- if enabled, let device, device.archivedAt != nil {
- try await store.setRecordingDevice(device.unarchived())
+ if enabled {
+ try await store.updateRecordingDevice(deviceID) { $0.unarchived() }
}
}
- if deviceID == currentDevice.id {
+ if deviceID == currentDevice?.id {
let authorization = await ingestor.authorizationStatus()
_ = try await reconcileLocked(
initialEnabled: initialEnabled,
@@ -111,16 +119,12 @@ public actor DeviceRecordingController {
defer { endExclusive() }
try requireActive()
try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled)
- guard let device = try await store.recordingDevices().first(where: { $0.id == deviceID })
- else { return try await configurationsLocked(includeArchived: false) }
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
- let renamed = device.renamed(trimmed.isEmpty ? nil : trimmed)
- guard renamed != device else {
- return try await configurationsLocked(includeArchived: false)
- }
try await store.perform {
- try await store.setRecordingDevice(renamed)
+ try await store.updateRecordingDevice(deviceID) {
+ $0.renamed(trimmed.isEmpty ? nil : trimmed)
+ }
}
return try await configurationsLocked(includeArchived: false)
}
@@ -131,12 +135,12 @@ public actor DeviceRecordingController {
_ deviceID: RecordingDeviceID,
initialEnabled: Bool,
) async throws -> [RecordingDeviceConfiguration] {
- precondition(deviceID != currentDevice.id, "The current device cannot archive itself.")
+ precondition(deviceID != currentDevice?.id, "The current device cannot archive itself.")
await beginExclusive()
defer { endExclusive() }
try requireActive()
try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled)
- guard let device = try await store.recordingDevices().first(where: { $0.id == deviceID })
+ guard try await store.recordingDevices().contains(where: { $0.id == deviceID })
else { return try await configurationsLocked(includeArchived: false) }
let date = now()
@@ -152,7 +156,7 @@ public actor DeviceRecordingController {
)
try await store.perform {
try await store.addRecordingPolicyChange(change)
- try await store.setRecordingDevice(device.archived(at: date))
+ try await store.updateRecordingDevice(deviceID) { $0.archived(at: date) }
}
return try await configurationsLocked(includeArchived: false)
}
@@ -180,6 +184,9 @@ public actor DeviceRecordingController {
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 {
@@ -206,19 +213,21 @@ public actor DeviceRecordingController {
let needsAcknowledgement = device.lastAppliedPolicyChangeID != latest.id
|| device.status != status
let needsPeriodicCheckIn = checkIn.timeIntervalSince(device.lastSeenAt) >= 15 * 60
- let acknowledged = if needsAcknowledgement || needsPeriodicCheckIn {
- device.acknowledging(
- policyChangeID: latest.id,
- status: status,
- at: checkIn,
- )
- } else {
- device
- }
- if acknowledged != device {
- try await store.perform {
- try await store.setRecordingDevice(acknowledged)
+ 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,
@@ -228,6 +237,7 @@ public actor DeviceRecordingController {
}
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 })
@@ -270,7 +280,7 @@ public actor DeviceRecordingController {
let (resolvedDevices, resolvedPolicies) = try await (devices, policies)
return resolvedDevices
.filter {
- includeArchived || $0.archivedAt == nil || $0.id == currentDevice.id
+ includeArchived || $0.archivedAt == nil || $0.id == currentDevice?.id
}
.map { device in
let latest = Self.latestPolicy(for: device.id, in: resolvedPolicies)
@@ -281,8 +291,8 @@ public actor DeviceRecordingController {
)
}
.sorted { lhs, rhs in
- if lhs.id == currentDevice.id { return true }
- if rhs.id == currentDevice.id { return false }
+ 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
}
diff --git a/Where/WhereCore/Sources/Devices/RecordingParticipation.swift b/Where/WhereCore/Sources/Devices/RecordingParticipation.swift
new file mode 100644
index 00000000..1f1d43cb
--- /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/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift
index b765f47d..5c2e1dac 100644
--- a/Where/WhereCore/Sources/Location/LocationIngestor.swift
+++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift
@@ -29,7 +29,10 @@ public actor LocationIngestor {
private let store: any WhereStore
private let locationSource: any LocationSource
- private let recordingDeviceID: RecordingDeviceID
+ /// 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
@@ -90,7 +93,7 @@ public actor LocationIngestor {
init(
store: any WhereStore,
locationSource: any LocationSource,
- recordingDeviceID: RecordingDeviceID,
+ recordingDeviceID: RecordingDeviceID?,
calendar: Calendar,
outbox: any LocationOutbox = NoOpLocationOutbox(),
retryQueueCapacity: Int = 1000,
@@ -122,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
@@ -245,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)
@@ -331,6 +336,7 @@ 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 {
diff --git a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift
index f3c2b3ff..5345631f 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 6a4c4f92..f8d34af1 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 00000000..4f1d63cd
--- /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 1a4f149d..0a13450a 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 8842956f..54b4536d 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 {
@@ -142,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,
@@ -204,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
}
@@ -230,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
}
@@ -261,10 +273,24 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
}
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
@@ -274,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
@@ -293,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
@@ -368,6 +461,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
// actor reentrancy.
await beginExclusive()
let peer = ModelContext(modelContainer)
+ peer.author = localTransactionAuthor
writerContext = peer
defer {
writerContext = nil
@@ -514,6 +608,35 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
}
}
+ 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(
diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift
index 388039d9..0e2ca975 100644
--- a/Where/WhereCore/Sources/Persistence/WhereStore.swift
+++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift
@@ -9,6 +9,7 @@ import RegionKit
/// implementation has somewhere to surface I/O errors.
///
/// 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
@@ -40,6 +41,15 @@ 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]
@@ -52,6 +62,17 @@ public protocol WhereStore: Sendable {
/// 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]
@@ -142,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 ddaf769b..03e6a495 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/RegionAttribution.swift b/Where/WhereCore/Sources/RegionAttribution.swift
index 5f9feea7..2bd658e3 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/WhereServices+Intents.swift b/Where/WhereCore/Sources/WhereServices+Intents.swift
index 1ae86a55..236bcdc0 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,18 +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(),
- currentDevice: base.currentDevice,
+ 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,
)
}
@@ -50,7 +54,7 @@ extension WhereServices {
try await make(
store: store,
locationSource: IdleLocationSource(),
- currentDevice: .preview,
+ recordingParticipation: .managementOnly,
reminderScheduler: NoopLoggingReminderScheduler(),
summaryScheduler: NoopDailySummaryScheduler(),
issueAlertScheduler: NoopDataIssueAlertScheduler(),
diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift
index f125e84b..6fa4f606 100644
--- a/Where/WhereCore/Sources/WhereServices.swift
+++ b/Where/WhereCore/Sources/WhereServices.swift
@@ -69,9 +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
- /// Installation identity used to stamp automatic samples. Retained so a
- /// derived App Intents stack preserves the same composition value.
- let currentDevice: CurrentRecordingDevice
+ /// 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
@@ -94,13 +95,17 @@ public struct WhereServices: Sendable {
public init(
store: any WhereStore,
locationSource: any LocationSource,
- currentDevice: CurrentRecordingDevice = .preview,
+ 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() },
@@ -139,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,
@@ -162,7 +167,7 @@ public struct WhereServices: Sendable {
let ingestor = LocationIngestor(
store: store,
locationSource: locationSource,
- recordingDeviceID: currentDevice.id,
+ recordingDeviceID: recordingParticipation.currentDevice?.id,
calendar: aggregator.calendar,
outbox: locationOutbox,
onPersisted: { outcome in
@@ -190,7 +195,7 @@ public struct WhereServices: Sendable {
let recording = DeviceRecordingController(
store: store,
ingestor: ingestor,
- currentDevice: currentDevice,
+ participation: recordingParticipation,
now: now,
)
let journal = DayJournal(
@@ -237,7 +242,7 @@ public struct WhereServices: Sendable {
self.issueAlertScheduler = issueAlertScheduler
self.widgetRefresher = widgetRefresher
self.now = now
- self.currentDevice = currentDevice
+ self.recordingParticipation = recordingParticipation
modelContainer = (store as? SwiftDataStore)?.inspectorContainer
}
@@ -254,7 +259,7 @@ public struct WhereServices: Sendable {
public static func make(
store: any WhereStore,
locationSource: any LocationSource,
- currentDevice: CurrentRecordingDevice,
+ recordingParticipation: RecordingParticipation,
aggregator: DayAggregator = DayAggregator(),
reminderScheduler: any LoggingReminderScheduling,
summaryScheduler: any DailySummaryScheduling,
@@ -272,10 +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,
- currentDevice: currentDevice,
+ recordingParticipation: recordingParticipation,
attributor: attribution,
aggregator: aggregator,
reminderScheduler: reminderScheduler,
@@ -286,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
diff --git a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift
index e333b02c..9af71d3f 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)
}
}
@@ -103,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 2866ec21..e9033484 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 5c27d4bc..499d58db 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 6e854221..721b5cdc 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/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift
index 8cd64bf4..3e5ebeac 100644
--- a/Where/WhereCore/Tests/BackupServiceTests.swift
+++ b/Where/WhereCore/Tests/BackupServiceTests.swift
@@ -6,9 +6,7 @@ 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")!
@@ -151,6 +149,63 @@ struct BackupServiceTests {
#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(
@@ -306,11 +361,11 @@ 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)
diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift
index aad946b6..d50f04ab 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
index 9eb7b98f..37fa2d20 100644
--- a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift
+++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift
@@ -12,7 +12,10 @@ struct DeviceRecordingControllerTests {
let services = WhereServices(
store: store,
locationSource: ScriptedLocationSource(authorizationStatus: authorization),
- currentDevice: .preview,
+ recordingParticipation: .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: true,
+ ),
now: { now },
)
return (services, store)
@@ -21,10 +24,10 @@ struct DeviceRecordingControllerTests {
@Test func firstReconcileRegistersMigratedIntentAndAcknowledgesRecording() async throws {
let (services, store) = try Self.makeServices(authorization: .always)
- let configuration = try await services.recording.reconcile(
+ let configuration = try #require(try await services.recording.reconcile(
initialEnabled: true,
authorization: .always,
- )
+ ))
#expect(configuration.id == CurrentRecordingDevice.preview.id)
#expect(configuration.isEnabled)
@@ -38,10 +41,10 @@ struct DeviceRecordingControllerTests {
@Test func enabledWithoutAlwaysPermissionIsAcknowledgedAsPermissionRequired() async throws {
let (services, _) = try Self.makeServices(authorization: .whenInUse)
- let configuration = try await services.recording.reconcile(
+ let configuration = try #require(try await services.recording.reconcile(
initialEnabled: true,
authorization: .whenInUse,
- )
+ ))
#expect(configuration.isEnabled)
#expect(configuration.isPending == false)
@@ -205,4 +208,66 @@ struct DeviceRecordingControllerTests {
#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 c475fae9..935abbda 100644
--- a/Where/WhereCore/Tests/LocationIngestorTests.swift
+++ b/Where/WhereCore/Tests/LocationIngestorTests.swift
@@ -544,6 +544,13 @@ private actor ToggleFailingStore: WhereStore {
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()
}
diff --git a/Where/WhereCore/Tests/RecordingParticipationTests.swift b/Where/WhereCore/Tests/RecordingParticipationTests.swift
new file mode 100644
index 00000000..01b510ff
--- /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/StoreHistoryClassifierTests.swift b/Where/WhereCore/Tests/StoreHistoryClassifierTests.swift
new file mode 100644
index 00000000..8a358331
--- /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 5fd87a8f..8d53d18a 100644
--- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift
+++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift
@@ -132,21 +132,82 @@ struct SwiftDataStoreTests {
#expect(try await store.recordingPolicyChanges() == [policy])
}
- /// 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 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 00000000..98508203
--- /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 6263ccea..21758a8d 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,7 +57,10 @@ struct WhereServicesTests {
let services = try await WhereServices.make(
store: store,
locationSource: ScriptedLocationSource(),
- currentDevice: .preview,
+ recordingParticipation: .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: true,
+ ),
aggregator: Self.makeAggregator(),
reminderScheduler: NoopLoggingReminderScheduler(),
summaryScheduler: NoopDailySummaryScheduler(),
@@ -1131,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)
@@ -1337,7 +1411,7 @@ private actor SpyWidgetRefresher: WidgetTimelineRefreshing {
publishedSnapshots.last
}
- func publish(_ snapshot: WidgetSnapshot) async {
+ func publish(_ snapshot: WidgetSnapshot) async throws {
publishedSnapshots.append(snapshot)
}
}
@@ -1397,6 +1471,13 @@ private actor ToggleFailingStore: WhereStore {
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()
}
diff --git a/Where/WhereCore/Tests/WidgetDataReaderTests.swift b/Where/WhereCore/Tests/WidgetDataReaderTests.swift
index b27ff46b..653f2c41 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 e85019e9..20d3322c 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 65a19bcc..931effc7 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 e0c4e45a..928ff8b5 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 00000000..7dda85c4
--- /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 00000000..698df304
--- /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 00000000..59fd6bd0
--- /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 00000000..d6eaadac
--- /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 00000000..01a03540
--- /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 00000000..6653bccb
--- /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 00000000..ac8e243b
--- /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 00000000..e25884ee
--- /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 00000000..51a6f573
--- /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 00000000..b1360c24
--- /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 00000000..119f8ceb
--- /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 c4d68bf4..91bfae54 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 ebeaf617..46d67515 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 00000000..3c728f04
--- /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 00000000..485c08fb
--- /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 00000000..095f7a5b
--- /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 00000000..c100a1e0
--- /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 00000000..0f42079d
--- /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 00000000..0c43d653
--- /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 00000000..d3d369ab
--- /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 00000000..9d6c833c
--- /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 00000000..ab3fc775
--- /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 00000000..ebec553e
--- /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 00000000..57558569
--- /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 00000000..8da3fa79
--- /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 00000000..83ea6dc9
--- /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 00000000..3a71026d
--- /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 58fc994e..2a8d7874 100644
--- a/Where/WhereUI/AGENTS.md
+++ b/Where/WhereUI/AGENTS.md
@@ -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 863f9ee7..634ab389 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
@@ -63,7 +69,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's
- **`WhereSession`** — the always-on coordinator: tracking + location
authorization state and the intents that drive them (`requestPermission()`,
per-device recording changes, `startTracking()` / `stopTracking()`,
- `refreshWidgetSnapshot()`). It holds no presentation state of its own.
+ `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`**
@@ -82,7 +91,9 @@ 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,
@@ -124,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/MacSummaryWidgetViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/MacSummaryWidgetViewSnapshotTests.swift
new file mode 100644
index 00000000..0fa0e7cf
--- /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 00000000..be44cf33
--- /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__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall.png
new file mode 100644
index 00000000..03e3ff76
--- /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 00000000..96f69d4e
--- /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 00000000..cc709ae9
--- /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 00000000..c979516a
--- /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 00000000..8225d7b0
--- /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 00000000..e9b99d70
--- /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 995db43f..fad3a370 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 50633f90..72c702e8 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 7c18bd96..20570ec6 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 1a91f836..53323de3 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 d028495d..5e95d74e 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 b863acc6..ee0a6a0f 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 8de3fd03..c71e3e18 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 8408dae8..4a1442eb 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 0c5c2936..a75ae060 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 2642c1f7..f813b2e2 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/Sources/Developer/Flyover/WhereFlyoverCatalog.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift
index e07f06e9..aeda0357 100644
--- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift
+++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift
@@ -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
index 60068d3b..b4068cbd 100644
--- a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift
+++ b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift
@@ -2,19 +2,38 @@ import Foundation
import UIKit
import WhereCore
-/// Builds the local installation identity at the app composition boundary.
+/// Resolves local recording participation at the app composition boundary.
///
-/// 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.
+/// 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 current(defaults: UserDefaults) -> CurrentRecordingDevice {
+ 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)
@@ -26,16 +45,40 @@ enum CurrentRecordingDeviceProvider {
defaults.set(id.uuidString, forKey: Key.recordingDeviceID.rawValue)
}
- let kind: RecordingDeviceKind = switch device.userInterfaceIdiom {
+ let kind: RecordingDeviceKind = switch idiom {
case .phone: .phone
case .pad: .tablet
case .unspecified, .tv, .carPlay, .mac, .vision: .other
@unknown default: .other
}
- return CurrentRecordingDevice(
- id: RecordingDeviceID(rawValue: id),
- systemName: device.model,
- kind: kind,
+ 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 d8c83ee3..95686097 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,9 +271,16 @@ 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
- let currentDevice = CurrentRecordingDeviceProvider.current(defaults: .standard)
do {
let store = try await Task.detached(priority: .userInitiated) {
try SwiftDataStore.make()
@@ -274,7 +288,7 @@ public final class WhereBootstrap: WhereScopeAssembling {
let services = try await WhereServices.make(
store: store,
locationSource: source,
- currentDevice: currentDevice,
+ 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 373da80a..d0ba15c7 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/MainInterfaceStyle.swift b/Where/WhereUI/Sources/MainInterfaceStyle.swift
new file mode 100644
index 00000000..81d35fb8
--- /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 00000000..72f079a0
--- /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 00000000..79852f8c
--- /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 484cccc5..1fcf74b6 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 00000000..d2b3d11d
--- /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 a4d4281f..728c86f8 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 9ad09a80..c1649846 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,10 +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,
- currentDevice: .preview,
+ 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
@@ -224,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 495d6c06..56414d24 100644
--- a/Where/WhereUI/Sources/Model/WhereSession.swift
+++ b/Where/WhereUI/Sources/Model/WhereSession.swift
@@ -47,9 +47,17 @@ public final class WhereSession {
public private(set) var isTracking = false
/// Stable installation identity used by the Devices settings screen to mark
- /// the current row and prevent archiving it.
- public var currentRecordingDeviceID: RecordingDeviceID {
- services.recording.currentDevice.id
+ /// 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
@@ -112,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 }
}
@@ -316,10 +330,13 @@ public final class WhereSession {
func reconcileTracking() async {
let wasTracking = isTracking
do {
- let configuration = try await services.recording.reconcile(
+ 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
@@ -345,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())
}
@@ -353,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
@@ -374,6 +393,7 @@ 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 {
+ guard let currentRecordingDeviceID else { return }
do {
_ = try await setRecordingEnabled(true, for: currentRecordingDeviceID)
} catch {
@@ -384,6 +404,7 @@ public final class WhereSession {
}
public func stopTracking() async {
+ guard let currentRecordingDeviceID else { return }
do {
_ = try await setRecordingEnabled(false, for: currentRecordingDeviceID)
} catch {
diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift
index 12c5b8b5..6c86d3f0 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,13 +322,24 @@ public struct OnboardingView: View {
gate.fail(error)
return
}
- // This is also the initial synced policy for a newly registered
- // installation. “Not Now” must therefore record false explicitly;
- // leaving the old default true would make the next launch start
- // recording despite the user's choice.
- scope.preferences.wantsTracking = enableLocation
+ 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
@@ -329,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 {
@@ -380,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) {
@@ -393,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) {
@@ -405,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")]) {
@@ -419,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
@@ -434,6 +494,7 @@ final class OnboardingIntroState {
enum Activity {
case browsing
case restoringBackup
+ case joiningExistingData
case buildingDemo
case failed(Failure)
}
@@ -442,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
}
@@ -465,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
@@ -486,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 00000000..a275f40d
--- /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 adfd283a..a148659d 100644
--- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift
+++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift
@@ -636,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 b14cf0fc..0db4140e 100644
--- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings
+++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings
@@ -1575,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" : {
@@ -1664,7 +1700,7 @@
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Restore from a backup"
+ "value" : "Restore Backup"
}
}
}
@@ -3745,6 +3781,28 @@
}
}
},
+ "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" : {
@@ -4254,6 +4312,90 @@
}
}
},
+ "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" : "Allow Where in Login Items in System Settings to finish enabling it."
+ }
+ }
+ }
+ },
+ "settings.menuBar.enabled" : {
+ "comment" : "Toggle label for the native Mac menu bar companion.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Show Where in the menu bar"
+ }
+ }
+ }
+ },
+ "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" : "Couldn’t Update Menu Bar Item"
+ }
+ }
+ }
+ },
+ "settings.menuBar.footer" : {
+ "comment" : "Description of the glance data shown by the Mac menu bar companion.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Shows today’s observed regions and year-to-date day counts without opening Where."
+ }
+ }
+ }
+ },
+ "settings.menuBar.header" : {
+ "comment" : "Header for Mac-specific menu bar settings.",
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "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."
+ }
+ }
+ }
+ },
"settings.permissionAlert.message" : {
"extractionState" : "manual",
"localizations" : {
diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift
index dd1a799f..81ed32e3 100644
--- a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift
+++ b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift
@@ -10,6 +10,7 @@ struct DeviceSettingsSection: View {
@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 {
@@ -32,8 +33,16 @@ struct DeviceSettingsSection: View {
TextField(String(localized: .settingsDevicesName), text: $row.nickname)
.settingsRow(DevicesSettingsView.Item.deviceName)
.disabled(row.isBusy)
+ .focused($isEditingNickname)
.onSubmit {
- Task { await model.rename(row) }
+ isEditingNickname = false
+ }
+ .onChange(of: isEditingNickname) { wasEditing, isEditing in
+ guard wasEditing, !isEditing else { return }
+ commitNickname()
+ }
+ .onDisappear {
+ commitNickname()
}
LabeledContent(String(localized: .settingsDevicesStatus)) {
@@ -135,6 +144,10 @@ struct DeviceSettingsSection: View {
}
}
+ 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 {
diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift
index 71ec0fcb..92a8c408 100644
--- a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift
+++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift
@@ -72,11 +72,15 @@ final class DevicesSettingsModel {
}
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 nickname = row.nickname.trimmingCharacters(in: .whitespacesAndNewlines)
let configurations = try await session.renameRecordingDevice(
row.id,
to: nickname,
@@ -130,7 +134,7 @@ final class DevicesSettingsModel {
}
return DeviceSettingsRowModel(
configuration: configuration,
- isCurrent: configuration.id == session.currentRecordingDeviceID,
+ isCurrent: session.currentRecordingDeviceID == configuration.id,
)
}
}
diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift
index ef6ba39d..dde34a33 100644
--- a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift
+++ b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift
@@ -10,6 +10,9 @@ struct DevicesSettingsView: View {
@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) {
@@ -38,8 +41,14 @@ struct DevicesSettingsView: View {
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 {
@@ -61,8 +70,20 @@ struct DevicesSettingsView: View {
}
}
case .loaded:
- ForEach(model.rows) { row in
- DeviceSettingsSection(model: model, row: row)
+ 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)
+ }
}
}
}
diff --git a/Where/WhereUI/Sources/Settings/MenuBarSettingsModel.swift b/Where/WhereUI/Sources/Settings/MenuBarSettingsModel.swift
new file mode 100644
index 00000000..34123420
--- /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 00000000..9b83fd3f
--- /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/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift
index ae77b3c5..f543c185 100644
--- a/Where/WhereUI/Sources/Settings/SettingsView.swift
+++ b/Where/WhereUI/Sources/Settings/SettingsView.swift
@@ -184,10 +184,12 @@ struct SettingsView: View {
private func subtitle(for destination: SettingsDestination) -> String? {
switch destination {
case .devices:
- LocationStatusRow.statusTitle(
- status: session.authorizationStatus,
- isTracking: session.isTracking,
- )
+ 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:
diff --git a/Where/WhereUI/Sources/Widgets/MacSummaryWidgetView.swift b/Where/WhereUI/Sources/Widgets/MacSummaryWidgetView.swift
new file mode 100644
index 00000000..f06be745
--- /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
index f3b55a29..471f9ecf 100644
--- a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift
+++ b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift
@@ -4,18 +4,72 @@ import Testing
@MainActor
struct CurrentRecordingDeviceProviderTests {
- @Test func persistsOneInstallationIdentity() throws {
+ @Test func phonePersistsOneInstallationIdentityAndDefaultsOn() throws {
let suiteName = "CurrentRecordingDeviceProviderTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
- let first = CurrentRecordingDeviceProvider.current(defaults: defaults)
- let second = CurrentRecordingDeviceProvider.current(defaults: defaults)
+ 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) == first.id.rawValue.uuidString
+ ($0 as? String) == firstDevice.id.rawValue.uuidString
})
- #expect(first.systemName.isEmpty == false)
+ #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/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift
index 38975aea..84f3f200 100644
--- a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift
+++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift
@@ -16,7 +16,10 @@ struct DevicesSettingsModelTests {
let services = WhereServices(
store: store,
locationSource: ScriptedLocationSource(authorizationStatus: .always),
- currentDevice: .preview,
+ recordingParticipation: .recording(
+ device: .preview,
+ defaultEnabledForNewInstallation: true,
+ ),
now: { Self.now },
)
let preferences = makePreferences()
@@ -77,4 +80,21 @@ struct DevicesSettingsModelTests {
#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/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift
index 89bb0fdd..7da07655 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/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift
index 71e5f151..ee7908a7 100644
--- a/Where/WhereUI/Tests/Support/TestStore.swift
+++ b/Where/WhereUI/Tests/Support/TestStore.swift
@@ -97,6 +97,13 @@ actor TestStore: WhereStore {
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()
}
diff --git a/Where/WhereUI/Tests/WhereSessionTests.swift b/Where/WhereUI/Tests/WhereSessionTests.swift
index fad35eb5..82c6b43d 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 d112b8b5..c79fd44b 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)
@@ -96,25 +151,26 @@ struct WhereSessionTrackingTests {
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: session.currentRecordingDeviceID,
+ for: currentDeviceID,
)
}
await waitUntil { source.isAwaitingPermission }
_ = try await session.setRecordingEnabled(
false,
- for: session.currentRecordingDeviceID,
+ for: currentDeviceID,
)
source.resolvePermission(as: .always)
_ = try await enabling.value
let current = try #require(
try await session.recordingDevices()
- .first(where: { $0.id == session.currentRecordingDeviceID }),
+ .first(where: { $0.id == currentDeviceID }),
)
#expect(current.isEnabled == false)
#expect(current.device.status == .off)
diff --git a/Where/WhereUI/Tests/WidgetSnapshotRankingTests.swift b/Where/WhereUI/Tests/WidgetSnapshotRankingTests.swift
index 13060659..93c86cb7 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 24797523..374ac63b 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 8b82b4f1..694a5cae 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 2acc1aea..1fea5f34 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 00000000..88c96737
--- /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 4a33f6e7..c88217f5 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 59ea9f05..76a7fb20 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 00000000..3c728f04
--- /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
+
+
+