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