From d2ce539af3d11b2f183a16d4384233a098b69f90 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 31 Jul 2026 07:40:53 -0700 Subject: [PATCH 1/3] Add iPad and Mac app surfaces --- .bumper/RULES.md | 4 +- .bumper/Sources/WhereArchitecture.swift | 7 + .bumper/Tests/WhereArchitectureTests.swift | 66 ++++-- .github/workflows/ci.yml | 31 +++ AGENTS.md | 7 + BumperBowling.swift | 20 +- Package.swift | 7 + Project.swift | 117 ++++++++++- Where/AGENTS.md | 43 ++-- Where/Where/AGENTS.md | 22 +- Where/Where/README.md | 37 ++-- Where/Where/Sources/AppDelegate.swift | 29 +-- Where/Where/Where-MacCatalyst.entitlements | 26 +++ Where/WhereCore/AGENTS.md | 18 +- Where/WhereCore/README.md | 25 ++- .../Devices/DeviceRecordingController.swift | 43 ++-- .../Devices/RecordingParticipation.swift | 30 +++ .../Sources/Location/LocationIngestor.swift | 10 +- .../Sources/Logging/SwiftDataStoreLog.swift | 8 +- .../Persistence/StoreChangeBroadcaster.swift | 11 +- .../Persistence/StoreHistoryClassifier.swift | 48 +++++ .../Persistence/StoreRemoteChangeSource.swift | 100 ++++----- .../Sources/Persistence/SwiftDataStore.swift | 152 +++++++++++--- .../Sources/Persistence/WhereStore.swift | 14 ++ .../Preferences/WherePreferences.swift | 11 + .../Sources/WhereServices+Intents.swift | 16 +- Where/WhereCore/Sources/WhereServices.swift | 51 +++-- .../Sources/Widgets/WidgetDataReader.swift | 62 +++++- .../Widgets/WidgetSnapshotPublisher.swift | 95 ++++++++- .../Sources/Widgets/WidgetSnapshotStore.swift | 11 +- .../Widgets/WidgetTimelineRefresher.swift | 9 +- Where/WhereCore/Tests/DayJournalTests.swift | 2 +- .../DeviceRecordingControllerTests.swift | 75 ++++++- .../Tests/RecordingParticipationTests.swift | 23 +++ .../Tests/StoreHistoryClassifierTests.swift | 36 ++++ .../WhereCore/Tests/SwiftDataStoreTests.swift | 13 +- .../Tests/WherePreferencesTests.swift | 27 +++ .../WhereCore/Tests/WhereServicesTests.swift | 80 +++++++- .../Tests/WidgetDataReaderTests.swift | 45 +++- .../Tests/WidgetSnapshotPublisherTests.swift | 192 +++++++++++++++++- .../Tests/WidgetSnapshotStoreTests.swift | 3 + .../Tests/WhereIntentReaderTests.swift | 6 + Where/WhereMenuBar/AGENTS.md | 29 +++ Where/WhereMenuBar/README.md | 25 +++ .../Resources/Localizable.xcstrings | 186 +++++++++++++++++ .../Sources/WhereMenuBarApp.swift | 13 ++ .../Sources/WhereMenuBarAppDelegate.swift | 133 ++++++++++++ .../Sources/WhereMenuBarDayCountRow.swift | 17 ++ .../Sources/WhereMenuBarModel.swift | 81 ++++++++ .../Sources/WhereMenuBarRegionRow.swift | 19 ++ .../Sources/WhereMenuBarSnapshotView.swift | 75 +++++++ .../Sources/WhereMenuBarUnavailableView.swift | 46 +++++ .../Sources/WhereMenuBarView.swift | 23 +++ Where/WhereShareExtension/AGENTS.md | 7 +- Where/WhereShareExtension/README.md | 7 +- ...ereShareExtension-MacCatalyst.entitlements | 12 ++ Where/WhereSurface/AGENTS.md | 28 +++ Where/WhereSurface/README.md | 32 +++ .../WhereSurfaceChangeNotification.swift | 20 ++ .../Sources/WhereSurfaceDocument.swift | 17 ++ .../Sources/WhereSurfaceReading.swift | 6 + .../Sources/WhereSurfaceSnapshot.swift | 63 ++++++ .../Sources/WhereSurfaceStore.swift | 46 +++++ .../WhereSurfaceChangeNotificationTests.swift | 8 + .../Tests/WhereSurfaceDocumentTests.swift | 53 +++++ .../Tests/WhereSurfaceSnapshotTests.swift | 26 +++ .../Tests/WhereSurfaceStoreTests.swift | 57 ++++++ Where/WhereUI/AGENTS.md | 17 ++ Where/WhereUI/README.md | 29 ++- .../MacSummaryWidgetViewSnapshotTests.swift | 10 + .../SnapshotTests/MainViewSnapshotTests.swift | 10 + .../macSummaryWidget.Compact_MacSmall.png | 3 + ...macSummaryWidget.Compact_MacSmall_dark.png | 3 + .../macSummaryWidget.Wide_MacMedium.png | 3 + .../macSummaryWidget.Wide_MacMedium_dark.png | 3 + .../mainView.Split_iPad.png | 3 + .../mainView.Split_iPad_dark.png | 3 + .../onboarding.Default_iPad.png | 4 +- .../onboarding.Default_iPad_accessibility.png | 4 +- .../onboarding.Default_iPad_ax5.png | 4 +- .../onboarding.Default_iPad_contrast.png | 4 +- .../onboarding.Default_iPad_dark.png | 4 +- .../onboarding.Default_iPhone.png | 4 +- ...nboarding.Default_iPhone_accessibility.png | 4 +- .../onboarding.Default_iPhone_ax5.png | 4 +- .../onboarding.Default_iPhone_contrast.png | 4 +- .../onboarding.Default_iPhone_dark.png | 4 +- .../Flyover/WhereFlyoverCatalog.swift | 1 + .../CurrentRecordingDeviceProvider.swift | 65 +++++- .../WhereUI/Sources/Launch/WhereLaunch.swift | 22 +- .../Sources/Logging/OnboardingViewLog.swift | 9 +- .../WhereUI/Sources/MainInterfaceStyle.swift | 21 ++ Where/WhereUI/Sources/MainSection.swift | 34 ++++ Where/WhereUI/Sources/MainSplitView.swift | 47 +++++ Where/WhereUI/Sources/MainTabs.swift | 87 +++----- Where/WhereUI/Sources/MainView.swift | 65 ++++++ Where/WhereUI/Sources/Model/WhereModel.swift | 21 ++ Where/WhereUI/Sources/Model/WhereScope.swift | 20 +- .../WhereUI/Sources/Model/WhereSession.swift | 37 +++- .../Sources/Onboarding/OnboardingView.swift | 145 +++++++++---- Where/WhereUI/Sources/PhoneMainTabs.swift | 51 +++++ .../Sources/Preview/PreviewSupport.swift | 3 + .../Sources/Resources/Localizable.xcstrings | 144 ++++++++++++- .../Settings/DevicesSettingsModel.swift | 2 +- .../Settings/DevicesSettingsView.swift | 25 ++- .../Settings/MenuBarSettingsModel.swift | 100 +++++++++ .../Settings/MenuBarSettingsSection.swift | 65 ++++++ .../Sources/Settings/SettingsView.swift | 10 +- .../Widgets/MacSummaryWidgetView.swift | 97 +++++++++ .../CurrentRecordingDeviceProviderTests.swift | 64 +++++- .../Tests/DevicesSettingsModelTests.swift | 5 +- Where/WhereUI/Tests/OnboardingTests.swift | 128 +++++++++++- Where/WhereUI/Tests/WhereSessionTests.swift | 2 +- .../Tests/WhereSessionTrackingTests.swift | 62 +++++- .../Tests/WidgetSnapshotRankingTests.swift | 10 +- Where/WhereWidgets/AGENTS.md | 13 +- Where/WhereWidgets/README.md | 9 +- .../Resources/Localizable.xcstrings | 24 +++ .../Sources/MacSummaryWidget.swift | 55 +++++ .../Sources/WhereWidgetsBundle.swift | 26 ++- .../Sources/WidgetSnapshotFixtures.swift | 3 + .../WhereWidgets-MacCatalyst.entitlements | 12 ++ 122 files changed, 3813 insertions(+), 456 deletions(-) create mode 100644 Where/Where/Where-MacCatalyst.entitlements create mode 100644 Where/WhereCore/Sources/Devices/RecordingParticipation.swift create mode 100644 Where/WhereCore/Sources/Persistence/StoreHistoryClassifier.swift create mode 100644 Where/WhereCore/Tests/RecordingParticipationTests.swift create mode 100644 Where/WhereCore/Tests/StoreHistoryClassifierTests.swift create mode 100644 Where/WhereCore/Tests/WherePreferencesTests.swift create mode 100644 Where/WhereMenuBar/AGENTS.md create mode 100644 Where/WhereMenuBar/README.md create mode 100644 Where/WhereMenuBar/Resources/Localizable.xcstrings create mode 100644 Where/WhereMenuBar/Sources/WhereMenuBarApp.swift create mode 100644 Where/WhereMenuBar/Sources/WhereMenuBarAppDelegate.swift create mode 100644 Where/WhereMenuBar/Sources/WhereMenuBarDayCountRow.swift create mode 100644 Where/WhereMenuBar/Sources/WhereMenuBarModel.swift create mode 100644 Where/WhereMenuBar/Sources/WhereMenuBarRegionRow.swift create mode 100644 Where/WhereMenuBar/Sources/WhereMenuBarSnapshotView.swift create mode 100644 Where/WhereMenuBar/Sources/WhereMenuBarUnavailableView.swift create mode 100644 Where/WhereMenuBar/Sources/WhereMenuBarView.swift create mode 100644 Where/WhereShareExtension/WhereShareExtension-MacCatalyst.entitlements create mode 100644 Where/WhereSurface/AGENTS.md create mode 100644 Where/WhereSurface/README.md create mode 100644 Where/WhereSurface/Sources/WhereSurfaceChangeNotification.swift create mode 100644 Where/WhereSurface/Sources/WhereSurfaceDocument.swift create mode 100644 Where/WhereSurface/Sources/WhereSurfaceReading.swift create mode 100644 Where/WhereSurface/Sources/WhereSurfaceSnapshot.swift create mode 100644 Where/WhereSurface/Sources/WhereSurfaceStore.swift create mode 100644 Where/WhereSurface/Tests/WhereSurfaceChangeNotificationTests.swift create mode 100644 Where/WhereSurface/Tests/WhereSurfaceDocumentTests.swift create mode 100644 Where/WhereSurface/Tests/WhereSurfaceSnapshotTests.swift create mode 100644 Where/WhereSurface/Tests/WhereSurfaceStoreTests.swift create mode 100644 Where/WhereUI/SnapshotTests/MacSummaryWidgetViewSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/MainViewSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad_dark.png create mode 100644 Where/WhereUI/Sources/MainInterfaceStyle.swift create mode 100644 Where/WhereUI/Sources/MainSection.swift create mode 100644 Where/WhereUI/Sources/MainSplitView.swift create mode 100644 Where/WhereUI/Sources/MainView.swift create mode 100644 Where/WhereUI/Sources/PhoneMainTabs.swift create mode 100644 Where/WhereUI/Sources/Settings/MenuBarSettingsModel.swift create mode 100644 Where/WhereUI/Sources/Settings/MenuBarSettingsSection.swift create mode 100644 Where/WhereUI/Sources/Widgets/MacSummaryWidgetView.swift create mode 100644 Where/WhereWidgets/Sources/MacSummaryWidget.swift create mode 100644 Where/WhereWidgets/WhereWidgets-MacCatalyst.entitlements diff --git a/.bumper/RULES.md b/.bumper/RULES.md index d4e60c62..98c466bf 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -9,12 +9,14 @@ tests and generated files are outside the architecture graph. | Component | Allowed Where dependencies | Framework capabilities | | --- | --- | --- | | `RegionKit` | none | Foundation | -| `WhereCore` | `RegionKit` | Foundation, persistence | +| `WhereSurface` | none | Foundation | +| `WhereCore` | `RegionKit`, `WhereSurface` | Foundation, persistence | | `WhereUI` | `RegionKit`, `WhereCore` | Foundation, SwiftUI, UIKit | | `WhereIntents` | `RegionKit`, `WhereCore`, `WhereUI` | Foundation, SwiftUI, UIKit | | `Where` app | `RegionKit`, `WhereCore`, `WhereUI`, `WhereIntents` | Foundation, SwiftUI, UIKit | | `WhereWidgets` | `RegionKit`, `WhereCore`, `WhereUI` | Foundation, SwiftUI, UIKit | | `WhereShareExtension` | `WhereCore`, `WhereUI` | Foundation, SwiftUI, UIKit | +| `WhereMenuBar` | `WhereSurface` | Foundation, SwiftUI, AppKit | | `RegionViewer` | `RegionKit`, `WhereCore`, `WhereUI` | Foundation, SwiftUI, UIKit | An import of a declared Where module outside these edges is a diff --git a/.bumper/Sources/WhereArchitecture.swift b/.bumper/Sources/WhereArchitecture.swift index cd54cc50..655d3c5b 100644 --- a/.bumper/Sources/WhereArchitecture.swift +++ b/.bumper/Sources/WhereArchitecture.swift @@ -20,6 +20,13 @@ extension ComponentShape { static let whereHostLayer = ComponentShape { MayUse(.foundation, .swiftUI, .uiKit) } + + static let whereMacHostLayer = ComponentShape { + // Bumper Bowling has no AppKit capability yet; AppKit is the native + // host framework and the component's explicit dependency rules still + // forbid CoreLocation and SwiftData. + MayUse(.foundation, .swiftUI) + } } extension AssertionShape { diff --git a/.bumper/Tests/WhereArchitectureTests.swift b/.bumper/Tests/WhereArchitectureTests.swift index 783c1058..8aa980da 100644 --- a/.bumper/Tests/WhereArchitectureTests.swift +++ b/.bumper/Tests/WhereArchitectureTests.swift @@ -9,21 +9,47 @@ func `Where architecture accepts downward dependencies`() throws { files: [ SourceInput( path: "Where/WhereCore/Sources/Service.swift", - component: try ComponentID(WhereComponent.whereCore.rawValue), - source: "import RegionKit\nstruct Service {}" + component: ComponentID(WhereComponent.whereCore.rawValue), + source: "import RegionKit\nimport WhereSurface\nstruct Service {}", ), SourceInput( path: "Where/WhereUI/Sources/Screen.swift", - component: try ComponentID(WhereComponent.whereUI.rawValue), - source: "import WhereCore\nimport SwiftUI\nstruct Screen {}" + component: ComponentID(WhereComponent.whereUI.rawValue), + source: "import WhereCore\nimport SwiftUI\nstruct Screen {}", ), - ] - ) + SourceInput( + path: "Where/WhereMenuBar/Sources/MenuBar.swift", + component: ComponentID(WhereComponent.menuBar.rawValue), + source: "import SwiftUI\nimport WhereSurface\nstruct MenuBar {}", + ), + ], + ), ) #expect(report.violations.isEmpty) } +@Test +func `WhereSurface cannot depend upward on WhereCore`() throws { + let report = try bumper.evaluate( + RepositoryInput( + architecture: bumper.architecture, + files: [ + SourceInput( + path: "Where/WhereSurface/Sources/Snapshot.swift", + component: ComponentID(WhereComponent.whereSurface.rawValue), + source: "import WhereCore\nstruct Snapshot {}", + ), + ], + ), + ) + + let violation = try #require(report.violations.first) + #expect(report.violations.count == 1) + #expect(violation.rule.id == .componentBoundary) + #expect(violation.path.rawValue == "Where/WhereSurface/Sources/Snapshot.swift") +} + @Test func `RegionKit cannot depend upward on WhereCore`() throws { let report = try bumper.evaluate( @@ -32,11 +58,11 @@ func `RegionKit cannot depend upward on WhereCore`() throws { files: [ SourceInput( path: "Where/RegionKit/Sources/Region.swift", - component: try ComponentID(WhereComponent.regionKit.rawValue), - source: "import WhereCore\nstruct Region {}" + component: ComponentID(WhereComponent.regionKit.rawValue), + source: "import WhereCore\nstruct Region {}", ), - ] - ) + ], + ), ) let violation = try #require(report.violations.first) @@ -53,11 +79,11 @@ func `WhereUI cannot import persistence`() throws { files: [ SourceInput( path: "Where/WhereUI/Sources/Screen.swift", - component: try ComponentID(WhereComponent.whereUI.rawValue), - source: "import SwiftData\nstruct Screen {}" + component: ComponentID(WhereComponent.whereUI.rawValue), + source: "import SwiftData\nstruct Screen {}", ), - ] - ) + ], + ), ) let violation = try #require(report.violations.first) @@ -74,16 +100,16 @@ func `Where adapters cannot link Broadway directly`() throws { files: [ SourceInput( path: "Where/WhereWidgets/Sources/Widget.swift", - component: try ComponentID(WhereComponent.widgets.rawValue), - source: "import BroadwayUI\nstruct Widget {}" + component: ComponentID(WhereComponent.widgets.rawValue), + source: "import BroadwayUI\nstruct Widget {}", ), SourceInput( path: "Where/WhereIntents/Sources/Intent.swift", - component: try ComponentID(WhereComponent.whereIntents.rawValue), - source: "import BroadwayCore\nstruct Intent {}" + component: ComponentID(WhereComponent.whereIntents.rawValue), + source: "import BroadwayCore\nstruct Intent {}", ), - ] - ) + ], + ), ) #expect(report.violations.count == 2) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea9b6fa7..aa5f2f68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,37 @@ jobs: if-no-files-found: warn retention-days: 7 + catalyst: + name: Build (Mac Catalyst) + needs: format + runs-on: xcode-27 + timeout-minutes: 30 + env: + CATALYST_DERIVED_DATA: ${{ github.workspace }}/catalyst-derived + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v3 + - name: Generate project + run: ./ide --no-open + - name: Build Where for Mac Catalyst + run: | + xcodebuild build \ + -workspace Stuff.xcworkspace \ + -scheme Where-Catalyst \ + -destination 'generic/platform=macOS,variant=Mac Catalyst' \ + -derivedDataPath "$CATALYST_DERIVED_DATA" \ + CODE_SIGNING_ALLOWED=NO + - name: Verify embedded Mac surfaces + run: | + APP="$CATALYST_DERIVED_DATA/Build/Products/Debug-maccatalyst/Where.app" + HELPER="$APP/Contents/Library/LoginItems/WhereMenuBar.app" + test -x "$HELPER/Contents/MacOS/WhereMenuBar" + test "$(plutil -extract CFBundleIdentifier raw -o - "$HELPER/Contents/Info.plist")" = "com.stuff.where.menubar" + test "$(plutil -extract CFBundlePackageType raw -o - "$HELPER/Contents/Info.plist")" = "APPL" + ! plutil -extract NSMainStoryboardFile raw -o - "$HELPER/Contents/Info.plist" + test -d "$APP/Contents/PlugIns/WhereWidgets.appex" + test -d "$APP/Contents/PlugIns/WhereShareExtension.appex" + snapshot: name: Snapshot Tests (iOS) needs: format diff --git a/AGENTS.md b/AGENTS.md index 1d10d162..469a885b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,6 +206,13 @@ app onto a connected iPhone without the Xcode UI, use configured once via `./ide --team-id` (see [`Where/AGENTS.md`](Where/AGENTS.md#installing-to-a-device)). +Where's Mac app is the same target built for Mac Catalyst. Use the explicit +`Where-Catalyst` scheme: it builds the native `WhereMenuBar` login item first, +then the Catalyst app conditionally embeds it at +`Contents/Library/LoginItems`. Keep that manual build order and copy phase +together; Tuist rejects a direct dependency edge between the Catalyst and +native-macOS targets. + ## Per-module docs Shared modules live under `Shared/`, feature modules under a top-level folder diff --git a/BumperBowling.swift b/BumperBowling.swift index 52e813a4..edfae2ff 100644 --- a/BumperBowling.swift +++ b/BumperBowling.swift @@ -2,24 +2,28 @@ import BumperBowlingCore enum WhereComponent: String, ComponentKey { case regionKit + case whereSurface case whereCore case whereUI case whereIntents case app case widgets case shareExtension + case menuBar case regionViewer } let bumper = BumperProject { Included { "Where/RegionKit/Sources" + "Where/WhereSurface/Sources" "Where/WhereCore/Sources" "Where/WhereUI/Sources" "Where/WhereIntents/Sources" "Where/Where/Sources" "Where/WhereWidgets/Sources" "Where/WhereShareExtension/Sources" + "Where/WhereMenuBar/Sources" "Where/RegionViewer/Sources" } @@ -37,10 +41,16 @@ let bumper = BumperProject { DoesNotUse("CoreLocation") } + Component(.whereSurface) { + Owns("Where/WhereSurface/Sources") + Modules("WhereSurface") + Applies(.whereFoundationLayer) + } + Component(.whereCore) { Owns("Where/WhereCore/Sources") Modules("WhereCore") - MayDependOn(.regionKit) + MayDependOn(.regionKit, .whereSurface) Applies(.whereDomainLayer) } @@ -82,6 +92,14 @@ let bumper = BumperProject { Applies(.whereAdapterLayer) } + Component(.menuBar) { + Owns("Where/WhereMenuBar/Sources") + Modules("WhereMenuBar") + MayDependOn(.whereSurface) + Applies(.whereMacHostLayer) + DoesNotUse("CoreLocation", "SwiftData") + } + Component(.regionViewer) { Owns("Where/RegionViewer/Sources") Modules("RegionViewer") diff --git a/Package.swift b/Package.swift index 6a52f459..6cf751f8 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,7 @@ let package = Package( defaultLocalization: "en", platforms: [ .iOS(.v26), + .macOS(.v26), ], products: [ .library(name: "StuffCore", targets: ["StuffCore"]), @@ -22,6 +23,7 @@ let package = Package( .library(name: "SnapshotKitTesting", targets: ["SnapshotKitTesting"]), .library(name: "TestHostSupport", targets: ["TestHostSupport"]), .library(name: "RegionKit", targets: ["RegionKit"]), + .library(name: "WhereSurface", targets: ["WhereSurface"]), .library(name: "WhereCore", targets: ["WhereCore"]), .library(name: "WhereUI", targets: ["WhereUI"]), .library(name: "WhereIntents", targets: ["WhereIntents"]), @@ -136,12 +138,17 @@ let package = Package( .process("Resources"), ], ), + .target( + name: "WhereSurface", + path: "Where/WhereSurface/Sources", + ), .target( name: "WhereCore", dependencies: [ .target(name: "CreditKit"), .target(name: "PeriscopeCore"), .target(name: "RegionKit"), + .target(name: "WhereSurface"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), ], path: "Where/WhereCore/Sources", diff --git a/Project.swift b/Project.swift index 31fecea3..5466beb8 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, …). @@ -57,6 +59,13 @@ let whereAppEntitlements: Entitlements = .dictionary([ ), ]) +/// The native menu-bar login item reads only the app's published glance JSON. +/// It has no CloudKit, network, location, or store capability of its own. +let whereMenuBarEntitlements: Entitlements = .dictionary([ + "com.apple.security.app-sandbox": .boolean(true), + "com.apple.security.application-groups": .array([.string("group.com.stuff.where")]), +]) + /// The environment the LFS reference images were recorded on, and the single /// source of truth for it. /// @@ -174,7 +183,7 @@ let project = Project( targets: [ .target( name: "Where", - destinations: destinations, + destinations: whereDestinations, product: .app, bundleId: "com.stuff.where", deploymentTargets: deployment, @@ -182,6 +191,12 @@ let project = Project( "UILaunchScreen": .dictionary([:]), "UIApplicationSupportsIndirectInputEvents": .boolean(true), "UIBackgroundModes": .array([.string("remote-notification")]), + "CFBundleURLTypes": .array([ + .dictionary([ + "CFBundleURLName": .string("com.stuff.where"), + "CFBundleURLSchemes": .array([.string("where")]), + ]), + ]), // Stated explicitly rather than left to Tuist's `1.0` / `1` // defaults, because Settings > About shows them: the version a // user reads off the screen should be one this manifest chose. @@ -196,6 +211,19 @@ let project = Project( ]), sources: ["Where/Where/Sources/**"], resources: ["Where/Where/Resources/**"], + copyFiles: [ + .wrapper( + name: "Embed Menu Bar Login Item", + subpath: "Contents/Library/LoginItems", + files: [ + .buildProduct( + name: "WhereMenuBar", + condition: .when([.catalyst]), + codeSignOnCopy: true, + ), + ], + ), + ], entitlements: whereAppEntitlements, // Writes `WhereGitSHA` / `WhereGitStatus` into the built Info.plist // for Settings > About. A *post* script so it lands after "Process @@ -227,11 +255,15 @@ let project = Project( settings: .settings(base: [ "ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS": "YES", "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "", + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]": + "$(SRCROOT)/Where/Where/Where-MacCatalyst.entitlements", + "DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER": "NO", + "ENABLE_HARDENED_RUNTIME[sdk=macosx*]": "YES", ]), ), .target( name: "WhereWidgets", - destinations: destinations, + destinations: whereDestinations, product: .appExtension, bundleId: "com.stuff.where.widgets", deploymentTargets: deployment, @@ -250,10 +282,16 @@ let project = Project( .package(product: "WhereCore"), .package(product: "WhereUI"), ], + settings: .settings(base: [ + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]": + "$(SRCROOT)/Where/WhereWidgets/WhereWidgets-MacCatalyst.entitlements", + "DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER": "NO", + "ENABLE_HARDENED_RUNTIME[sdk=macosx*]": "YES", + ]), ), .target( name: "WhereShareExtension", - destinations: destinations, + destinations: whereDestinations, product: .appExtension, bundleId: "com.stuff.where.share", deploymentTargets: deployment, @@ -285,6 +323,46 @@ 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", + ]), ), .target( name: "RegionViewer", @@ -471,6 +549,12 @@ let project = Project( productDependency: "RegionKit", sources: ["Where/RegionKit/Tests/**"], ), + unitTests( + name: "WhereSurfaceTests", + bundleIdSuffix: "wheresurface", + productDependency: "WhereSurface", + sources: ["Where/WhereSurface/Tests/**"], + ), unitTests( name: "WhereCoreTests", bundleIdSuffix: "wherecore", @@ -622,9 +706,27 @@ let project = Project( // WhereCoreTests` / `tuist test WhereTests` / `tuist test WhereUITests` // target a single bundle without building the whole workspace. schemes: [ - // App target schemes are normally autogenerated, but declare the - // RegionViewer one explicitly so `tuist build RegionViewer` (and a - // Run that launches the Catalyst app) is always available. + // App target schemes are normally autogenerated, but declare the two + // Catalyst-capable hosts explicitly so CLI builds and Runs are stable. + .scheme( + name: "Where", + shared: true, + buildAction: .buildAction(targets: ["Where"]), + runAction: .runAction(executable: "Where"), + ), + // Tuist rejects a target-dependency edge from a Catalyst app to a + // native macOS login-item app even when that edge is Catalyst-filtered. + // Build the helper first in a manual-order scheme; Where's conditional + // copy phase then embeds that product only for Catalyst. + .scheme( + name: "Where-Catalyst", + shared: true, + buildAction: .buildAction( + targets: ["WhereMenuBar", "Where"], + buildOrder: .manual, + ), + runAction: .runAction(executable: "Where"), + ), .scheme( name: "RegionViewer", shared: true, @@ -654,6 +756,7 @@ let project = Project( "SnapshotKitTests", "SnapshotKitTestingTests", "RegionKitTests", + "WhereSurfaceTests", "WhereCoreTests", "WhereTests", "WhereUITests", @@ -678,6 +781,7 @@ let project = Project( "SnapshotKitTests", "SnapshotKitTestingTests", "RegionKitTests", + "WhereSurfaceTests", "WhereCoreTests", "WhereTests", "WhereUITests", @@ -702,6 +806,7 @@ let project = Project( testScheme(name: "SnapshotKitTests"), testScheme(name: "SnapshotKitTestingTests"), testScheme(name: "RegionKitTests"), + testScheme(name: "WhereSurfaceTests"), testScheme(name: "WhereCoreTests"), testScheme(name: "WhereTests"), testScheme(name: "WhereUITests"), diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 3c886149..e4af575d 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -1,7 +1,8 @@ # Where – Feature Shape -Where is an iOS/iPadOS app for answering "what region was I in on which -day?" It ingests passive GPS (Visits + significant-change), accepts +Where is an iOS/iPadOS and Mac Catalyst app for answering "what region was I +in on which day?" Participating iPhones and iPads ingest passive GPS (Visits + +significant-change); every host accepts user-asserted history (manual coordinates, whole-day overlays, evidence like boarding passes), and rolls everything up into per-day region presence and per-year reports. A day "counts" for a region if **any** sample in that @@ -13,13 +14,14 @@ system, formatting, and global conventions. Read that first. ## Modules -The layering stack, bottom-up: **RegionKit** (geometry + region lookup) → +The layering stack, bottom-up: **WhereSurface** (presentation-ready glance +document, Foundation only) and **RegionKit** (geometry + region lookup) → **WhereCore** (domain; never imports SwiftUI/UIKit) → **WhereUI** (SwiftUI views + view models) → the thin hosts (**Where** app, **WhereIntents**, -**WhereWidgets**, **WhereShareExtension**, **RegionViewer**). Each layer -reaches only *down*; each module's own `AGENTS.md` / `README.md` is the -authority on what it is. Add domain behavior to WhereCore and presentation to -WhereUI — the app target stays tiny. +**WhereWidgets**, **WhereShareExtension**, **WhereMenuBar**, **RegionViewer**). +Each layer reaches only *down*; each module's own `AGENTS.md` / `README.md` is +the authority on what it is. Add domain behavior to WhereCore and presentation +to WhereUI — the app target stays tiny. ## Layering @@ -68,6 +70,9 @@ Rules the code enforces and agents must preserve: through `LocationHistoryReader`. A synced cutoff hides later raw samples immediately while the target device is still pending, and raw/legacy/manual history is never deleted or hidden without an attributable device policy. + Mac Catalyst is management-only and never creates a local recording identity + or `CLLocationManager`; a new iPad defaults recording off while an upgraded + iPad without a stored preference preserves the historical enabled behavior. - **Manual entries carry a `ManualEntryAudit`**; `DayJournal`'s write methods take an explicit `audit:` (no default). An additive backfill can't downgrade an authoritative row's regions, but the newer audit always wins. @@ -175,15 +180,18 @@ slow. ## Navigation -The logged-in shell is `MainTabs` — **three fixed tabs**: Locations, Your -Year, Settings; everything else hangs off one of them. A new screen is a +The logged-in shell is `MainTabs`, with three fixed sections: Locations, Your +Year, Settings. `PhoneMainTabs` presents them as tabs on iPhone; +`MainSplitView` presents the same sections in a stable two-column +`NavigationSplitView` on iPad and Mac Catalyst, letting the system collapse +columns rather than swapping roots at a width threshold. A new screen is a pushed destination, a sheet, or a Settings row inside that shape — a fourth -tab is a product decision to raise before building. `MainTabs` passes the +section is a product decision to raise before building. `MainTabs` passes the scene-scoped `YearReportModel` by explicit init injection; the always-on `WhereSession` coordinator travels in the environment. Settings is a typed-route list (`SettingsSearch.swift`; every switch is exhaustive), so a -new drill-in is a set of compile errors to fill in; About stays the last -block and the demo-mode exit the first. +new drill-in is a set of compile errors to fill in; About stays the last block +and the demo-mode exit the first. The About screen renders three live sources — the generated attribution report (`WhereCore.AppAttribution`), `RegionDataSource`, and `BuildInfo` — @@ -191,6 +199,17 @@ never a list hard-coded in the view. A missing report or unstamped build renders an honest empty state, and shipped libraries stay a separate section from development tools. Design and rationale: PR #140. +## Glance surfaces + +`WhereCore.WidgetSnapshotPublisher` is the only writer of the version-tolerant +App Group JSON used by widgets and the native `WhereMenuBar` login item. The +document carries both the widget's domain snapshot and a presentation-ready +`WhereSurfaceSnapshot`; lightweight hosts import only `WhereSurface`, retain +the last good document when a read fails, and never open SwiftData, CloudKit, +or CoreLocation. A successful atomic 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/Where/AGENTS.md b/Where/Where/AGENTS.md index f0c6b435..2e5c16aa 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 @@ -51,6 +54,11 @@ layering, and the domain rules this target merely starts up. container (`iCloud.com.stuff.where`), and remote-notification background mode together in `Project.swift`; widgets and the share extension stay App Group-only and never open a CloudKit container. +- **Catalyst embeds, but never launches, the native `WhereMenuBar` helper.** + `Project.swift` builds it first in the `Where-Catalyst` scheme and conditionally + copies it to `Contents/Library/LoginItems`; Settings registers or unregisters + that exact bundle with `SMAppService`. The helper opens this app through the + `where://open` URL and must not gain a direct target dependency back to it. - **Nothing here may assume the user has a store.** `didFinishLaunching` starts the ambient log sources and drives the launch; anything wanting the user's data waits for `.ready` and checks what it got — the Spotlight indexing after diff --git a/Where/Where/README.md b/Where/Where/README.md index f97f9385..abe868bc 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,20 +30,24 @@ 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 diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index 454d13ed..b140018d 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -10,13 +10,14 @@ import WhereUI /// launch, wiring both up at process launch rather than from a SwiftUI view's /// `.task`. /// -/// This matters for background relaunch: when CoreLocation relaunches the app -/// after termination (a significant location change or visit), there's no UI, -/// so a view's `.task` is not a reliable hook. `didFinishLaunching` always -/// runs, so building the runner here (whose synchronous -/// `initializePrerequisites` installs the `CLLocationManager`) lets CoreLocation -/// deliver the pending event, while the async launch steps continue background -/// tracking off the main thread. +/// This matters for background relaunch on a participating iPhone or iPad: +/// when CoreLocation relaunches the app after termination (a significant +/// location change or visit), there's no UI, so a view's `.task` is not a +/// reliable hook. `didFinishLaunching` always runs, so building the runner here +/// (whose synchronous `initializePrerequisites` installs the +/// `CLLocationManager` on those hosts) lets CoreLocation deliver the pending +/// event, while the async launch steps continue background tracking off the +/// main thread. Mac Catalyst skips the location prerequisite entirely. @MainActor final class AppDelegate: NSObject, UIApplicationDelegate { /// The app's model, logging into the process-wide Periscope system. This is @@ -67,18 +68,20 @@ final class AppDelegate: NSObject, UIApplicationDelegate { // possible location wake we can't yet rule out) and builds no view tree; // `RootView`'s `enterForeground()` promotes it to `.userForeground` once // a scene genuinely activates. A genuine headless wake simply stays - // `.undetermined` — the queued location event is delivered through the - // `CLLocationManager` installed below, so no launch-state guess is - // needed to service it. + // `.undetermined` — on a participating iPhone or iPad the queued + // location event is delivered through the `CLLocationManager` installed + // below, so no launch-state guess is needed to service it. Catalyst has + // no local recorder to service. // // Start the process-wide ambient log sources. The durable sink belongs // to whichever scope the user ends up in (`WhereScope` opens it), and // no scope exists this early, so these — and everything else logged // before the launch resolves one — reach OSLog only. WhereLaunch.startAmbientLogging(on: .shared) - // `initializePrerequisites` installs the CLLocationManager synchronously - // (so a queued location event isn't lost) and registers the - // foreground-notification presenter; the rest (store open, etc.) runs as + // `initializePrerequisites` installs the CLLocationManager + // synchronously on participating iPhone/iPad hosts (so a queued location + // event isn't lost) and registers the foreground-notification presenter; + // Catalyst skips the location half. The rest (store open, etc.) runs as // async steps off this synchronous launch path. // `onServicesReady` fires from the `start-session` step on every session // (re)start: derive the App Intents stack from the launch's services — diff --git a/Where/Where/Where-MacCatalyst.entitlements b/Where/Where/Where-MacCatalyst.entitlements new file mode 100644 index 00000000..f17a7a5a --- /dev/null +++ b/Where/Where/Where-MacCatalyst.entitlements @@ -0,0 +1,26 @@ + + + + + com.apple.developer.icloud-container-identifiers + + iCloud.com.stuff.where + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)com.stuff.where + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.stuff.where + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + + diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 827c1702..3af56cf2 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,16 @@ 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`, 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 @@ -90,6 +101,9 @@ internal shape. awaits, stamp every ingested GPS sample with the current installation id, and apply `LocationHistoryReader` to every user-facing projection. Backups alone read the lossless raw samples and full policy/device tables. +- **Management-only participation has no local recording identity.** Keep its + ingestor inert and never register a current-device row; synced remote-device + policy and profile edits remain available. - **Tracked regions live in the store, not preferences** — one `SDTrackedRegion` row per region so cross-device edits merge; read as a `Set` defaulting to the four. `RegionAttribution` derives the attributor diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 41111e06..018df04d 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -25,8 +25,10 @@ one it belongs to rather than to a god-object: 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 — + implementation; `SwiftDataStore.inMemory()` backs tests and previews. An + on-disk store stamps writer contexts and filters SwiftData history so + `remoteChanges()` never echoes local GPS commits. Each process opens its + on-disk store **once** and injects it where it's needed — in the app, the launch's `resolve-scope` step opens it and the App Intents stack shares it via `WhereServices.forIntents(sharingStoreOf:)` — so two subsystems never race to create/open the same store file. It also @@ -90,7 +92,11 @@ one it belongs to rather than to a god-object: - **`DeviceRecordingController`** — serializes per-device enable/disable policy with the current installation's physical `LocationIngestor`. A remote disable is effective at its timestamp as soon as it syncs; the target device - later acknowledges that event after it has stopped. + later acknowledges that event after it has stopped. `RecordingParticipation` + makes local recording explicit: iPhone starts new installations on, iPad + starts them opt-in while retaining a migrated preference, and a + management-only process has no local identity or GPS lifecycle but can still + edit synced remote devices. - **`LocationHistoryReader`** — the shared policy-aware read boundary used by reports, widgets, recent activity, and foreground capture checks. It filters GPS samples during disabled intervals while keeping raw storage, backups, @@ -111,8 +117,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 atomic write as fresh. The + base app and its derived App Intents stack share this publisher so their + local write paths cannot leave competing hot-path caches. - **`BackupCoordinator`** — whole-database export / import (a ZIP archive, via `ZIPFoundation`). - **`RecentActivitySummarizer`** — an on-device Foundation Models narrative over @@ -121,7 +132,9 @@ one it belongs to rather than to a god-object: reminder / summary schedules) behind a `KeyValueStore`. The store has no default: production names `UserDefaults.standard` and everything else names `InMemoryKeyValueStore()`, so no test or preview can reach the host's real - defaults by saying nothing. + defaults by saying nothing. Its recording-intent resolver distinguishes a + new installation's platform default from the historical on-by-default value + retained by an already-onboarded installation. - **`BuildInfo`** + **`AppAttribution`** — what Settings > About says about the bundle it is running in. `BuildInfo.current(bundle:)` reads the marketing version, build number, the commit the app was built from, and how the Swift diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index 3bf4c410..95a1e839 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -1,6 +1,6 @@ import Foundation -/// Serializes the synced recording policy with this device's physical GPS +/// Serializes synced recording policy with this process's optional physical GPS /// lifecycle. /// /// Policy writes take effect historically at their timestamp immediately on @@ -9,7 +9,11 @@ import Foundation public actor DeviceRecordingController { private let store: any WhereStore private let ingestor: LocationIngestor - public nonisolated let currentDevice: CurrentRecordingDevice + public nonisolated let participation: RecordingParticipation + public nonisolated var currentDevice: CurrentRecordingDevice? { + participation.currentDevice + } + private let now: @Sendable () -> Date /// Reentrancy-safe gate: each public mutation/reconcile holds it across @@ -22,34 +26,39 @@ public actor DeviceRecordingController { init( store: any WhereStore, ingestor: LocationIngestor, - currentDevice: CurrentRecordingDevice, + participation: RecordingParticipation, now: @escaping @Sendable () -> Date, ) { self.store = store self.ingestor = ingestor - self.currentDevice = currentDevice + self.participation = participation self.now = now } - /// Register this installation if needed, migrate its initial desired state - /// from local preferences, then make physical monitoring match the latest - /// synced policy and authorization. + /// For a participating installation, register it if needed, migrate its + /// initial desired state from local preferences, then make physical + /// monitoring match the latest synced policy and authorization. A + /// management-only process stops its inert ingestor and returns `nil`. @discardableResult public func reconcile( initialEnabled: Bool, authorization: LocationAuthorizationStatus, - ) async throws -> RecordingDeviceConfiguration { + ) async throws -> RecordingDeviceConfiguration? { await beginExclusive() defer { endExclusive() } try requireActive() + guard currentDevice != nil else { + await ingestor.stop() + return nil + } return try await reconcileLocked( initialEnabled: initialEnabled, authorization: authorization, ) } - /// Active device configurations, current device first and then by most - /// recent check-in. + /// Active device configurations, with the current device first when this + /// process participates and the rest ordered by most recent check-in. public func devices(initialEnabled: Bool) async throws -> [RecordingDeviceConfiguration] { await beginExclusive() defer { endExclusive() } @@ -90,7 +99,7 @@ public actor DeviceRecordingController { } } - if deviceID == currentDevice.id { + if deviceID == currentDevice?.id { let authorization = await ingestor.authorizationStatus() _ = try await reconcileLocked( initialEnabled: initialEnabled, @@ -131,7 +140,7 @@ public actor DeviceRecordingController { _ deviceID: RecordingDeviceID, initialEnabled: Bool, ) async throws -> [RecordingDeviceConfiguration] { - precondition(deviceID != currentDevice.id, "The current device cannot archive itself.") + precondition(deviceID != currentDevice?.id, "The current device cannot archive itself.") await beginExclusive() defer { endExclusive() } try requireActive() @@ -180,6 +189,9 @@ public actor DeviceRecordingController { initialEnabled: Bool, authorization: LocationAuthorizationStatus, ) async throws -> RecordingDeviceConfiguration { + guard let currentDevice else { + preconditionFailure("A management-only controller cannot reconcile local recording.") + } try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) let policies = try await store.recordingPolicyChanges() guard let latest = Self.latestPolicy(for: currentDevice.id, in: policies) else { @@ -228,6 +240,7 @@ public actor DeviceRecordingController { } private func ensureCurrentDeviceLocked(initialEnabled: Bool) async throws { + guard let currentDevice else { return } let devices = try await store.recordingDevices() let policies = try await store.recordingPolicyChanges() let existing = devices.first(where: { $0.id == currentDevice.id }) @@ -270,7 +283,7 @@ public actor DeviceRecordingController { let (resolvedDevices, resolvedPolicies) = try await (devices, policies) return resolvedDevices .filter { - includeArchived || $0.archivedAt == nil || $0.id == currentDevice.id + includeArchived || $0.archivedAt == nil || $0.id == currentDevice?.id } .map { device in let latest = Self.latestPolicy(for: device.id, in: resolvedPolicies) @@ -281,8 +294,8 @@ public actor DeviceRecordingController { ) } .sorted { lhs, rhs in - if lhs.id == currentDevice.id { return true } - if rhs.id == currentDevice.id { return false } + if lhs.id == currentDevice?.id { return true } + if rhs.id == currentDevice?.id { return false } if lhs.device.lastSeenAt != rhs.device.lastSeenAt { return lhs.device.lastSeenAt > rhs.device.lastSeenAt } diff --git a/Where/WhereCore/Sources/Devices/RecordingParticipation.swift b/Where/WhereCore/Sources/Devices/RecordingParticipation.swift new file mode 100644 index 00000000..1f1d43cb --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingParticipation.swift @@ -0,0 +1,30 @@ +/// Whether this process may contribute automatic locations from the local +/// installation, plus the policy a genuinely new installation starts with. +/// +/// A management-only process can still read and edit synced recording-device +/// rows, but it has no local device identity and must never start GPS. +public enum RecordingParticipation: Sendable, Hashable { + case recording( + device: CurrentRecordingDevice, + defaultEnabledForNewInstallation: Bool, + ) + case managementOnly + + public var currentDevice: CurrentRecordingDevice? { + switch self { + case let .recording(device, _): device + case .managementOnly: nil + } + } + + public var defaultEnabledForNewInstallation: Bool { + switch self { + case let .recording(_, isEnabled): isEnabled + case .managementOnly: false + } + } + + public var supportsLocalRecording: Bool { + currentDevice != nil + } +} diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index b765f47d..5c2e1dac 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -29,7 +29,10 @@ public actor LocationIngestor { private let store: any WhereStore private let locationSource: any LocationSource - private let recordingDeviceID: RecordingDeviceID + /// Nil for a management-only process. Such a process can use the service + /// layer for reads and manual writes, but every automatic-location entry + /// point remains inert because there is no installation to attribute it to. + private let recordingDeviceID: RecordingDeviceID? private let calendar: Calendar private let onPersisted: PostPersistHook /// Durable mirror of `retryQueue`, so a backlog survives the process dying @@ -90,7 +93,7 @@ public actor LocationIngestor { init( store: any WhereStore, locationSource: any LocationSource, - recordingDeviceID: RecordingDeviceID, + recordingDeviceID: RecordingDeviceID?, calendar: Calendar, outbox: any LocationOutbox = NoOpLocationOutbox(), retryQueueCapacity: Int = 1000, @@ -122,6 +125,7 @@ public actor LocationIngestor { /// single-consumer `AsyncStream`, so a later `start()` would iterate an /// already-finished stream and silently drop every subsequent sample. public func start() async { + guard let recordingDeviceID else { return } // Re-open the sample gate a prior `quiesce()` may have shut (e.g. the // relaunch after a reset resumes ingestion here). acceptsSamples = true @@ -245,6 +249,7 @@ public actor LocationIngestor { /// this stays safe regardless because `requestCurrentLocation()` returns /// `nil` when no fix is available. public func captureTodayIfNeeded(now: Date) { + guard recordingDeviceID != nil else { return } guard captureTask == nil else { return } captureTask = Task { [weak self] in await self?.performTodayCapture(now: now) @@ -331,6 +336,7 @@ public actor LocationIngestor { /// failure. Drains any backlog first so a single transient outage doesn't /// permanently reorder samples on disk. private func processIngestedSample(_ sample: LocationSample) async { + guard let recordingDeviceID else { return } let sample = sample.recorded(by: recordingDeviceID) let drainedDays = await drainRetryQueue() do { diff --git a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift index f3c2b3ff..5345631f 100644 --- a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift +++ b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift @@ -41,13 +41,17 @@ enum SwiftDataStoreLog: LogEvent { case ignoredUnknownPrimaryRegions(ids: [String]) /// Dropped a record that failed to materialize into a domain value. case droppedCorruptRecord(type: String) + /// Persistent history could not be read to classify a store write. + case historyReadFailed(description: String) static let eventName = "SwiftDataStore" var level: LogLevel { switch self { case .openedInMemory, .openedOnDisk: .info - case .ignoredUnknownTrackedRegions, .ignoredUnknownPrimaryRegions: .warning + case .ignoredUnknownTrackedRegions, .ignoredUnknownPrimaryRegions, + .historyReadFailed: + .warning case .droppedCorruptRecord: .fault } } @@ -64,6 +68,8 @@ enum SwiftDataStoreLog: LogEvent { "Ignored \(ids.count) unknown primary-region id(s): \(ids.joined(separator: ", "))" case let .droppedCorruptRecord(type): "Dropped corrupt SwiftData record of type \(type)" + case let .historyReadFailed(description): + "Failed to read SwiftData history: \(description)" } } } diff --git a/Where/WhereCore/Sources/Persistence/StoreChangeBroadcaster.swift b/Where/WhereCore/Sources/Persistence/StoreChangeBroadcaster.swift index 6a4c4f92..f8d34af1 100644 --- a/Where/WhereCore/Sources/Persistence/StoreChangeBroadcaster.swift +++ b/Where/WhereCore/Sources/Persistence/StoreChangeBroadcaster.swift @@ -3,14 +3,9 @@ import Foundation /// Fans "the persisted data changed" pings out to any number of independent /// `AsyncStream` subscribers. /// -/// The persistence boundary (`SwiftDataStore`) owns one of these and pings it -/// once per committed change — after every outermost `perform` transaction -/// commits, and on a CloudKit remote import synced from another device. That -/// gives every reader a single signal regardless of *who* wrote: a manual edit, -/// live GPS ingestion, or a remote sync. Consumers re-derive what they mirror -/// (the `DataIssueScanner` drops its cache; `WhereSession` re-pulls its report + -/// data-issue scan), so the payload is a bare `Void` — N pending pings and one -/// are equivalent. +/// `SwiftDataStore` owns one for every committed change and another for the +/// remote-import subset. Consumers re-derive what they mirror, so the payload +/// is a bare `Void` — N pending pings and one are equivalent. /// /// Like `AuthorizationStatusBroadcaster`, each `subscribe()` gets an isolated /// stream — an `AsyncStream` is single-pass, and the session is dropped + rebuilt diff --git a/Where/WhereCore/Sources/Persistence/StoreHistoryClassifier.swift b/Where/WhereCore/Sources/Persistence/StoreHistoryClassifier.swift new file mode 100644 index 00000000..4f1d63cd --- /dev/null +++ b/Where/WhereCore/Sources/Persistence/StoreHistoryClassifier.swift @@ -0,0 +1,48 @@ +import Foundation +import SwiftData + +/// Separates this store instance's transactions from writes made elsewhere. +/// +/// `NSPersistentStoreRemoteChange` is a write notification, not an +/// external-origin guarantee. Every writer context owned by `SwiftDataStore` +/// carries `localAuthor`; history after a checkpoint is external when at least +/// one transaction has a different author. Each operation uses a fresh +/// `ModelContext`, keeping this value stateless and safe to use from the +/// store's long-lived observation task. +struct StoreHistoryClassifier { + struct Classification { + let latestToken: DefaultHistoryToken? + let containsExternalTransaction: Bool + } + + let container: ModelContainer + let localAuthor: String + + /// Returns the newest history token, used as the observation checkpoint. + func checkpoint() throws -> DefaultHistoryToken? { + let context = ModelContext(container) + var descriptor = HistoryDescriptor( + sortBy: [SortDescriptor(\.transactionIdentifier, order: .reverse)], + ) + descriptor.fetchLimit = 1 + return try context.fetchHistory(descriptor).first?.token + } + + /// Classifies every transaction after `token` and advances the checkpoint. + func classify(after token: DefaultHistoryToken?) throws -> Classification { + let context = ModelContext(container) + var descriptor = if let token { + HistoryDescriptor( + predicate: #Predicate { $0.token > token }, + ) + } else { + HistoryDescriptor() + } + descriptor.sortBy = [SortDescriptor(\.transactionIdentifier, order: .forward)] + let transactions = try context.fetchHistory(descriptor) + return Classification( + latestToken: transactions.last?.token ?? token, + containsExternalTransaction: transactions.contains { $0.author != localAuthor }, + ) + } +} diff --git a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift index 1a4f149d..0a13450a 100644 --- a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift +++ b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift @@ -1,11 +1,22 @@ import CoreData import Foundation -/// Abstraction over "the persistent store imported changes from elsewhere" — -/// for a CloudKit-backed store, a sync landing from another device. A -/// `SwiftDataStore` observes one of these and re-pings its `changes()` fan-out, -/// so a remote import refreshes the UI exactly like a local commit (one read -/// path, regardless of who wrote). +/// A persistent-store write signal awaiting origin classification. +/// +/// Core Data's `.NSPersistentStoreRemoteChange` name is misleading: Apple +/// documents that it posts for every persistent-store write, including writes +/// from the current process. `SwiftDataStore` therefore classifies production +/// events through SwiftData history before deciding whether to emit its +/// external-only side-effect signal. The scripted case is explicitly external +/// so tests can drive the post-classification path without a persistent store. +enum StoreRemoteChangeEvent { + case persistentStoreWrite + case external +} + +/// Abstraction over persistent-store write notifications. A `SwiftDataStore` +/// observes one and uses transaction history to separate its own commits from +/// CloudKit or sibling-process writes. /// /// The seam exists so the whole remote-change path is exercisable off-device: /// production wires `PersistentStoreRemoteChangeSource` (a real Core Data @@ -14,66 +25,59 @@ import Foundation /// the notification on import — stays untested here. /// /// Class-only (`AnyObject`) because every implementation owns long-lived state -/// (a notification token, an `AsyncStream.Continuation`) that can't be +/// (an observer registration, an `AsyncStream.Continuation`) that can't be /// value-copied. Mirrors `LocationSource`. protocol StoreRemoteChangeSource: AnyObject, Sendable { - /// Emits once per imported remote change. A bare `Void`: the store re-pings - /// its fan-out and consumers re-read, so they only need to know *that* - /// something changed. Exactly one consumer (the store) subscribes, so this - /// is a single stream rather than a broadcaster. - var remoteChanges: AsyncStream { get } + /// Emits once per persistent-store notification (production) or explicitly + /// external test event. Exactly one store consumes this stream. + var remoteChanges: AsyncStream { get } } /// Production `StoreRemoteChangeSource`: bridges Core Data's -/// `.NSPersistentStoreRemoteChange` notification into an `AsyncStream`. That -/// notification fires both when the CloudKit mirror -/// (`NSPersistentCloudKitContainer`) imports records synced from another device -/// and when a sibling process writes to a shared App Group store (the Where -/// share extension saving evidence) — persistent-history tracking is on for -/// on-disk stores. Observing it and re-reading is Apple's documented way to -/// react to remote SwiftData/CloudKit and cross-process changes. +/// `.NSPersistentStoreRemoteChange` notification into an `AsyncStream`. +/// Despite its name, the notification fires for every write, including a +/// `ModelContext.save()` in this process. The source deliberately preserves +/// that raw meaning; `SwiftDataStore` checks transaction authors before it +/// calls a write external. /// -/// One store per app, so it forwards every remote-change notification rather +/// One store per app, so it forwards every store-write notification rather /// than filtering by coordinator (SwiftData doesn't expose the underlying /// `NSPersistentStoreCoordinator` to filter on anyway). -final class PersistentStoreRemoteChangeSource: StoreRemoteChangeSource, @unchecked Sendable { - let remoteChanges: AsyncStream +final class PersistentStoreRemoteChangeSource: NSObject, StoreRemoteChangeSource, + @unchecked Sendable +{ + let remoteChanges: AsyncStream private let center: NotificationCenter - private let continuation: AsyncStream.Continuation - private let observer: NSObjectProtocol + private let continuation: AsyncStream.Continuation init(center: NotificationCenter = .default) { self.center = center - var cont: AsyncStream.Continuation! + var cont: AsyncStream.Continuation! remoteChanges = AsyncStream { cont = $0 } continuation = cont - // Capture the continuation in a local — deliberately *not* - // `self.continuation` — so the long-lived observer block (which - // `NotificationCenter` retains until `removeObserver`) doesn't capture - // `self`. Capturing `self` would keep this source alive for as long as - // the observer is registered, so `deinit` (which removes it) could - // never run. The stored `continuation` property exists only for - // `deinit` to `finish()`. - let captured = cont! - observer = center.addObserver( - forName: .NSPersistentStoreRemoteChange, + super.init() + center.addObserver( + self, + selector: #selector(persistentStoreDidWrite), + name: .NSPersistentStoreRemoteChange, object: nil, - queue: nil, - ) { _ in - captured.yield() - } + ) + } + + @objc private func persistentStoreDidWrite(_: Notification) { + continuation.yield(.persistentStoreWrite) } deinit { - center.removeObserver(observer) + center.removeObserver(self) continuation.finish() } } #if DEBUG - /// Hand-driven `StoreRemoteChangeSource` for tests: `yield()` simulates a - /// remote import landing, so the store-observes-remote-change path can be - /// driven deterministically without CloudKit or a device. + /// Hand-driven `StoreRemoteChangeSource` for tests: `yield()` sends an + /// explicitly external event, so the post-classification path can be driven + /// deterministically without CloudKit or a device. /// /// `@_spi(Testing)` + `#if DEBUG` per the agents.md testing-hook convention: /// it's test-only scaffolding that mustn't ship in release. Import it with @@ -83,19 +87,19 @@ final class PersistentStoreRemoteChangeSource: StoreRemoteChangeSource, @uncheck public final class ScriptedStoreRemoteChangeSource: StoreRemoteChangeSource, @unchecked Sendable { - let remoteChanges: AsyncStream - private let continuation: AsyncStream.Continuation + let remoteChanges: AsyncStream + private let continuation: AsyncStream.Continuation init() { - var cont: AsyncStream.Continuation! + var cont: AsyncStream.Continuation! remoteChanges = AsyncStream { cont = $0 } continuation = cont } - /// Simulate a remote import: a store observing this source re-pings its - /// `changes()` fan-out. Named for the `continuation.yield()` it makes. + /// Simulate a change already known to be external. Named for the + /// continuation operation it performs. func yield() { - continuation.yield() + continuation.yield(.external) } func finish() { diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 8842956f..733118a6 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 @@ -142,10 +144,9 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { case .localOnly, .cloudKit: .identifier(appGroupIdentifier) } // CloudKit mode backs the container with `NSPersistentCloudKitContainer`, - // which enables persistent-history tracking and posts - // `.NSPersistentStoreRemoteChange` on remote import — no extra knobs - // needed (and SwiftData exposes none). `make` observes that notification - // via `PersistentStoreRemoteChangeSource`. + // which enables persistent-history tracking. SwiftData's Core Data store + // posts `.NSPersistentStoreRemoteChange` for every write; `make` observes + // it and uses the history author to identify imports. let config = ModelConfiguration( schema: schema, isStoredInMemoryOnly: storage == .inMemory, @@ -204,14 +205,20 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let store = SwiftDataStore(modelContainer: container) // On-disk stores live in a shared App Group container, so another process // (the share extension) — or, for CloudKit, a sync from another device — - // can commit behind our back. Both surface as - // `.NSPersistentStoreRemoteChange` (persistent-history tracking is on for - // on-disk stores); forward those into `changes()` so an external write - // refreshes the UI like a local commit. This is what makes a - // share-extension add show up live in the running app (debug included), - // not just on next launch. + // can commit behind our back. Core Data posts + // `.NSPersistentStoreRemoteChange` for those *and* this process's own + // writes, so checkpoint history before installing the observer and + // classify later transactions by their author. This is what makes a + // share-extension add show up live without making every local GPS save + // look external. if storage.observesRemoteChanges { - store.startObservingRemoteChanges(PersistentStoreRemoteChangeSource()) + let classifier = store.historyClassifier + let checkpoint = Self.historyCheckpoint(for: classifier) + store.startObservingRemoteChanges( + PersistentStoreRemoteChangeSource(), + classifier: classifier, + checkpoint: checkpoint, + ) } return store } @@ -230,7 +237,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { ) throws -> SwiftDataStore { let container = try makeContainer(storage: .inMemory) let store = SwiftDataStore(modelContainer: container) - store.startObservingRemoteChanges(remoteChangeSource) + let classifier = store.historyClassifier + store.startObservingRemoteChanges( + remoteChangeSource, + classifier: classifier, + checkpoint: Self.historyCheckpoint(for: classifier), + ) return store } @@ -261,10 +273,24 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } private static let logger = WhereLog.root(SwiftDataStoreLog.self) + /// Unique to this store instance. Every context created by `perform` stamps + /// it into SwiftData history, so a system write notification can be proven + /// local instead of inferred from the notification's misleading name. + private nonisolated let localTransactionAuthor = "where-\(UUID().uuidString)" + + private nonisolated var historyClassifier: StoreHistoryClassifier { + StoreHistoryClassifier( + container: modelContainer, + localAuthor: localTransactionAuthor, + ) + } /// Fans "committed data changed" pings to `changes()` subscribers. Fired /// once per outermost `perform` commit (see `perform`). private let changeBroadcaster = StoreChangeBroadcaster() + /// The remote-import subset of `changeBroadcaster`, used by expensive + /// side-effect publishers whose local-write paths already invoke them. + private let remoteChangeBroadcaster = StoreChangeBroadcaster() /// A fresh stream that pings whenever committed data changes (see the /// `WhereStore` contract). `nonisolated` so a subscriber needn't hop onto @@ -274,18 +300,21 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { changeBroadcaster.subscribe() } - /// Forwards a `StoreRemoteChangeSource`'s remote-import events into the same - /// `changes()` fan-out a local commit pings. `nonisolated(unsafe)` for the - /// same reason as the scanner's: assigned once during setup, cancelled in - /// `deinit`, never accessed concurrently. The task captures only the - /// `Sendable` broadcaster + source (no `self`), so there's no retain cycle. + public nonisolated func remoteChanges() -> AsyncStream { + remoteChangeBroadcaster.subscribe() + } + + /// Classifies persistent-store write notifications and forwards only + /// external transactions to the external side-effect fan-out. + /// `nonisolated(unsafe)` because it is assigned once during factory setup, + /// cancelled in `deinit`, and never otherwise accessed concurrently. private nonisolated(unsafe) var remoteChangeTask: Task? - /// Begin re-pinging `changes()` on every remote import from `source`, so a - /// CloudKit sync from another device refreshes observers identically to a - /// local write — one read path for every write origin. `nonisolated` so the - /// factories can wire it without hopping onto the actor. The forwarding task - /// retains `source`, so the caller needn't. + /// Begin classifying store-write events. The initial classification closes + /// the checkpoint-to-observer race: the checkpoint is taken first, the + /// source begins observing second, and this catch-up fetch sees any write + /// that landed in between. A queued notification for that same write then + /// finds no newer transaction and is harmless. /// /// `private` and wired exactly once per store from a factory — `make` /// (any on-disk store) or `inMemory(remoteChangeSource:)` (tests) — so @@ -293,16 +322,80 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// unsynchronized, `nonisolated(unsafe)` `remoteChangeTask` sound without a /// re-arm/cancel dance: it's assigned once before the store is shared and /// only read again in `deinit`. - private nonisolated func startObservingRemoteChanges(_ source: any StoreRemoteChangeSource) { - remoteChangeTask = Task { [changeBroadcaster] in - for await _ in source.remoteChanges { - changeBroadcaster.send() + private nonisolated func startObservingRemoteChanges( + _ source: any StoreRemoteChangeSource, + classifier: StoreHistoryClassifier, + checkpoint: DefaultHistoryToken?, + ) { + remoteChangeTask = Task { [changeBroadcaster, remoteChangeBroadcaster] in + var token = Self.forwardStoreChange( + .persistentStoreWrite, + classifier: classifier, + after: checkpoint, + changeBroadcaster: changeBroadcaster, + remoteChangeBroadcaster: remoteChangeBroadcaster, + ) + for await event in source.remoteChanges { + guard Task.isCancelled == false else { return } + token = Self.forwardStoreChange( + event, + classifier: classifier, + after: token, + changeBroadcaster: changeBroadcaster, + remoteChangeBroadcaster: remoteChangeBroadcaster, + ) } } } + private static func historyCheckpoint( + for classifier: StoreHistoryClassifier, + ) -> DefaultHistoryToken? { + do { + return try classifier.checkpoint() + } catch { + logger { .historyReadFailed(description: String(describing: error)) } + return nil + } + } + + /// Returns the checkpoint to use for the next notification. + private static func forwardStoreChange( + _ event: StoreRemoteChangeEvent, + classifier: StoreHistoryClassifier, + after token: DefaultHistoryToken?, + changeBroadcaster: StoreChangeBroadcaster, + remoteChangeBroadcaster: StoreChangeBroadcaster, + ) -> DefaultHistoryToken? { + switch event { + case .external: + changeBroadcaster.send() + remoteChangeBroadcaster.send() + return token + case .persistentStoreWrite: + do { + let classification = try classifier.classify(after: token) + if classification.containsExternalTransaction { + changeBroadcaster.send() + remoteChangeBroadcaster.send() + } + return classification.latestToken + } catch { + // A failed history read can't prove the event local. Refresh + // conservatively so UI/surfaces remain honest, then try to + // recover at the newest token for the next notification. + logger { .historyReadFailed(description: String(describing: error)) } + changeBroadcaster.send() + remoteChangeBroadcaster.send() + return (try? classifier.checkpoint()) ?? token + } + } + } + deinit { remoteChangeTask?.cancel() + changeBroadcaster.finishAll() + remoteChangeBroadcaster.finishAll() } /// Peer `ModelContext` active for the duration of an outermost @@ -368,6 +461,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // actor reentrancy. await beginExclusive() let peer = ModelContext(modelContainer) + peer.author = localTransactionAuthor writerContext = peer defer { writerContext = nil diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index 388039d9..2c93e4fd 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -40,6 +40,15 @@ public protocol WhereStore: Sendable { /// import, so a consumer that re-derives on each ping can't go stale. func changes() -> AsyncStream + /// A fresh stream containing only changes imported from outside this + /// process (CloudKit or another App Group process). + /// + /// This is a narrow side-effect trigger, not a second read path: + /// `changes()` remains the signal for readers, while expensive publishers + /// use this stream when local writes already invoke them directly and + /// putting every hot GPS commit through a full rebuild would duplicate work. + func remoteChanges() -> AsyncStream + func add(sample: LocationSample) async throws func samples(in interval: DateInterval) async throws -> [LocationSample] func allSamples() async throws -> [LocationSample] @@ -142,6 +151,11 @@ public protocol WhereStore: Sendable { } extension WhereStore { + /// Stores without an external writer return an already-finished stream. + public func remoteChanges() -> AsyncStream { + AsyncStream { $0.finish() } + } + /// Regions tracked out of the box, until the user chooses their own. The /// "no rows yet" fallback for ``trackedRegions()`` and the historical /// California / New York / Canada / European Union set. diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index ddaf769b..03e6a495 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -36,6 +36,17 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.wantsTracking.rawValue) } } + /// Resolve the local recording intent before this installation has written + /// one. Existing onboarded installations retain the historical `true` + /// default, while a genuinely new installation can adopt a platform policy + /// such as iPad's opt-in default. + public func wantsTracking(defaultForNewInstallation defaultValue: Bool) -> Bool { + if let stored = store.object(forKey: Keys.wantsTracking.rawValue) as? Bool { + return stored + } + return hasOnboarded ? true : defaultValue + } + /// Whether the daily "log before the day ends" reminder is enabled. Defaults /// to `true` so the safety net is active out of the box. public var remindersEnabled: Bool { diff --git a/Where/WhereCore/Sources/WhereServices+Intents.swift b/Where/WhereCore/Sources/WhereServices+Intents.swift index 1ae86a55..236bcdc0 100644 --- a/Where/WhereCore/Sources/WhereServices+Intents.swift +++ b/Where/WhereCore/Sources/WhereServices+Intents.swift @@ -3,9 +3,10 @@ import Foundation extension WhereServices { /// Assemble the App Intents stack (Siri, Spotlight, Shortcuts — executing /// in the app's own process) over the **same store, live attribution, - /// aggregation calendar, and clock `base` already holds** — only the - /// location source differs (``IdleLocationSource``, so resolving an - /// intent never starts GPS). + /// aggregation calendar, and clock `base` already holds**. Its location + /// source is ``IdleLocationSource`` and its recording participation is + /// management-only, so resolving an intent never starts GPS or registers a + /// second local device. /// /// This is the *only* way an intents stack is built, and it is /// deliberately synchronous and non-throwing: deriving from an assembled @@ -21,18 +22,21 @@ extension WhereServices { /// The notification and widget seams come from `base` for the same reason /// the attributor does: a stack derived from the demo world is built out of /// no-ops, and minting real ones here would let a demo intent post a real - /// notification or reload the user's widgets. + /// notification or reload the user's widgets. The derived stack does not + /// start another external-change observer; `base` owns the single observer + /// that republishes for their shared store and refresher. public static func forIntents(sharingStoreOf base: WhereServices) -> WhereServices { WhereServices( store: base.store, locationSource: IdleLocationSource(), - currentDevice: base.currentDevice, + recordingParticipation: .managementOnly, attributor: base.attributor, aggregator: base.aggregator, reminderScheduler: base.reminderScheduler, summaryScheduler: base.summaryScheduler, issueAlertScheduler: base.issueAlertScheduler, widgetRefresher: base.widgetRefresher, + sharedWidgetPublisher: base.widgets, now: base.now, ) } @@ -50,7 +54,7 @@ extension WhereServices { try await make( store: store, locationSource: IdleLocationSource(), - currentDevice: .preview, + recordingParticipation: .managementOnly, reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), issueAlertScheduler: NoopDataIssueAlertScheduler(), diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index f125e84b..c80ef456 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -69,9 +69,10 @@ public struct WhereServices: Sendable { /// The clock the stack was built with, retained so a derived stack can't /// diverge from an injected test/preview clock. let now: @Sendable () -> Date - /// Installation identity used to stamp automatic samples. Retained so a - /// derived App Intents stack preserves the same composition value. - let currentDevice: CurrentRecordingDevice + /// Whether this process contributes automatic locations locally. Retained + /// so derived stacks preserve the same capability and new-install policy + /// without re-detecting the host platform. + let recordingParticipation: RecordingParticipation /// The live SwiftData container when the backing store is the production /// `SwiftDataStore`; `nil` for non-SwiftData stores (e.g. test fakes). /// Surfaced only for read-only debug tooling (the SwiftData inspector) so @@ -94,13 +95,17 @@ public struct WhereServices: Sendable { public init( store: any WhereStore, locationSource: any LocationSource, - currentDevice: CurrentRecordingDevice = .preview, + recordingParticipation: RecordingParticipation = .recording( + device: .preview, + defaultEnabledForNewInstallation: true, + ), attributor: any RegionAttributing = RegionAttributor.shared, aggregator: DayAggregator = DayAggregator(), reminderScheduler: any LoggingReminderScheduling = NoopLoggingReminderScheduler(), summaryScheduler: any DailySummaryScheduling = NoopDailySummaryScheduler(), issueAlertScheduler: any DataIssueAlertScheduling = NoopDataIssueAlertScheduler(), widgetRefresher: any WidgetTimelineRefreshing = NoopWidgetTimelineRefresher(), + sharedWidgetPublisher: WidgetSnapshotPublisher? = nil, locationOutbox: any LocationOutbox = NoOpLocationOutbox(), activitySummaryGenerator: any ActivitySummaryGenerating = FoundationModelSummaryGenerator(), now: @escaping @Sendable () -> Date = { Date() }, @@ -139,16 +144,16 @@ public struct WhereServices: Sendable { calendar: aggregator.calendar, now: now, ) - // The reader runs in *this* (app) process and shares the store, calendar, - // and attributor so the published snapshot's day/year line up with - // everything else reported. - let widgetReader = WidgetDataReader( - store: store, - aggregator: aggregator, - attributor: attributor, - ) - let widgets = WidgetSnapshotPublisher( - widgetReader: widgetReader, + // A derived App Intents stack shares the base publisher as well as its + // store. That keeps the publisher's hot-path cache coherent when an + // intent writes immediately before the base ingestor handles a sample. + // Independent test/preview stacks build their own publisher. + let widgets = sharedWidgetPublisher ?? WidgetSnapshotPublisher( + widgetReader: WidgetDataReader( + store: store, + aggregator: aggregator, + attributor: attributor, + ), widgetRefresher: widgetRefresher, attributor: attributor, calendar: aggregator.calendar, @@ -162,7 +167,7 @@ public struct WhereServices: Sendable { let ingestor = LocationIngestor( store: store, locationSource: locationSource, - recordingDeviceID: currentDevice.id, + recordingDeviceID: recordingParticipation.currentDevice?.id, calendar: aggregator.calendar, outbox: locationOutbox, onPersisted: { outcome in @@ -190,7 +195,7 @@ public struct WhereServices: Sendable { let recording = DeviceRecordingController( store: store, ingestor: ingestor, - currentDevice: currentDevice, + participation: recordingParticipation, now: now, ) let journal = DayJournal( @@ -237,7 +242,7 @@ public struct WhereServices: Sendable { self.issueAlertScheduler = issueAlertScheduler self.widgetRefresher = widgetRefresher self.now = now - self.currentDevice = currentDevice + self.recordingParticipation = recordingParticipation modelContainer = (store as? SwiftDataStore)?.inspectorContainer } @@ -254,7 +259,7 @@ public struct WhereServices: Sendable { public static func make( store: any WhereStore, locationSource: any LocationSource, - currentDevice: CurrentRecordingDevice, + recordingParticipation: RecordingParticipation, aggregator: DayAggregator = DayAggregator(), reminderScheduler: any LoggingReminderScheduling, summaryScheduler: any DailySummaryScheduling, @@ -272,10 +277,10 @@ public struct WhereServices: Sendable { initial: RegionAttributor(for: Region.inCanonicalOrder(tracked)), trackedIDs: Set(tracked.map(\.rawValue)), ) - return WhereServices( + let services = WhereServices( store: store, locationSource: locationSource, - currentDevice: currentDevice, + recordingParticipation: recordingParticipation, attributor: attribution, aggregator: aggregator, reminderScheduler: reminderScheduler, @@ -286,6 +291,12 @@ 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()) + return services } /// A fresh stream that fires whenever persisted data changes — local commits diff --git a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift index e333b02c..9af71d3f 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift @@ -1,9 +1,10 @@ import Foundation import RegionKit +import WhereSurface /// Everything the Where widgets render, captured as one `Sendable` value: /// which regions the snapshot's day already counts for, plus the per-region -/// day totals for the calendar year containing that day. +/// day totals from January 1 through that day. /// /// `Codable` because the app process publishes this (after each committed /// store write) to a small JSON file in the shared App Group container, @@ -12,12 +13,12 @@ import RegionKit public struct WidgetSnapshot: Hashable, Sendable, Codable { /// Start-of-day (in the reader's calendar) this snapshot describes. public let day: Date - /// The calendar year containing `day`; the year `totals` covers. + /// The calendar year containing `day`; the year `totals` belongs to. public let year: Int /// Regions `day` counts for so far. Empty when nothing is logged yet. public let dayRegions: Set - /// Day counts per region for `year` (a `YearReport.totals`). A day in - /// two regions counts once for each. + /// Day counts per region from January 1 of `year` through `day`, inclusive. + /// A day in two regions counts once for each. public let totals: [Region: Int] /// The user's picked appearances for their primary regions, carried across /// the App Group so the widget process can render each region's chosen @@ -26,23 +27,33 @@ public struct WidgetSnapshot: Hashable, Sendable, Codable { /// hasn't customized (and for snapshots written before this field existed) — /// those fall back to the default look. public let appearances: [Region: RegionAppearance] + /// When the app generated this artifact. Optional only so an artifact + /// published by an older app version still decodes. + public let generatedAt: Date? + /// Presentation-ready data for store-free glance processes. Optional only + /// for compatibility with artifacts published before WhereSurface existed. + public let surface: WhereSurfaceSnapshot? public init( day: Date, year: Int, dayRegions: Set, totals: [Region: Int], - appearances: [Region: RegionAppearance] = [:], + appearances: [Region: RegionAppearance], + generatedAt: Date?, + surface: WhereSurfaceSnapshot?, ) { self.day = day self.year = year self.dayRegions = dayRegions self.totals = totals self.appearances = appearances + self.generatedAt = generatedAt + self.surface = surface } private enum CodingKeys: String, CodingKey { - case day, year, dayRegions, totals, appearances + case day, year, dayRegions, totals, appearances, generatedAt, surface } public init(from decoder: any Decoder) throws { @@ -55,6 +66,8 @@ public struct WidgetSnapshot: Hashable, Sendable, Codable { // empty map rather than failing (the widget then uses default looks). appearances = try container .decodeIfPresent([Region: RegionAppearance].self, forKey: .appearances) ?? [:] + generatedAt = try container.decodeIfPresent(Date.self, forKey: .generatedAt) + surface = try container.decodeIfPresent(WhereSurfaceSnapshot.self, forKey: .surface) } } @@ -103,16 +116,51 @@ public struct WidgetDataReader: Sendable { let dayRegions = report.days .first { $0.day == calendarDay }? .regions ?? [] + var totalsToDate: [Region: Int] = [:] + for day in report.days where day.day <= calendarDay { + for region in day.regions { + totalsToDate[region, default: 0] += 1 + } + } var appearances: [Region: RegionAppearance] = [:] for primary in try await store.primaryRegions() { if let appearance = primary.appearance { appearances[primary.region] = appearance } } + let surfaceRegion: (Region) -> WhereSurfaceSnapshot.Region = { region in + WhereSurfaceSnapshot.Region( + id: region.rawValue, + name: region.localizedName, + emoji: appearances[region]?.emoji, + symbolName: appearances[region]?.symbolName, + ) + } + let todayRegions = Region.inCanonicalOrder(dayRegions).map(surfaceRegion) + let yearToDate = Region.rankedByDayCount( + totalsToDate, + days: { $0.value }, + region: { $0.key }, + ) + .prefix(3) + .map { total in + WhereSurfaceSnapshot.DayCount( + region: surfaceRegion(total.key), + days: total.value, + ) + } + let surface = WhereSurfaceSnapshot( + day: startOfDay, + todayRegions: todayRegions, + year: year, + yearToDate: Array(yearToDate), + ) return WidgetSnapshot( day: startOfDay, year: year, dayRegions: dayRegions, - totals: report.totals, + totals: totalsToDate, appearances: appearances, + generatedAt: date, + surface: surface, ) } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift index 2866ec21..96a53621 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,86 @@ 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. Local writes do not enter this stream: their journal/ingestor + /// paths already invoke the exact publish operation they need. + func startObservingExternalChanges(_ changes: AsyncStream) { + precondition( + externalChangesTask == nil, + "WidgetSnapshotPublisher external observation started twice", + ) + externalChangesTask = Task { [weak self] in + for await _ in changes { + 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() { + externalChangesTask?.cancel() + 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 +166,7 @@ public actor WidgetSnapshotPublisher { ) } } catch { + retryFailedPublish = true Self.logger { .buildFailed(description: error.localizedDescription) } } } @@ -102,7 +181,7 @@ public actor WidgetSnapshotPublisher { /// add to its own day; a region already present means the day's regions and /// the year totals are both unchanged.) func publishAfterIngest(of sample: LocationSample) async { - if let last = lastPublished { + if retryFailedPublish == false, let last = lastPublished { let day = calendar.startOfDay(for: sample.timestamp) let region = attributor.region(at: sample.coordinate) if day == last.snapshot.day, last.snapshot.dayRegions.contains(region) { diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift index 5c27d4bc..dee220d6 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift @@ -1,5 +1,6 @@ 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 @@ -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,7 +42,7 @@ 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 diff --git a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift index 6e854221..721b5cdc 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift @@ -1,4 +1,5 @@ import PeriscopeCore +import WhereSurface import WidgetKit /// Publishes a freshly-computed `WidgetSnapshot` for the widget extension @@ -9,7 +10,7 @@ import WidgetKit public protocol WidgetTimelineRefreshing: Sendable { /// Persist `snapshot` where the widget process can read it, then ask /// WidgetKit to rebuild every timeline. - func publish(_ snapshot: WidgetSnapshot) async + func publish(_ snapshot: WidgetSnapshot) async throws } /// A `WidgetTimelineRefreshing` that does nothing. For SwiftUI previews and @@ -18,7 +19,7 @@ public protocol WidgetTimelineRefreshing: Sendable { public struct NoopWidgetTimelineRefresher: WidgetTimelineRefreshing { public init() {} - public func publish(_: WidgetSnapshot) async {} + public func publish(_: WidgetSnapshot) async throws {} } /// Production `WidgetTimelineRefreshing`: writes the snapshot to the shared @@ -30,13 +31,15 @@ public struct WidgetCenterTimelineRefresher: WidgetTimelineRefreshing { public init() {} - public func publish(_ snapshot: WidgetSnapshot) async { + public func publish(_ snapshot: WidgetSnapshot) async throws { do { try WidgetSnapshotStore.shared().write(snapshot) Self.logger { .wroteSnapshot } } catch { Self.logger { .publishFailed(description: error.localizedDescription) } + throw error } + WhereSurfaceChangeNotification.post() WidgetCenter.shared.reloadAllTimelines() } } diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift index aad946b6..d50f04ab 100644 --- a/Where/WhereCore/Tests/DayJournalTests.swift +++ b/Where/WhereCore/Tests/DayJournalTests.swift @@ -17,7 +17,7 @@ struct DayJournalTests { private actor SpyRefresher: WidgetTimelineRefreshing { private(set) var publishCount = 0 - func publish(_: WidgetSnapshot) async { + func publish(_: WidgetSnapshot) async throws { publishCount += 1 } } diff --git a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift index 9eb7b98f..37fa2d20 100644 --- a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift +++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift @@ -12,7 +12,10 @@ struct DeviceRecordingControllerTests { let services = WhereServices( store: store, locationSource: ScriptedLocationSource(authorizationStatus: authorization), - currentDevice: .preview, + recordingParticipation: .recording( + device: .preview, + defaultEnabledForNewInstallation: true, + ), now: { now }, ) return (services, store) @@ -21,10 +24,10 @@ struct DeviceRecordingControllerTests { @Test func firstReconcileRegistersMigratedIntentAndAcknowledgesRecording() async throws { let (services, store) = try Self.makeServices(authorization: .always) - let configuration = try await services.recording.reconcile( + let configuration = try #require(try await services.recording.reconcile( initialEnabled: true, authorization: .always, - ) + )) #expect(configuration.id == CurrentRecordingDevice.preview.id) #expect(configuration.isEnabled) @@ -38,10 +41,10 @@ struct DeviceRecordingControllerTests { @Test func enabledWithoutAlwaysPermissionIsAcknowledgedAsPermissionRequired() async throws { let (services, _) = try Self.makeServices(authorization: .whenInUse) - let configuration = try await services.recording.reconcile( + let configuration = try #require(try await services.recording.reconcile( initialEnabled: true, authorization: .whenInUse, - ) + )) #expect(configuration.isEnabled) #expect(configuration.isPending == false) @@ -205,4 +208,66 @@ struct DeviceRecordingControllerTests { #expect(try await store.recordingDevices().isEmpty) #expect(try await store.recordingPolicyChanges().isEmpty) } + + @Test func managementOnlyReconcileNeverRegistersOrStartsLocalRecording() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: .always) + let services = WhereServices( + store: store, + locationSource: source, + recordingParticipation: .managementOnly, + now: { Self.now }, + ) + + let configuration = try await services.recording.reconcile( + initialEnabled: true, + authorization: .always, + ) + await services.ingestor.start() + await services.ingestor.captureTodayIfNeeded(now: Self.now) + + #expect(configuration == nil) + #expect(await services.ingestor.isActive == false) + #expect(try await store.recordingDevices().isEmpty) + #expect(try await store.recordingPolicyChanges().isEmpty) + #expect(try await store.allSamples().isEmpty) + } + + @Test func managementOnlyControllerCanManageASyncedRemoteDevice() async throws { + let store = try SwiftDataStore.inMemory() + let services = WhereServices( + store: store, + locationSource: IdleLocationSource(), + recordingParticipation: .managementOnly, + now: { Self.now }, + ) + let remoteID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")), + ) + try await store.perform { + try await store.setRecordingDevice(RecordingDevice( + id: remoteID, + systemName: "iPad", + nickname: nil, + kind: .tablet, + registeredAt: Self.now, + lastSeenAt: Self.now, + archivedAt: nil, + lastAppliedPolicyChangeID: nil, + status: .off, + )) + } + + let before = try await services.recording.devices(initialEnabled: false) + let after = try await services.recording.setEnabled( + true, + for: remoteID, + initialEnabled: false, + ) + + #expect(before.map(\.id) == [remoteID]) + #expect(after.map(\.id) == [remoteID]) + #expect(after.first?.isEnabled == true) + #expect(try await store.recordingDevices().count == 1) + } } diff --git a/Where/WhereCore/Tests/RecordingParticipationTests.swift b/Where/WhereCore/Tests/RecordingParticipationTests.swift new file mode 100644 index 00000000..01b510ff --- /dev/null +++ b/Where/WhereCore/Tests/RecordingParticipationTests.swift @@ -0,0 +1,23 @@ +import Testing +@testable import WhereCore + +struct RecordingParticipationTests { + @Test func recordingCarriesItsDeviceAndNewInstallationDefault() { + let participation = RecordingParticipation.recording( + device: .preview, + defaultEnabledForNewInstallation: false, + ) + + #expect(participation.currentDevice == .preview) + #expect(participation.defaultEnabledForNewInstallation == false) + #expect(participation.supportsLocalRecording) + } + + @Test func managementOnlyHasNoLocalRecordingCapability() { + let participation = RecordingParticipation.managementOnly + + #expect(participation.currentDevice == nil) + #expect(participation.defaultEnabledForNewInstallation == false) + #expect(participation.supportsLocalRecording == false) + } +} diff --git a/Where/WhereCore/Tests/StoreHistoryClassifierTests.swift b/Where/WhereCore/Tests/StoreHistoryClassifierTests.swift new file mode 100644 index 00000000..8a358331 --- /dev/null +++ b/Where/WhereCore/Tests/StoreHistoryClassifierTests.swift @@ -0,0 +1,36 @@ +import SwiftData +import Testing +@testable import WhereCore + +/// Transaction-author filtering behind the external-only store signal. +struct StoreHistoryClassifierTests { + @Test func excludesLocalAuthorAndIncludesAnotherAuthor() throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let localAuthor = "where-tests-local" + let classifier = StoreHistoryClassifier( + container: container, + localAuthor: localAuthor, + ) + let initialToken = try classifier.checkpoint() + + let localContext = ModelContext(container) + localContext.author = localAuthor + let localDay = SDManualDay() + localDay.dayKey = "2026-03-15" + localContext.insert(localDay) + try localContext.save() + + let local = try classifier.classify(after: initialToken) + #expect(local.containsExternalTransaction == false) + + let otherContext = ModelContext(container) + otherContext.author = "where-tests-other-process" + let otherDay = SDManualDay() + otherDay.dayKey = "2026-03-16" + otherContext.insert(otherDay) + try otherContext.save() + + let external = try classifier.classify(after: local.latestToken) + #expect(external.containsExternalTransaction) + } +} diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 5fd87a8f..60ec3087 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -132,21 +132,22 @@ struct SwiftDataStoreTests { #expect(try await store.recordingPolicyChanges() == [policy]) } - /// A remote import (simulated via a scripted source) re-pings the same - /// `changes()` fan-out a local commit does, so observers can't tell a sync - /// from another device apart from a local write — one read path. - @Test func remoteChangeForwardsToChanges() async throws { + /// A remote import (simulated via a scripted source) pings both the general + /// read-refresh stream and the remote-only side-effect stream. + @Test func remoteChangeForwardsToBothChangeStreams() async throws { let source = ScriptedStoreRemoteChangeSource() // The remote-change wiring is folded into the factory (there's no // public `startObservingRemoteChanges` to call), so the store observes // `source` from construction. let store = try SwiftDataStore.inMemory(remoteChangeSource: source) // Subscribe before emitting so the forwarded ping isn't missed. - let stream = store.changes() + let changes = store.changes() + let remoteChanges = store.remoteChanges() source.yield() - #expect(await firstPing(stream, within: .seconds(2))) + #expect(await firstPing(changes, within: .seconds(2))) + #expect(await firstPing(remoteChanges, within: .seconds(2))) } /// Once `perform`'s `peer.save()` returns, the committed write must be diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift new file mode 100644 index 00000000..98508203 --- /dev/null +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import WhereCore + +struct WherePreferencesTests { + @Test func newInstallationUsesTheSuppliedRecordingDefault() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + + #expect(preferences.wantsTracking(defaultForNewInstallation: false) == false) + #expect(preferences.wantsTracking(defaultForNewInstallation: true)) + } + + @Test func onboardedInstallationWithoutAnExplicitValueKeepsLegacyRecordingOn() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + preferences.hasOnboarded = true + + #expect(preferences.wantsTracking(defaultForNewInstallation: false)) + } + + @Test func explicitRecordingIntentWinsOverPlatformAndMigrationDefaults() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + preferences.hasOnboarded = true + preferences.wantsTracking = false + + #expect(preferences.wantsTracking(defaultForNewInstallation: true) == false) + #expect(preferences.wantsTracking(defaultForNewInstallation: false) == false) + } +} diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index 6263ccea..ba7ff0ee 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -1,7 +1,7 @@ import Foundation import RegionKit import Testing -@_spi(Testing) import WhereCore +@_spi(Testing) @testable import WhereCore /// Integration coverage for the assembled `WhereServices`: the cross-collaborator /// wiring that no single focused suite owns — the ingestor's post-persist hook @@ -57,7 +57,10 @@ struct WhereServicesTests { let services = try await WhereServices.make( store: store, locationSource: ScriptedLocationSource(), - currentDevice: .preview, + recordingParticipation: .recording( + device: .preview, + defaultEnabledForNewInstallation: true, + ), aggregator: Self.makeAggregator(), reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), @@ -1131,6 +1134,77 @@ struct WhereServicesTests { #expect(snapshot?.totals == [.california: 1]) } + @Test func remoteImportPublishesOnceFromTheBaseService() async throws { + let remoteSource = ScriptedStoreRemoteChangeSource() + let store = try SwiftDataStore.inMemory(remoteChangeSource: remoteSource) + let now = WhereCoreTestSupport.iso("2026-03-15T20:00:00-07:00") + try await store.perform { + try await store.setManualDay(DayPresence( + date: now, + in: Self.makeAggregator().calendar, + regions: [.california], + )) + } + let refresher = SpyWidgetRefresher() + let services = try await WhereServices.make( + store: store, + locationSource: ScriptedLocationSource(), + recordingParticipation: .recording( + device: .preview, + defaultEnabledForNewInstallation: true, + ), + aggregator: Self.makeAggregator(), + reminderScheduler: NoopLoggingReminderScheduler(), + summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: NoopDataIssueAlertScheduler(), + widgetRefresher: refresher, + now: { now }, + ) + let intents = WhereServices.forIntents(sharingStoreOf: services) + + remoteSource.yield() + + try await waitUntil { await refresher.publishCount == 1 } + #expect(await refresher.lastSnapshot?.dayRegions == [.california]) + // Keep the derived stack alive through the assertion: if it had started + // a duplicate remote observer, the shared refresher would see two + // publishes for the one import. + withExtendedLifetime(intents) {} + #expect(await refresher.publishCount == 1) + } + + @Test func intentWriteKeepsBaseWidgetIngestCacheCoherent() async throws { + let now = WhereCoreTestSupport.iso("2026-03-15T20:00:00-07:00") + let refresher = SpyWidgetRefresher() + let (services, _) = try Self.makeWidgetServices( + refresher: refresher, + now: now, + ) + let intents = WhereServices.forIntents(sharingStoreOf: services) + + // Seed the base publisher's fast-path cache with a manual California + // day, then replace that overlay through the intent-derived stack. + try await services.journal.addManualDay( + date: now, + regions: [.california], + audit: nil, + ) + try await intents.journal.addManualDay( + date: now, + regions: [.newYork], + audit: nil, + ) + #expect(await refresher.lastSnapshot?.dayRegions == [.newYork]) + + // A subsequent live California sample must rebuild to include both + // sources. With separate publisher caches, the base still remembered + // California from before the intent write and incorrectly skipped. + try await services.journal.ingest(sample(at: "2026-03-15T21:00:00-07:00")) + + #expect(await refresher.publishCount == 3) + #expect(await refresher.lastSnapshot?.dayRegions == [.california, .newYork]) + } + @Test func gpsIngestPublishesWidgetSnapshot() async throws { let refresher = SpyWidgetRefresher() let (services, source) = try Self.makeWidgetServices(refresher: refresher) @@ -1337,7 +1411,7 @@ private actor SpyWidgetRefresher: WidgetTimelineRefreshing { publishedSnapshots.last } - func publish(_ snapshot: WidgetSnapshot) async { + func publish(_ snapshot: WidgetSnapshot) async throws { publishedSnapshots.append(snapshot) } } diff --git a/Where/WhereCore/Tests/WidgetDataReaderTests.swift b/Where/WhereCore/Tests/WidgetDataReaderTests.swift index b27ff46b..653f2c41 100644 --- a/Where/WhereCore/Tests/WidgetDataReaderTests.swift +++ b/Where/WhereCore/Tests/WidgetDataReaderTests.swift @@ -29,14 +29,17 @@ struct WidgetDataReaderTests { @Test func emptyStoreYieldsEmptySnapshot() async throws { let (reader, _) = try Self.makeReader() + let asOf = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00") - let snapshot = try await reader - .snapshot(asOf: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")) + let snapshot = try await reader.snapshot(asOf: asOf) #expect(snapshot.year == 2026) #expect(snapshot.dayRegions.isEmpty) #expect(snapshot.totals.isEmpty) #expect(snapshot.appearances.isEmpty) + #expect(snapshot.generatedAt == asOf) + #expect(snapshot.surface?.todayRegions.isEmpty == true) + #expect(snapshot.surface?.yearToDate.isEmpty == true) } @Test func snapshotCarriesPickedRegionAppearances() async throws { @@ -48,12 +51,19 @@ struct WidgetDataReaderTests { // A tracked region with no picked look contributes no appearance. PrimaryRegion(region: .newYork, appearance: nil, order: 1), ]) + try await store.setManualDay(DayPresence( + date: WhereCoreTestSupport.iso("2026-03-15T00:00:00-07:00"), + in: WhereCoreTestSupport.calendar(), + regions: [.california], + )) } let snapshot = try await reader .snapshot(asOf: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")) #expect(snapshot.appearances == [.california: caLook]) + #expect(snapshot.surface?.todayRegions.first?.emoji == "🌴") + #expect(snapshot.surface?.todayRegions.first?.symbolName == "sun.max.fill") } @Test func snapshotAppearancesSurviveCodableRoundTrip() throws { @@ -64,6 +74,8 @@ struct WidgetDataReaderTests { dayRegions: [.newYork], totals: [.newYork: 3], appearances: [.newYork: look], + generatedAt: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00"), + surface: nil, ) let data = try JSONEncoder().encode(snapshot) let decoded = try JSONDecoder().decode(WidgetSnapshot.self, from: data) @@ -71,7 +83,26 @@ struct WidgetDataReaderTests { #expect(decoded.appearances == [.newYork: look]) } - @Test func samplesAndManualDaysRollUpLikeTheYearReport() async throws { + @Test func oldSnapshotDecodesWithoutSurfaceFields() throws { + let data = Data( + """ + { + "day": 0, + "year": 2026, + "dayRegions": ["us-CA"], + "totals": ["us-CA", 1] + } + """.utf8, + ) + + let snapshot = try JSONDecoder().decode(WidgetSnapshot.self, from: data) + + #expect(snapshot.generatedAt == nil) + #expect(snapshot.surface == nil) + #expect(snapshot.appearances.isEmpty) + } + + @Test func totalsIncludeTheSnapshotDayButExcludeFutureDays() async throws { let (reader, store) = try Self.makeReader() try await store.perform { // Two same-day samples in CA, one in NY the next day. @@ -90,7 +121,7 @@ struct WidgetDataReaderTests { latitude: 40.7128, longitude: -74.0060, )) - // A manual backfill for a third day. + // A future manual entry must not leak into a year-to-date count. try await store.setManualDay(DayPresence( date: WhereCoreTestSupport.iso("2026-05-01T00:00:00-07:00"), in: WhereCoreTestSupport.calendar(), @@ -103,7 +134,11 @@ struct WidgetDataReaderTests { #expect(snapshot.year == 2026) #expect(snapshot.dayRegions == [.california]) - #expect(snapshot.totals == [.california: 1, .newYork: 1, .canada: 1]) + #expect(snapshot.totals == [.california: 1]) + let surface = try #require(snapshot.surface) + #expect(surface.todayRegions.map(\.id) == ["us-CA"]) + #expect(surface.yearToDate.map(\.region.id) == ["us-CA"]) + #expect(surface.yearToDate.map(\.days) == [1]) } @Test func dayRegionsAreEmptyWhenTodayHasNoData() async throws { diff --git a/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift b/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift index e85019e9..6f3cacd9 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,55 @@ 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 struct WaitTimeout: Error {} + private static func makePublisher( now: @escaping @Sendable () -> Date, maxAge: TimeInterval = WidgetSnapshotPublisher.defaultMaxAge, @@ -43,11 +86,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 +196,113 @@ 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() + + await publisher.startObservingExternalChanges(changes.subscribe()) + changes.send() + + try await Self.waitUntil { await refresher.publishCount == 1 } + await publisher.stopObservingExternalChanges() + } + + @Test func concurrentRequestsCoalesceIntoOneFinalRebuild() async throws { + let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00") + let refresher = GatedRefresher() + let publisher = try Self.makePublisher(now: { now }, refresher: refresher) + + let first = Task { await publisher.publish() } + try await Self.waitUntil { await refresher.isFirstPublishSuspended } + + let joined = (0 ..< 5).map { _ in + Task { await publisher.publish() } + } + try await Self.waitUntil { + await publisher.testingReceivedPublishRequestCount == 6 + } + await refresher.resumeFirstPublish() + + await first.value + for task in joined { + await task.value + } + #expect(await refresher.publishCount == 2) + } } diff --git a/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift b/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift index 65a19bcc..931effc7 100644 --- a/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift +++ b/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift @@ -21,6 +21,9 @@ struct WidgetSnapshotStoreTests { year: 2026, dayRegions: dayRegions, totals: totals, + appearances: [:], + generatedAt: Date(timeIntervalSince1970: 1_700_000_100), + surface: nil, ) } diff --git a/Where/WhereIntents/Tests/WhereIntentReaderTests.swift b/Where/WhereIntents/Tests/WhereIntentReaderTests.swift index e0c4e45a..928ff8b5 100644 --- a/Where/WhereIntents/Tests/WhereIntentReaderTests.swift +++ b/Where/WhereIntents/Tests/WhereIntentReaderTests.swift @@ -73,6 +73,9 @@ struct WhereIntentReaderTests { year: 2026, dayRegions: [.canada], totals: [:], + appearances: [:], + generatedAt: today, + surface: nil, ) } #expect(try await reader.todayRegions() == [.canada]) @@ -104,6 +107,9 @@ struct WhereIntentReaderTests { year: 2026, dayRegions: [.canada], totals: [:], + appearances: [:], + generatedAt: today, + surface: nil, ) } #expect(try await reader.todayRegions() == [.newYork]) diff --git a/Where/WhereMenuBar/AGENTS.md b/Where/WhereMenuBar/AGENTS.md new file mode 100644 index 00000000..7dda85c4 --- /dev/null +++ b/Where/WhereMenuBar/AGENTS.md @@ -0,0 +1,29 @@ +# WhereMenuBar – Module Shape + +WhereMenuBar is the native macOS, icon-only menu bar helper for Where. See +[`README.md`](README.md). This file complements the root +[`AGENTS.md`](../../AGENTS.md), feature [`Where/AGENTS.md`](../AGENTS.md), and +the shared contract [`WhereSurface/AGENTS.md`](../WhereSurface/AGENTS.md). + +## Scope & dependencies + +- Depend on WhereSurface plus system AppKit/SwiftUI only; never import + WhereCore, RegionKit, SwiftData, CloudKit, WidgetKit, or location frameworks. +- Read the App Group artifact only. The helper never writes shared data or + launches the host automatically. +- Keep login-item registration in the Catalyst app; the helper only renders + and handles its explicit Open Where action. + +## Invariants + +- Keep the status item icon-only and give its button an accessibility label. +- Preserve the last good snapshot when refresh or decode fails, showing its + original relative age and a failure note. +- Pair Darwin observer registration with removal; treat delivery as advisory. +- Keep user-facing copy in this target's generated string catalog. + +## Testing + +Wire-format, file-read, and compatibility behavior lives in +[`WhereSurface/Tests`](../WhereSurface/Tests); payload construction and ranking +live in WhereCore tests. Keep this target a thin native host. diff --git a/Where/WhereMenuBar/README.md b/Where/WhereMenuBar/README.md new file mode 100644 index 00000000..698df304 --- /dev/null +++ b/Where/WhereMenuBar/README.md @@ -0,0 +1,25 @@ +# WhereMenuBar + +**WhereMenuBar** is Where's native macOS menu bar companion. It is an +`LSUIElement` helper embedded in the Mac Catalyst app and optionally registered +by the user as a login item. + +The status item is icon-only. Its popover shows the regions observed today, +the top three year-to-date day counts, the age of the last successful publish, +and an explicit **Open Where** button. It keeps stale content visible when a +later refresh fails and never launches the main app on its own. + +## Data boundary + +The helper depends only on [`WhereSurface`](../WhereSurface). It reads the +presentation-ready overlay in the App Group's `widget-snapshot.json` and +treats the Darwin change notification as an advisory refresh hint. It never +opens SwiftData, contacts CloudKit, requests location, or recomputes reports. + +## Packaging + +The Tuist target is a native macOS app with bundle identifier +`com.stuff.where.menubar`, sandbox + App Group entitlements, and +`LSUIElement = true`. The Catalyst app embeds it in +`Contents/Library/LoginItems` and owns the `SMAppService.loginItem` user +control. diff --git a/Where/WhereMenuBar/Resources/Localizable.xcstrings b/Where/WhereMenuBar/Resources/Localizable.xcstrings new file mode 100644 index 00000000..59fd6bd0 --- /dev/null +++ b/Where/WhereMenuBar/Resources/Localizable.xcstrings @@ -0,0 +1,186 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "menuBar.accessibilityLabel" : { + "comment" : "Accessibility label for the icon-only Where menu bar item.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where" + } + } + } + }, + "menuBar.day" : { + "comment" : "Singular unit shown after a year-to-date day count.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "day" + } + } + } + }, + "menuBar.days" : { + "comment" : "Plural unit shown after a year-to-date day count.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "days" + } + } + } + }, + "menuBar.openWhere" : { + "comment" : "Button that explicitly opens the main Where app.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Where" + } + } + } + }, + "menuBar.refreshFailed" : { + "comment" : "Shown while retaining stale data after a refresh fails.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn’t refresh. Showing the last update." + } + } + } + }, + "menuBar.today.empty" : { + "comment" : "Empty state for today's observed-region list.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No regions observed today." + } + } + } + }, + "menuBar.today.title" : { + "comment" : "Heading above today's observed regions.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + } + } + }, + "menuBar.unavailable.appGroup" : { + "comment" : "Explains that the helper cannot access Where's shared App Group.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where’s shared summary isn’t available. Open Where and try again." + } + } + } + }, + "menuBar.unavailable.description" : { + "comment" : "Explains how to create the first menu bar summary.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Where to publish today’s summary." + } + } + } + }, + "menuBar.unavailable.failureTitle" : { + "comment" : "Heading when the shared menu bar summary cannot be read.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Summary unavailable" + } + } + } + }, + "menuBar.unavailable.title" : { + "comment" : "Heading when the app has not published a menu bar summary.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No summary yet" + } + } + } + }, + "menuBar.unavailable.unreadable" : { + "comment" : "Explains that the existing shared summary could not be decoded.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The shared summary couldn’t be read. Open Where to publish it again." + } + } + } + }, + "menuBar.updated" : { + "comment" : "Label before the relative age of the published summary.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Updated" + } + } + } + }, + "menuBar.yearToDate.empty" : { + "comment" : "Empty state for the year-to-date day-count list.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No days recorded this year." + } + } + } + }, + "menuBar.yearToDate.title" : { + "comment" : "Heading above the top year-to-date region day counts.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Year to Date" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarApp.swift b/Where/WhereMenuBar/Sources/WhereMenuBarApp.swift new file mode 100644 index 00000000..d6eaadac --- /dev/null +++ b/Where/WhereMenuBar/Sources/WhereMenuBarApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct WhereMenuBarApp: App { + @NSApplicationDelegateAdaptor(WhereMenuBarAppDelegate.self) + private var appDelegate + + var body: some Scene { + Settings { + EmptyView() + } + } +} diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarAppDelegate.swift b/Where/WhereMenuBar/Sources/WhereMenuBarAppDelegate.swift new file mode 100644 index 00000000..01a03540 --- /dev/null +++ b/Where/WhereMenuBar/Sources/WhereMenuBarAppDelegate.swift @@ -0,0 +1,133 @@ +import AppKit +import CoreFoundation +import SwiftUI +import WhereSurface + +@MainActor +final class WhereMenuBarAppDelegate: NSObject, NSApplicationDelegate { + private let model: WhereMenuBarModel + private var popover: NSPopover? + private var statusItem: NSStatusItem? + + override init() { + do { + model = try WhereMenuBarModel(reader: WhereSurfaceStore.shared()) + } catch let error as WhereSurfaceStore.AppGroupUnavailableError { + model = WhereMenuBarModel(appGroupUnavailable: error) + } catch { + assertionFailure("Unexpected WhereSurfaceStore error: \(error)") + model = WhereMenuBarModel( + appGroupUnavailable: WhereSurfaceStore.AppGroupUnavailableError(), + ) + } + super.init() + } + + func applicationDidFinishLaunching(_: Notification) { + NSApp.setActivationPolicy(.accessory) + configurePopover() + configureStatusItem() + startObservingSurfaceChanges() + } + + func applicationWillTerminate(_: Notification) { + stopObservingSurfaceChanges() + } + + fileprivate func surfaceDidChange() { + model.refresh() + } + + private func configurePopover() { + let popover = NSPopover() + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: WhereMenuBarView(model: model), + ) + self.popover = popover + } + + private func configureStatusItem() { + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + guard let button = statusItem.button else { + assertionFailure("NSStatusItem did not vend a button") + self.statusItem = statusItem + return + } + + let accessibilityLabel = String(localized: .menuBarAccessibilityLabel) + if let image = NSImage( + systemSymbolName: "location.fill", + accessibilityDescription: accessibilityLabel, + ) { + image.isTemplate = true + button.image = image + button.imagePosition = .imageOnly + } else { + assertionFailure("The location.fill system symbol is unavailable") + button.title = "●" + button.imagePosition = .noImage + } + button.setAccessibilityLabel(accessibilityLabel) + button.target = self + button.action = #selector(togglePopover) + self.statusItem = statusItem + } + + @objc + private func togglePopover() { + guard + let button = statusItem?.button, + let popover + else { + return + } + + if popover.isShown { + popover.performClose(nil) + } else { + model.refresh() + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + NSApp.activate() + } + } + + private func startObservingSurfaceChanges() { + stopObservingSurfaceChanges() + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + Unmanaged.passUnretained(self).toOpaque(), + whereMenuBarSurfaceChanged, + WhereSurfaceChangeNotification.name as CFString, + nil, + .deliverImmediately, + ) + } + + private func stopObservingSurfaceChanges() { + CFNotificationCenterRemoveObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + Unmanaged.passUnretained(self).toOpaque(), + CFNotificationName( + rawValue: WhereSurfaceChangeNotification.name as CFString, + ), + nil, + ) + } +} + +private func whereMenuBarSurfaceChanged( + _: CFNotificationCenter?, + observer: UnsafeMutableRawPointer?, + _: CFNotificationName?, + _: UnsafeRawPointer?, + _: CFDictionary?, +) { + guard let observer else { return } + let delegate = Unmanaged + .fromOpaque(observer) + .takeUnretainedValue() + Task { @MainActor in + delegate.surfaceDidChange() + } +} diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarDayCountRow.swift b/Where/WhereMenuBar/Sources/WhereMenuBarDayCountRow.swift new file mode 100644 index 00000000..6653bccb --- /dev/null +++ b/Where/WhereMenuBar/Sources/WhereMenuBarDayCountRow.swift @@ -0,0 +1,17 @@ +import SwiftUI +import WhereSurface + +struct WhereMenuBarDayCountRow: View { + let dayCount: WhereSurfaceSnapshot.DayCount + + var body: some View { + HStack { + WhereMenuBarRegionRow(region: dayCount.region) + Spacer() + Text(dayCount.days, format: .number) + .monospacedDigit() + Text(dayCount.days == 1 ? .menuBarDay : .menuBarDays) + .foregroundStyle(.secondary) + } + } +} diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarModel.swift b/Where/WhereMenuBar/Sources/WhereMenuBarModel.swift new file mode 100644 index 00000000..ac8e243b --- /dev/null +++ b/Where/WhereMenuBar/Sources/WhereMenuBarModel.swift @@ -0,0 +1,81 @@ +import Foundation +import Observation +import WhereSurface + +/// Keeps the helper's last successfully decoded glance payload. +/// +/// A failed advisory refresh never replaces loaded content with an empty state; +/// the popover continues to show that snapshot with its original generation +/// date and makes the refresh failure visible. +@MainActor +@Observable +final class WhereMenuBarModel { + enum UnavailableReason: Equatable { + case notPublished + case unreadable + case appGroupUnavailable + } + + enum State: Equatable { + case unavailable(UnavailableReason) + case loaded( + generatedAt: Date, + snapshot: WhereSurfaceSnapshot, + refreshFailed: Bool, + ) + } + + private let reader: (any WhereSurfaceReading)? + private(set) var state: State + + init(reader: any WhereSurfaceReading) { + self.reader = reader + state = .unavailable(.notPublished) + refresh() + } + + /// Builds an honest unavailable model when the App Group entitlement + /// cannot be resolved. That configuration cannot recover during this + /// process lifetime, so there is no reader to retry. + init(appGroupUnavailable _: WhereSurfaceStore.AppGroupUnavailableError) { + reader = nil + state = .unavailable(.appGroupUnavailable) + } + + func refresh() { + guard let reader else { return } + do { + guard let document = try reader.read() else { + handleUnavailable(.notPublished) + return + } + guard + let generatedAt = document.generatedAt, + let snapshot = document.surface + else { + handleUnavailable(.notPublished) + return + } + state = .loaded( + generatedAt: generatedAt, + snapshot: snapshot, + refreshFailed: false, + ) + } catch { + handleUnavailable(.unreadable) + } + } + + private func handleUnavailable(_ reason: UnavailableReason) { + switch state { + case let .loaded(generatedAt, snapshot, _): + state = .loaded( + generatedAt: generatedAt, + snapshot: snapshot, + refreshFailed: true, + ) + case .unavailable: + state = .unavailable(reason) + } + } +} diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarRegionRow.swift b/Where/WhereMenuBar/Sources/WhereMenuBarRegionRow.swift new file mode 100644 index 00000000..e25884ee --- /dev/null +++ b/Where/WhereMenuBar/Sources/WhereMenuBarRegionRow.swift @@ -0,0 +1,19 @@ +import SwiftUI +import WhereSurface + +struct WhereMenuBarRegionRow: View { + let region: WhereSurfaceSnapshot.Region + + var body: some View { + HStack { + if let emoji = region.emoji, emoji.isEmpty == false { + Text(verbatim: emoji) + .accessibilityHidden(true) + } else { + Image(systemName: region.symbolName ?? "location.fill") + .accessibilityHidden(true) + } + Text(verbatim: region.name) + } + } +} diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarSnapshotView.swift b/Where/WhereMenuBar/Sources/WhereMenuBarSnapshotView.swift new file mode 100644 index 00000000..51a6f573 --- /dev/null +++ b/Where/WhereMenuBar/Sources/WhereMenuBarSnapshotView.swift @@ -0,0 +1,75 @@ +import SwiftUI +import WhereSurface + +struct WhereMenuBarSnapshotView: View { + @Environment(\.openURL) private var openURL + + let generatedAt: Date + let snapshot: WhereSurfaceSnapshot + let refreshFailed: Bool + + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + Text(.menuBarTodayTitle) + .font(.headline) + Spacer() + // The helper can outlive the host app across midnight. Keep + // the artifact's logical day visible so stale rows never + // masquerade as observations for the new day. + Text(snapshot.day, format: .dateTime.month(.abbreviated).day()) + .font(.caption) + .foregroundStyle(.secondary) + } + if snapshot.todayRegions.isEmpty { + Text(.menuBarTodayEmpty) + .foregroundStyle(.secondary) + } else { + ForEach(snapshot.todayRegions) { region in + WhereMenuBarRegionRow(region: region) + } + } + + Divider() + + Text(.menuBarYearToDateTitle) + .font(.headline) + if snapshot.yearToDate.isEmpty { + Text(.menuBarYearToDateEmpty) + .foregroundStyle(.secondary) + } else { + ForEach(snapshot.yearToDate) { dayCount in + WhereMenuBarDayCountRow(dayCount: dayCount) + } + } + + Divider() + + HStack { + Text(.menuBarUpdated) + Text(generatedAt, style: .relative) + } + .font(.caption) + .foregroundStyle(.secondary) + + if refreshFailed { + Label( + .menuBarRefreshFailed, + systemImage: "exclamationmark.triangle", + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + Button( + .menuBarOpenWhere, + systemImage: "arrow.up.forward.app", + action: openWhere, + ) + } + } + + private func openWhere() { + openURL(WhereSurfaceStore.openWhereURL) + } +} diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarUnavailableView.swift b/Where/WhereMenuBar/Sources/WhereMenuBarUnavailableView.swift new file mode 100644 index 00000000..b1360c24 --- /dev/null +++ b/Where/WhereMenuBar/Sources/WhereMenuBarUnavailableView.swift @@ -0,0 +1,46 @@ +import SwiftUI +import WhereSurface + +struct WhereMenuBarUnavailableView: View { + @Environment(\.openURL) private var openURL + + let reason: WhereMenuBarModel.UnavailableReason + + var body: some View { + VStack(alignment: .leading) { + Label(title, systemImage: "location.slash") + .font(.headline) + Text(description) + .foregroundStyle(.secondary) + Button( + .menuBarOpenWhere, + systemImage: "arrow.up.forward.app", + action: openWhere, + ) + } + } + + private func openWhere() { + openURL(WhereSurfaceStore.openWhereURL) + } + + private var title: LocalizedStringResource { + switch reason { + case .notPublished: + .menuBarUnavailableTitle + case .unreadable, .appGroupUnavailable: + .menuBarUnavailableFailureTitle + } + } + + private var description: LocalizedStringResource { + switch reason { + case .notPublished: + .menuBarUnavailableDescription + case .unreadable: + .menuBarUnavailableUnreadable + case .appGroupUnavailable: + .menuBarUnavailableAppGroup + } + } +} diff --git a/Where/WhereMenuBar/Sources/WhereMenuBarView.swift b/Where/WhereMenuBar/Sources/WhereMenuBarView.swift new file mode 100644 index 00000000..119f8ceb --- /dev/null +++ b/Where/WhereMenuBar/Sources/WhereMenuBarView.swift @@ -0,0 +1,23 @@ +import SwiftUI +import WhereSurface + +struct WhereMenuBarView: View { + let model: WhereMenuBarModel + + var body: some View { + Group { + switch model.state { + case let .unavailable(reason): + WhereMenuBarUnavailableView(reason: reason) + case let .loaded(generatedAt, snapshot, refreshFailed): + WhereMenuBarSnapshotView( + generatedAt: generatedAt, + snapshot: snapshot, + refreshFailed: refreshFailed, + ) + } + } + .frame(width: 320) + .padding() + } +} diff --git a/Where/WhereShareExtension/AGENTS.md b/Where/WhereShareExtension/AGENTS.md index c4d68bf4..91bfae54 100644 --- a/Where/WhereShareExtension/AGENTS.md +++ b/Where/WhereShareExtension/AGENTS.md @@ -1,8 +1,9 @@ # WhereShareExtension – Module Shape -The **Where** share extension: a Share-sheet action that writes shared content -(PDFs, images, Wallet passes, emails, links) into the app's store as a new -`Evidence`. See [`README.md`](README.md) for the data path and design. +The **Where** iOS/iPadOS and Mac Catalyst share extension: a Share-sheet action +that writes shared content (PDFs, images, Wallet passes, emails, links) into the +app's store as a new `Evidence`. See [`README.md`](README.md) for the data path +and design. This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature [`Where/AGENTS.md`](../AGENTS.md). Read those first. diff --git a/Where/WhereShareExtension/README.md b/Where/WhereShareExtension/README.md index ebeaf617..46d67515 100644 --- a/Where/WhereShareExtension/README.md +++ b/Where/WhereShareExtension/README.md @@ -1,8 +1,9 @@ # WhereShareExtension -The **Where** share extension: a Share-sheet action that saves shared content — -a boarding pass, a PDF receipt, a screenshot, a forwarded reservation email, a -Wallet ticket — into Where as a new piece of [`Evidence`](../WhereCore/Sources/Evidence/Evidence.swift). +The **Where** iOS/iPadOS and Mac Catalyst share extension: a Share-sheet action +that saves shared content — a boarding pass, a PDF receipt, a screenshot, a +forwarded reservation email, a Wallet ticket — into Where as a new piece of +[`Evidence`](../WhereCore/Sources/Evidence/Evidence.swift). Pick "Where" from any app's Share sheet, confirm the kind / date / note in the compose sheet, and tap **Save**. The attachment bytes and metadata are written diff --git a/Where/WhereShareExtension/WhereShareExtension-MacCatalyst.entitlements b/Where/WhereShareExtension/WhereShareExtension-MacCatalyst.entitlements new file mode 100644 index 00000000..3c728f04 --- /dev/null +++ b/Where/WhereShareExtension/WhereShareExtension-MacCatalyst.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.stuff.where + + + diff --git a/Where/WhereSurface/AGENTS.md b/Where/WhereSurface/AGENTS.md new file mode 100644 index 00000000..928b260c --- /dev/null +++ b/Where/WhereSurface/AGENTS.md @@ -0,0 +1,28 @@ +# 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. +- Treat `WhereSurfaceChangeNotification` as advisory and the atomically + replaced JSON file as authoritative. +- Preserve the last successfully decoded payload when a later refresh fails. + +## Testing + +Swift Testing lives in [`Tests/`](Tests). Pin additive wire compatibility and +read behavior without resolving the real App Group container. diff --git a/Where/WhereSurface/README.md b/Where/WhereSurface/README.md new file mode 100644 index 00000000..97808dd4 --- /dev/null +++ b/Where/WhereSurface/README.md @@ -0,0 +1,32 @@ +# 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`. +- `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, and +file reads. The app's WhereCore tests cover construction, ranking, and +publication of the payload from real domain data. diff --git a/Where/WhereSurface/Sources/WhereSurfaceChangeNotification.swift b/Where/WhereSurface/Sources/WhereSurfaceChangeNotification.swift new file mode 100644 index 00000000..c100a1e0 --- /dev/null +++ b/Where/WhereSurface/Sources/WhereSurfaceChangeNotification.swift @@ -0,0 +1,20 @@ +import CoreFoundation +import Foundation + +/// The advisory Darwin notification posted after replacing the glance file. +/// +/// The file remains authoritative: a receiver always re-reads it and may also +/// refresh at launch. Darwin delivery is intentionally only a low-latency hint. +public enum WhereSurfaceChangeNotification { + public static let name = "com.stuff.where.surface.changed" + + public static func post() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(rawValue: name as CFString), + nil, + nil, + true, + ) + } +} diff --git a/Where/WhereSurface/Sources/WhereSurfaceDocument.swift b/Where/WhereSurface/Sources/WhereSurfaceDocument.swift new file mode 100644 index 00000000..0f42079d --- /dev/null +++ b/Where/WhereSurface/Sources/WhereSurfaceDocument.swift @@ -0,0 +1,17 @@ +import Foundation + +/// The helper-facing overlay decoded from `widget-snapshot.json`. +/// +/// The file also carries widget-specific fields. `Codable` deliberately +/// ignores those unknown keys, allowing a Foundation-only process to read the +/// glance payload without linking WhereCore or RegionKit. Both properties are +/// optional so files published before this overlay existed still decode. +public struct WhereSurfaceDocument: Codable, Hashable, Sendable { + public let generatedAt: Date? + public let surface: WhereSurfaceSnapshot? + + public init(generatedAt: Date?, surface: WhereSurfaceSnapshot?) { + self.generatedAt = generatedAt + self.surface = surface + } +} diff --git a/Where/WhereSurface/Sources/WhereSurfaceReading.swift b/Where/WhereSurface/Sources/WhereSurfaceReading.swift new file mode 100644 index 00000000..d3d369ab --- /dev/null +++ b/Where/WhereSurface/Sources/WhereSurfaceReading.swift @@ -0,0 +1,6 @@ +/// A read-only boundary for the glance artifact shared with store-free +/// processes. +public protocol WhereSurfaceReading: Sendable { + /// Returns `nil` when the app has never published an artifact. + func read() throws -> WhereSurfaceDocument? +} diff --git a/Where/WhereSurface/Sources/WhereSurfaceSnapshot.swift b/Where/WhereSurface/Sources/WhereSurfaceSnapshot.swift new file mode 100644 index 00000000..9d6c833c --- /dev/null +++ b/Where/WhereSurface/Sources/WhereSurfaceSnapshot.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Presentation-ready data for Where's store-free glance surfaces. +/// +/// The app builds this from its authoritative report and publishes it inside +/// `widget-snapshot.json`. Consumers render the supplied names and ordering; +/// they never open the user's store or repeat region aggregation. +public struct WhereSurfaceSnapshot: Codable, Hashable, Sendable { + /// A display-ready region shared by today's presence and year-to-date rows. + public struct Region: Codable, Hashable, Identifiable, Sendable { + /// The region's stable data identifier. + public let id: String + /// The localized name resolved by the publishing app. + public let name: String + /// A user-selected emoji, when the region has one. + public let emoji: String? + /// A user-selected SF Symbol name, when the region has one. + public let symbolName: String? + + public init(id: String, name: String, emoji: String?, symbolName: String?) { + self.id = id + self.name = name + self.emoji = emoji + self.symbolName = symbolName + } + } + + /// One ranked year-to-date region total. + public struct DayCount: Codable, Hashable, Identifiable, Sendable { + public var id: String { + region.id + } + + public let region: Region + public let days: Int + + public init(region: Region, days: Int) { + self.region = region + self.days = days + } + } + + /// The logical day represented by `todayRegions`. + public let day: Date + /// Regions observed on `day`, already in canonical display order. + public let todayRegions: [Region] + /// The Gregorian calendar year represented by `yearToDate`. + public let year: Int + /// The top year-to-date day counts, already ranked for display. + public let yearToDate: [DayCount] + + public init( + day: Date, + todayRegions: [Region], + year: Int, + yearToDate: [DayCount], + ) { + self.day = day + self.todayRegions = todayRegions + self.year = year + self.yearToDate = yearToDate + } +} diff --git a/Where/WhereSurface/Sources/WhereSurfaceStore.swift b/Where/WhereSurface/Sources/WhereSurfaceStore.swift new file mode 100644 index 00000000..6dcb40b4 --- /dev/null +++ b/Where/WhereSurface/Sources/WhereSurfaceStore.swift @@ -0,0 +1,46 @@ +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 FileManager.default.fileExists(atPath: fileURL.path) else { + return nil + } + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode(WhereSurfaceDocument.self, from: data) + } +} diff --git a/Where/WhereSurface/Tests/WhereSurfaceChangeNotificationTests.swift b/Where/WhereSurface/Tests/WhereSurfaceChangeNotificationTests.swift new file mode 100644 index 00000000..ebec553e --- /dev/null +++ b/Where/WhereSurface/Tests/WhereSurfaceChangeNotificationTests.swift @@ -0,0 +1,8 @@ +import Testing +import WhereSurface + +struct WhereSurfaceChangeNotificationTests { + @Test func notificationNameIsStableAcrossProcesses() { + #expect(WhereSurfaceChangeNotification.name == "com.stuff.where.surface.changed") + } +} diff --git a/Where/WhereSurface/Tests/WhereSurfaceDocumentTests.swift b/Where/WhereSurface/Tests/WhereSurfaceDocumentTests.swift new file mode 100644 index 00000000..57558569 --- /dev/null +++ b/Where/WhereSurface/Tests/WhereSurfaceDocumentTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +import WhereSurface + +struct WhereSurfaceDocumentTests { + @Test func oldWidgetDocumentDecodesWithoutGlanceFields() throws { + let data = Data( + """ + { + "day": 0, + "year": 2026, + "dayRegions": ["us-CA"], + "totals": ["us-CA", 1] + } + """.utf8, + ) + + let document = try JSONDecoder().decode(WhereSurfaceDocument.self, from: data) + + #expect(document.generatedAt == nil) + #expect(document.surface == nil) + } + + @Test func widgetOnlyKeysAreIgnored() throws { + let region = WhereSurfaceSnapshot.Region( + id: "us-CA", + name: "California", + emoji: nil, + symbolName: nil, + ) + let surface = WhereSurfaceSnapshot( + day: Date(timeIntervalSinceReferenceDate: 10), + todayRegions: [region], + year: 2026, + yearToDate: [.init(region: region, days: 42)], + ) + let encoder = JSONEncoder() + let document = WhereSurfaceDocument( + generatedAt: Date(timeIntervalSinceReferenceDate: 20), + surface: surface, + ) + var object = try #require( + JSONSerialization.jsonObject(with: encoder.encode(document)) as? [String: Any], + ) + object["dayRegions"] = ["us-NY"] + object["totals"] = ["us-NY": 9] + let data = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(WhereSurfaceDocument.self, from: data) + + #expect(decoded == document) + } +} diff --git a/Where/WhereSurface/Tests/WhereSurfaceSnapshotTests.swift b/Where/WhereSurface/Tests/WhereSurfaceSnapshotTests.swift new file mode 100644 index 00000000..83ea6dc9 --- /dev/null +++ b/Where/WhereSurface/Tests/WhereSurfaceSnapshotTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +import WhereSurface + +struct WhereSurfaceSnapshotTests { + @Test func codableRoundTripPreservesPresentationData() throws { + let california = WhereSurfaceSnapshot.Region( + id: "us-CA", + name: "California", + emoji: "🌴", + symbolName: "sun.max.fill", + ) + let snapshot = WhereSurfaceSnapshot( + day: Date(timeIntervalSinceReferenceDate: 10), + todayRegions: [california], + year: 2026, + yearToDate: [.init(region: california, days: 132)], + ) + + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(WhereSurfaceSnapshot.self, from: data) + + #expect(decoded == snapshot) + #expect(decoded.yearToDate.first?.id == "us-CA") + } +} diff --git a/Where/WhereSurface/Tests/WhereSurfaceStoreTests.swift b/Where/WhereSurface/Tests/WhereSurfaceStoreTests.swift new file mode 100644 index 00000000..3a71026d --- /dev/null +++ b/Where/WhereSurface/Tests/WhereSurfaceStoreTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +import WhereSurface + +struct WhereSurfaceStoreTests { + @Test func missingArtifactReturnsNil() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let document = try WhereSurfaceStore(directory: directory).read() + + #expect(document == nil) + } + + @Test func readsTheSurfaceOverlayFromWidgetJSON() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let expected = WhereSurfaceDocument( + generatedAt: Date(timeIntervalSinceReferenceDate: 20), + surface: WhereSurfaceSnapshot( + day: Date(timeIntervalSinceReferenceDate: 10), + todayRegions: [], + year: 2026, + yearToDate: [], + ), + ) + let data = try JSONEncoder().encode(expected) + try data.write( + to: directory.appending(path: WhereSurfaceStore.snapshotFileName), + options: .atomic, + ) + + let document = try WhereSurfaceStore(directory: directory).read() + + #expect(document == expected) + } + + @Test func malformedArtifactThrows() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try Data("not json".utf8).write( + to: directory.appending(path: WhereSurfaceStore.snapshotFileName), + ) + let store = WhereSurfaceStore(directory: directory) + + #expect(throws: DecodingError.self) { + try store.read() + } + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appending(path: "WhereSurfaceStoreTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 58fc994e..2a8d7874 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -16,6 +16,9 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) - Composition is the one exception: `WhereScope` and `WhereModel` decide which world the app is logged in to and assemble it. That's launch wiring, not domain logic — see [Scopes and the launch](../AGENTS.md#scopes-and-the-launch). +- Keep onboarding's existing-iCloud path write-free beyond its completion and + local-recording intent: it opens the real scope, seeds no regions, and + resolves the gate while CloudKit imports continue. - Flyover infrastructure stays under `#if DEBUG` in [`Sources/Developer/Flyover`](Sources/Developer/Flyover), while each represented screen declares a DEBUG-only `WhereFlyoverProviding` extension @@ -54,6 +57,20 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) days. Views don't read `\.isCapturingSnapshot` to branch themselves; capture handling stays inside the shared component. +## Navigation shell + +`MainTabs` owns the scene-scoped report lifecycle, while `MainView` selects +`PhoneMainTabs` on iPhone and `MainSplitView` on iPad and Mac Catalyst. Both +surfaces route the same `MainSection` identities to the existing Locations, +Your Year, and Settings screens. + +- Select the shell by device family through `MainInterfaceStyle`, never the + current size class. +- Keep `MainSplitView` as the stable `NavigationSplitView` root and let the + system collapse its columns. +- Preserve every section's stack and local presentation state when sidebar + selection changes. + ## Design system — `WhereStylesheet` All appearance tokens — geometry, fonts, colors, motion — live in diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 863f9ee7..634ab389 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -21,11 +21,17 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's - **`RootView`** — the app root: the typed launch plan (via [`LifecycleKit`](../../Shared/LifecycleKit), rendered by [`LifecycleKitUI`](../../Shared/LifecycleKitUI)'s container) gated in front of - `MainTabs`, the Liquid Glass tab bar over three tabs — Locations, Your Year, - Settings. Elsewhere is an entry card on Locations, Resolve a Locations toolbar - button, and the data screens (attachments, logged days, regions) sit in the - Settings "Data" group. Backup and destructive data management share one Data - drill-in. `AboutSettingsView` is the last Settings block — build + `MainTabs`, the scene-scoped owner of the adaptive logged-in shell. + `MainView` presents a Liquid Glass `PhoneMainTabs` on iPhone and a two-column + `MainSplitView` on iPad and Mac Catalyst. Both expose the same three sections + — Locations, Your Year, Settings — while the split shell keeps its + `NavigationSplitView` root as the window resizes and lets the system collapse + its columns. Its page-backed detail host retains each section's navigation + and presentation state while sidebar selection changes. Elsewhere is an + entry card on Locations, Resolve a Locations toolbar button, and the data + screens (attachments, logged days, regions) sit in the Settings "Data" group. + Backup and destructive data management share one Data drill-in. + `AboutSettingsView` is the last Settings block — build identity, the app's generated attribution report (linked libraries and development tools as separate sections), and bundled-data provenance, each vended by whoever owns it rather than listed in the view; it renders an @@ -63,7 +69,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's - **`WhereSession`** — the always-on coordinator: tracking + location authorization state and the intents that drive them (`requestPermission()`, per-device recording changes, `startTracking()` / `stopTracking()`, - `refreshWidgetSnapshot()`). It holds no presentation state of its own. + `refreshWidgetSnapshot()`). A management-only session exposes no current + recording device and never requests or starts local location services, while + retaining remote-device management. It holds no presentation state of its + own. - **Scope-tiered models** — scene-scoped **`YearReportModel`** (the selected year's `YearReport`, its `LoadState`, and the manual-day edit intents), plus view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** @@ -82,7 +91,9 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's tracked-region set + appearances before resolving the gate. The intro also offers **Restore from a backup**, which opens the store, imports a backup (`.replace`), and skips the manual pick/customize steps straight to the - location ask; and **Explore a demo**, which builds a throwaway in-memory + location ask; **Join Existing iCloud Data**, which opens the real scope + without seeding regions or enabling local recording and enters while remote + imports continue; and **Explore a demo**, which builds a throwaway in-memory world behind a captioned launch splash and enters it. - **`RegionPickerView` / `RegionCustomizeView`** — the shared primary-region picker (segmented map/list) and per-region color/emoji/icon customization, @@ -124,8 +135,8 @@ target's dependencies in [`Package.swift`](../../Package.swift): ## Quick start The app target is deliberately tiny — it builds the model + launch runner at -startup (so CoreLocation is wired for background relaunch) and hands them to -`RootView`: +startup (wiring CoreLocation for background relaunch on participating +iPhone/iPad hosts and skipping it on Catalyst) and hands them to `RootView`: ```swift import SwiftUI diff --git a/Where/WhereUI/SnapshotTests/MacSummaryWidgetViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/MacSummaryWidgetViewSnapshotTests.swift new file mode 100644 index 00000000..0fa0e7cf --- /dev/null +++ b/Where/WhereUI/SnapshotTests/MacSummaryWidgetViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct MacSummaryWidgetViewSnapshotTests { + @Test func macSummaryWidget() async { + await assertSnapshots(of: MacSummaryWidgetView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/MainViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/MainViewSnapshotTests.swift new file mode 100644 index 00000000..be44cf33 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/MainViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct MainViewSnapshotTests { + @Test func mainView() async { + await assertSnapshots(of: MainView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall.png new file mode 100644 index 00000000..03e3ff76 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a9b31c40512d793f8f1c792cf21684a02cf64e371bffbcda7f11e5ef39045df +size 40348 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall_dark.png new file mode 100644 index 00000000..96f69d4e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Compact_MacSmall_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c20484c4d713103b5a732584f745756531438b710034dcd42d44a20f001df86c +size 40611 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium.png new file mode 100644 index 00000000..cc709ae9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8c201c52fedf3e52cf7873f1cc01d5d1a9bfa00d8f2c449727cf277973131c0 +size 63858 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium_dark.png new file mode 100644 index 00000000..c979516a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MacSummaryWidgetViewSnapshotTests/macSummaryWidget.Wide_MacMedium_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:990992b9b96f8b50f7dec22e39448af60ea74675daddf6ea226a0c3b71a5327c +size 64238 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad.png new file mode 100644 index 00000000..8225d7b0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cf2d45683538893c38f21ad334e824fc1a446fb506cc4696d9f146dc4a35d6a +size 2266513 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad_dark.png new file mode 100644 index 00000000..e9b99d70 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainViewSnapshotTests/mainView.Split_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4b06be03f2701686af9fe650c61778c5abc661483cd1651884bd143b784a4c8 +size 2935628 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad.png index 995db43f..fad3a370 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b66d218b4a3a18ea79280099a62f1675114c0a2d451d91b87a4f3673da5b543 -size 1033547 +oid sha256:11c2ead13ee496531d39864f6b9f0692681687fea95866beb9a60065c67932c4 +size 1047941 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_accessibility.png index 50633f90..72c702e8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f4f8492e5e2ec3d6459d86b081e0de5523742cfb2c37849b436218c74600ee1 -size 2031769 +oid sha256:f486b1c1b03c2ac55da0e297ccaef1941f52107389b8263d33ef939e8e398e3d +size 2057272 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_ax5.png index 7c18bd96..20570ec6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f5d040ca6202e9b47def5b7dca42b73a7273853a4c9d327a5a78c807f0fc7da -size 1318137 +oid sha256:b55797c98a221d085d9d904a85617ca697c628cd17aba4d323f38c61c2bea81f +size 1376932 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_contrast.png index 1a91f836..53323de3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc0e031e06c44a3a065f1dda94b5f2d8efece1ed820296fe2882ebd41096c03d -size 988788 +oid sha256:9eafb3309b46f5a2ac6b0a1036f78784912d40d211696ed5c27e6ea0e21e1fb3 +size 1006297 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_dark.png index d028495d..5e95d74e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:812f7b15f3141668127a29331e01848dbdcab7be1534ef02cf198d672efb867f -size 1018593 +oid sha256:0d2291836f59f94af6556731e9de5c6ec8ee742e0dd658524425c06bf1dcaf4c +size 1048518 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone.png index b863acc6..ee0a6a0f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:348746d342faccc2a37075960a746eff3434e86a750d607b0b896eb73be0c568 -size 533542 +oid sha256:2f2622544a18f36cb0dfae2d94f0d37d64d353cabef35c4d445366ca70322e92 +size 546679 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_accessibility.png index 8de3fd03..c71e3e18 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:273941952b4ee47a3ec724cc31fdad99346fa238d8af17065f9a6bd3af84d24b -size 912480 +oid sha256:bd97f05072809799e2f688cc628eb9ff233e8dc9a764cc20b2a86f1a06cfcd45 +size 937214 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_ax5.png index 8408dae8..4a1442eb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8018f827540c6297e3ce05aae29f7522384c2dc1bff01e70e445a6b4b97eaf63 -size 724927 +oid sha256:70e1157199a23ff3750552c147a576d570ca1adc0a4e47ba8712a7c55037f69a +size 760374 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_contrast.png index 0c5c2936..a75ae060 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d984f769764e1ce225d1cc9370fd621f360abfd7aeae528715ba8f1175936d9 -size 512481 +oid sha256:1cc0006b3c628325fd4bb7c0f61e78100058908f20e5fd8094b5a016c940b382 +size 526556 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_dark.png index 2642c1f7..f813b2e2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b6bfd6d81cbb9b8c8f9a24d568083fa34cc7341478667be58ef3411b06579ce4 -size 521892 +oid sha256:82bc8f6a67f78a1650b8a4ceaa6df4e4cb2a5e0c652e25dbd50aa7df4068d0b3 +size 534708 diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift index e07f06e9..aeda0357 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift @@ -114,6 +114,7 @@ TodayWidgetView.flyoverData, TodayInlineAccessoryView.flyoverData, TodayCircularAccessoryView.flyoverData, + MacSummaryWidgetView.flyoverData, YearTotalsWidgetView.flyoverData, YearTotalsRectangularAccessoryView.flyoverData, ] diff --git a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift index 60068d3b..b4068cbd 100644 --- a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift +++ b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift @@ -2,19 +2,38 @@ import Foundation import UIKit import WhereCore -/// Builds the local installation identity at the app composition boundary. +/// Resolves local recording participation at the app composition boundary. /// -/// The first available `identifierForVendor` (or a generated fallback before -/// first unlock) is persisted immediately. Every later launch reuses that -/// choice, so a pre-unlock headless wake cannot register one identity and the -/// foreground launch silently switch to another. +/// On a participating iPhone or iPad, the first available +/// `identifierForVendor` (or a generated fallback before first unlock) is +/// persisted immediately. Every later launch reuses that choice, so a +/// pre-unlock headless wake cannot register one identity and the foreground +/// launch silently switch to another. Catalyst returns management-only before +/// reading or writing an identity. @MainActor enum CurrentRecordingDeviceProvider { private enum Key: String { case recordingDeviceID = "where.recordingDeviceID" } - static func current(defaults: UserDefaults) -> CurrentRecordingDevice { + static func supportsLocalRecording(idiom: UIUserInterfaceIdiom) -> Bool { + #if targetEnvironment(macCatalyst) + return false + #else + switch idiom { + case .phone, .pad: true + case .unspecified, .tv, .carPlay, .mac, .vision: false + @unknown default: false + } + #endif + } + + static func participation( + defaults: UserDefaults, + idiom: UIUserInterfaceIdiom, + ) -> RecordingParticipation { + guard supportsLocalRecording(idiom: idiom) else { return .managementOnly } + let device = UIDevice.current let id: UUID if let stored = defaults.string(forKey: Key.recordingDeviceID.rawValue) @@ -26,16 +45,40 @@ enum CurrentRecordingDeviceProvider { defaults.set(id.uuidString, forKey: Key.recordingDeviceID.rawValue) } - let kind: RecordingDeviceKind = switch device.userInterfaceIdiom { + let kind: RecordingDeviceKind = switch idiom { case .phone: .phone case .pad: .tablet case .unspecified, .tv, .carPlay, .mac, .vision: .other @unknown default: .other } - return CurrentRecordingDevice( - id: RecordingDeviceID(rawValue: id), - systemName: device.model, - kind: kind, + return .recording( + device: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: id), + systemName: device.model, + kind: kind, + ), + defaultEnabledForNewInstallation: idiom == .phone, + ) + } + + /// Demo mode mirrors the host's physical-recording capability without + /// minting a real installation identity. A management-only host stays + /// management-only even inside its throwaway demo world. + static func demoParticipation( + supportsLocalRecording: Bool, + ) -> RecordingParticipation { + guard supportsLocalRecording else { return .managementOnly } + return .recording( + device: .preview, + defaultEnabledForNewInstallation: true, + ) + } + + static var demoParticipationForCurrentHost: RecordingParticipation { + demoParticipation( + supportsLocalRecording: supportsLocalRecording( + idiom: UIDevice.current.userInterfaceIdiom, + ), ) } } diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index d8c83ee3..95686097 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -1,6 +1,7 @@ import LifecycleKit import PeriscopeCore import SwiftUI +import UIKit import UserNotifications import WhereCore @@ -237,12 +238,18 @@ public final class WhereBootstrap: WhereScopeAssembling { private static let logger = WhereLog.root(WhereLaunchLog.self) private var locationSource: CoreLocationSource? + private let userInterfaceIdiom: UIUserInterfaceIdiom - public init() {} + public init() { + userInterfaceIdiom = UIDevice.current.userInterfaceIdiom + } /// Install the `CLLocationManager` + delegate right away, without touching /// the store. Idempotent. public func prepareLocation() { + guard CurrentRecordingDeviceProvider.supportsLocalRecording( + idiom: userInterfaceIdiom, + ) else { return } guard locationSource == nil else { return } locationSource = CoreLocationSource() } @@ -264,9 +271,16 @@ public final class WhereBootstrap: WhereScopeAssembling { /// `.failed`, so without this line the failure would leave no trace /// anywhere. public func makeServices() async throws -> WhereServices { - let source = locationSource ?? CoreLocationSource() + let participation = CurrentRecordingDeviceProvider.participation( + defaults: .standard, + idiom: userInterfaceIdiom, + ) + let source: any LocationSource = if participation.supportsLocalRecording { + locationSource ?? CoreLocationSource() + } else { + IdleLocationSource() + } locationSource = nil - let currentDevice = CurrentRecordingDeviceProvider.current(defaults: .standard) do { let store = try await Task.detached(priority: .userInitiated) { try SwiftDataStore.make() @@ -274,7 +288,7 @@ public final class WhereBootstrap: WhereScopeAssembling { let services = try await WhereServices.make( store: store, locationSource: source, - currentDevice: currentDevice, + recordingParticipation: participation, // The real world's seams, named here because this is the only // place that wants them: the demo scope builds the same stack // out of no-ops, and every test and preview gets no-ops by diff --git a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift index 373da80a..fab63ca5 100644 --- a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift +++ b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift @@ -6,6 +6,9 @@ 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. @@ -21,7 +24,9 @@ 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 } @@ -33,6 +38,8 @@ 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 .scopeCreationFailed(description): diff --git a/Where/WhereUI/Sources/MainInterfaceStyle.swift b/Where/WhereUI/Sources/MainInterfaceStyle.swift new file mode 100644 index 00000000..81d35fb8 --- /dev/null +++ b/Where/WhereUI/Sources/MainInterfaceStyle.swift @@ -0,0 +1,21 @@ +#if canImport(UIKit) + import UIKit +#endif + +/// The logged-in shell chosen from the device family, not the current window +/// width, so an iPad keeps its split-view navigation as the window resizes. +enum MainInterfaceStyle { + case tabs + case split + + @MainActor + static var current: Self { + #if targetEnvironment(macCatalyst) + .split + #elseif canImport(UIKit) + UIDevice.current.userInterfaceIdiom == .pad ? .split : .tabs + #else + .split + #endif + } +} diff --git a/Where/WhereUI/Sources/MainSection.swift b/Where/WhereUI/Sources/MainSection.swift new file mode 100644 index 00000000..72f079a0 --- /dev/null +++ b/Where/WhereUI/Sources/MainSection.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// A stable identity shared by the phone tab bar and the iPad/Mac sidebar. +enum MainSection: Hashable, CaseIterable, Identifiable { + case locations + case year + case settings + + var id: Self { + self + } + + var title: String { + switch self { + case .locations: + String(localized: .tabLocations) + case .year: + String(localized: .tabYear) + case .settings: + String(localized: .tabSettings) + } + } + + var systemImage: String { + switch self { + case .locations: + "location.fill" + case .year: + "calendar" + case .settings: + "gearshape.fill" + } + } +} diff --git a/Where/WhereUI/Sources/MainSplitView.swift b/Where/WhereUI/Sources/MainSplitView.swift new file mode 100644 index 00000000..79852f8c --- /dev/null +++ b/Where/WhereUI/Sources/MainSplitView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// The regular-device logged-in interface. The split view remains the root as +/// an iPad or Catalyst window resizes, letting the system collapse its columns +/// without replacing the navigation model. +struct MainSplitView: View { + let report: YearReportModel + + @State private var selection: MainSection? = .locations + + var body: some View { + NavigationSplitView { + List(MainSection.allCases, selection: $selection) { section in + Label(section.title, systemImage: section.systemImage) + .tag(section) + } + } detail: { + // A page-style TabView keeps every section's NavigationStack and + // local presentation state alive while the sidebar changes the + // visible section. Its own tab chrome is intentionally hidden. + TabView(selection: $selection) { + Tab(value: MainSection?.some(.locations)) { + LocationsView(report: report) + } + + Tab(value: MainSection?.some(.year)) { + YearView(report: report) + } + + Tab(value: MainSection?.some(.settings)) { + SettingsView(report: report) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + .navigationSplitViewStyle(.balanced) + } +} + +#if DEBUG + #Preview { + MainSplitView(report: PreviewSupport.loadedYearReportModel()) + .environment(PreviewSupport.loadedModel()) + .environment(PreviewSupport.loadedSession()) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index 484cccc5..1fcf74b6 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -1,28 +1,18 @@ import SwiftUI import WhereCore -/// The logged-in tab bar — the launch *destination* once the runner reaches -/// `.ready`, not a launch step. Owns the scene-scoped ``YearReportModel`` as -/// `@State` and drives its store-change subscription from `scenePhase` (active → -/// subscribe + pull, background → cancel — closing the headless-relaunch rescan -/// leak). +/// The adaptive logged-in shell — the launch *destination* once the runner +/// reaches `.ready`, not a launch step. Owns the scene-scoped +/// ``YearReportModel`` as `@State` and drives its store-change subscription from +/// `scenePhase` (active → subscribe + pull, background → cancel — closing the +/// headless-relaunch rescan leak). /// -/// Three fixed tabs — Locations, Your Year, Settings. Elsewhere is folded into -/// Locations (an entry card) and Resolve into a Locations toolbar button; the -/// data screens (attachments, logged days, regions) live in the Settings "Data" -/// group. The tabs receive the report by explicit init injection (compile- -/// checked wiring); the always-on `WhereSession` coordinator stays in the -/// environment. +/// Phones keep the three-tab interface while iPad and Mac Catalyst use the same +/// three sections in a two-column split view. The sections receive the report by +/// explicit init injection (compile-checked wiring); the always-on +/// `WhereSession` coordinator stays in the environment. struct MainTabs: View { - /// Identity for the tab-bar selection. - private enum TabID: Hashable { - case locations - case year - case settings - } - @State private var report: YearReportModel - @State private var selection: TabID = .locations @Environment(\.scenePhase) private var scenePhase /// Build the scene's report model from the coordinator's service layer. @@ -39,53 +29,29 @@ struct MainTabs: View { } var body: some View { - TabView(selection: $selection) { - Tab( - String(localized: .tabLocations), - systemImage: "location.fill", - value: TabID.locations, - ) { - LocationsView(report: report) - .reportingDeveloperTabBarInset() + MainView(report: report, interfaceStyle: .current) + // Subscribe + pull once the scene is on screen, and again whenever it + // returns to the foreground; cancel the subscription on background so a + // backgrounded scene drives no refreshes. + .task { await report.activate() } + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .active: + Task { await report.activate() } + case .background: + report.deactivate() + case .inactive: + break + @unknown default: + break + } } - - Tab(String(localized: .tabYear), systemImage: "calendar", value: TabID.year) { - YearView(report: report) - .reportingDeveloperTabBarInset() - } - - Tab( - String(localized: .tabSettings), - systemImage: "gearshape.fill", - value: TabID.settings, - ) { - SettingsView(report: report) - .reportingDeveloperTabBarInset() - } - } - // Keep the tab bar fixed — don't minimize it as content scrolls. - .tabBarMinimizeBehavior(.never) - // Subscribe + pull once the scene is on screen, and again whenever it - // returns to the foreground; cancel the subscription on background so a - // backgrounded scene drives no refreshes. - .task { await report.activate() } - .onChange(of: scenePhase) { _, newPhase in - switch newPhase { - case .active: - Task { await report.activate() } - case .background: - report.deactivate() - case .inactive: - break - @unknown default: - break - } - } } } #if DEBUG private struct MainTabsPreview: View { + private let model = PreviewSupport.loadedModel() private let session = PreviewSupport.loadedSession() var body: some View { @@ -94,6 +60,7 @@ struct MainTabs: View { initialReport: PreviewSupport.sampleReport(), selectedYear: PreviewSupport.year, ) + .environment(model) .environment(session) .whereBroadwayRoot() } diff --git a/Where/WhereUI/Sources/MainView.swift b/Where/WhereUI/Sources/MainView.swift new file mode 100644 index 00000000..d2b3d11d --- /dev/null +++ b/Where/WhereUI/Sources/MainView.swift @@ -0,0 +1,65 @@ +import SnapshotKit +import SwiftUI + +/// Selects the device-family-appropriate presentation of the logged-in +/// sections while keeping report ownership in ``MainTabs``. +struct MainView: View { + let report: YearReportModel + let interfaceStyle: MainInterfaceStyle + + var body: some View { + switch interfaceStyle { + case .tabs: + PhoneMainTabs(report: report) + case .split: + MainSplitView(report: report) + } + } +} + +#if DEBUG + extension MainView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot( + name: "Split", + configurations: SnapshotConfiguration.combinations( + devices: [.iPad], + colorSchemes: [.light, .dark], + ), + settle: .settledAtLeast(minDuration: 1.0), + ) { + MainView( + report: PreviewSupport.loadedYearReportModel(), + interfaceStyle: .split, + ) + .environment(PreviewSupport.loadedModel()) + .environment(PreviewSupport.loadedSession()) + // SnapshotKit's fixed iPad frame still runs inside the + // checkout's iPhone simulator. Supply the regular-width trait + // that a real iPad/Catalyst window contributes so this + // reference actually guards the two-column presentation. + .environment(\.horizontalSizeClass, .regular) + } + } + } + + #Preview("Phone tabs") { + MainView( + report: PreviewSupport.loadedYearReportModel(), + interfaceStyle: .tabs, + ) + .environment(PreviewSupport.loadedModel()) + .environment(PreviewSupport.loadedSession()) + .whereBroadwayRoot() + } + + #Preview("iPad and Mac split") { + MainView( + report: PreviewSupport.loadedYearReportModel(), + interfaceStyle: .split, + ) + .environment(PreviewSupport.loadedModel()) + .environment(PreviewSupport.loadedSession()) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index a4d4281f..ecdb4a1f 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -142,6 +142,27 @@ public final class WhereModel { Self.logger { .onboardingCompleted } } + /// 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() + scope.preferences.wantsTracking = false + if let currentDeviceID = scope.services.recording.currentDevice?.id { + _ = try await scope.services.recording.setEnabled( + false, + for: currentDeviceID, + initialEnabled: false, + ) + } + completeOnboarding() + } + public static var currentYear: Int { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current diff --git a/Where/WhereUI/Sources/Model/WhereScope.swift b/Where/WhereUI/Sources/Model/WhereScope.swift index 9ad09a80..c1649846 100644 --- a/Where/WhereUI/Sources/Model/WhereScope.swift +++ b/Where/WhereUI/Sources/Model/WhereScope.swift @@ -173,9 +173,10 @@ public final class WhereScope { /// Every collaborator that would touch the device outside the store is a /// no-op: the schedulers never ask for notification permission, the widget /// refresher never writes the shared snapshot file, and the location - /// source is scripted, so demo mode prompts for nothing. The scripted - /// source reports `.always` and answers a one-shot fix from New York, so - /// the app behaves as it would for a user who has granted everything. + /// source is scripted, so demo mode prompts for nothing. On a participating + /// iPhone or iPad, that source reports `.always` and answers a one-shot fix + /// from New York; Catalyst keeps the same management-only participation as + /// its real scope and relies on the already-seeded demo history. /// /// Building this is the slow part of entering demo mode (seeding a year), /// which is why the entry point shows an interstitial while it runs. @@ -203,10 +204,12 @@ public final class WhereScope { let store = try await Task.detached(priority: .userInitiated) { try SwiftDataStore.inMemory() }.value + let recordingParticipation = + CurrentRecordingDeviceProvider.demoParticipationForCurrentHost let services = try await WhereServices.make( store: store, locationSource: locationSource, - currentDevice: .preview, + recordingParticipation: recordingParticipation, aggregator: aggregator, // Authorized, like the location source is: the demo presents a user // who has granted everything, so the alerts screen shows its real @@ -224,10 +227,11 @@ public final class WhereScope { .seed(into: services) let preferences = WherePreferences(store: InMemoryKeyValueStore()) - // Onboarded and tracking, so the demo opens on the logged-in app with - // live tracking shown rather than on a first-run prompt. These are the - // demo's own preferences: the user's real ones are untouched, which is - // what makes quitting mid-demo return to onboarding. + // Onboarded with tracking intent, so a participating host's demo opens + // with live tracking shown rather than on a first-run prompt. A + // management-only host ignores that local intent. These are the demo's + // own preferences: the user's real ones are untouched, which is what + // makes quitting mid-demo return to onboarding. preferences.hasOnboarded = true preferences.wantsTracking = true diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 495d6c06..56414d24 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -47,9 +47,17 @@ public final class WhereSession { public private(set) var isTracking = false /// Stable installation identity used by the Devices settings screen to mark - /// the current row and prevent archiving it. - public var currentRecordingDeviceID: RecordingDeviceID { - services.recording.currentDevice.id + /// the current row and prevent archiving it. Nil for a management-only + /// session. + public var currentRecordingDeviceID: RecordingDeviceID? { + services.recording.currentDevice?.id + } + + /// Whether this installation can contribute automatic locations. Catalyst + /// sessions are management-only: they still edit synced device policies, + /// but expose no current row and never touch local location services. + public var supportsLocalRecording: Bool { + services.recording.participation.supportsLocalRecording } /// The latest known location authorization status, kept live via @@ -112,10 +120,16 @@ public final class WhereSession { private var warnedIssueAlertsUnauthorized = false /// Persisted user intent to track in the background. Effective tracking is - /// this AND `.always` authorization; we default to `true` so that, once the - /// user grants Always, tracking resumes automatically on every launch. + /// this AND `.always` authorization. A missing value resolves against the + /// composition policy for a new installation, except an already-onboarded + /// installation retains the historical on-by-default behavior. private var wantsTracking: Bool { - get { preferences.wantsTracking } + get { + preferences.wantsTracking( + defaultForNewInstallation: + services.recording.participation.defaultEnabledForNewInstallation, + ) + } set { preferences.wantsTracking = newValue } } @@ -316,10 +330,13 @@ public final class WhereSession { func reconcileTracking() async { let wasTracking = isTracking do { - let configuration = try await services.recording.reconcile( + guard let configuration = try await services.recording.reconcile( initialEnabled: wantsTracking, authorization: authorizationStatus, - ) + ) else { + isTracking = false + return + } // Keep the legacy local preference as the migration seed/fallback, // but synced policy is authoritative once the device exists. wantsTracking = configuration.isEnabled @@ -345,6 +362,7 @@ public final class WhereSession { /// on persist. A launch step (see `WhereLaunch.plan(for:)`); also runs on /// every foreground. func captureTodayIfNeeded() async { + guard supportsLocalRecording else { return } guard wantsTracking, authorizationStatus.allowsForegroundFix else { return } await services.ingestor.captureTodayIfNeeded(now: now()) } @@ -353,6 +371,7 @@ public final class WhereSession { /// access" button. Drives the system prompt when possible, then syncs the /// status and reconciles tracking so the UI reflects the outcome. public func requestPermission() async { + guard supportsLocalRecording else { return } do { try await services.ingestor.requestPermission() permissionDenied = false @@ -374,6 +393,7 @@ public final class WhereSession { /// When-In-Use is granted the indicator guides the user to Settings; on a /// hard denial the Settings alert is surfaced. public func startTracking() async { + guard let currentRecordingDeviceID else { return } do { _ = try await setRecordingEnabled(true, for: currentRecordingDeviceID) } catch { @@ -384,6 +404,7 @@ public final class WhereSession { } public func stopTracking() async { + guard let currentRecordingDeviceID else { return } do { _ = try await setRecordingEnabled(false, for: currentRecordingDeviceID) } catch { diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 12c5b8b5..b03c6867 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 + }, ) } } @@ -380,6 +404,28 @@ public struct OnboardingView: View { } } + /// Open the real iCloud-backed scope without writing onboarding region + /// defaults or requesting location. A failed open stays on the intro and + /// offers an explicit retry; success resolves the launch gate immediately + /// while CloudKit imports continue through the live store. + private func joinExistingData() { + guard !intro.isJoiningExistingData else { return } + intro.activity = .joiningExistingData + Task { + do { + try await model.joinExistingData() + gate.complete() + } catch is CancellationError { + intro.activity = .browsing + } catch { + intro.activity = .failed(.init(flow: .joinExistingData, error: error)) + Self.logger(attachments: [.error(error, name: "join-existing-error")]) { + .joinExistingDataFailed(description: error.localizedDescription) + } + } + } + } + // MARK: - Restore from backup private func handleRestoreSelection(_ result: Result) { @@ -393,8 +439,9 @@ public struct OnboardingView: View { /// Import the chosen backup (a fresh install, so `.replace` mirrors the file /// exactly), then skip the manual pick/customize steps straight to the - /// location ask. Restoring is the user committing to their real data, so - /// this is one of the two places the store gets opened. On failure — + /// location ask on a participating iPhone/iPad or finish immediately on + /// management-only Catalyst. Restoring is the user committing to their real + /// data, so this is one of the two places the store gets opened. On failure — /// including a store that won't open — surface an alert and stay in the /// intro, where they can retry or continue manually. private func restore(from url: URL) { @@ -405,7 +452,11 @@ public struct OnboardingView: View { let scope = try await model.resolveScope() _ = try await scope.services.backup.importBackup(from: url, strategy: .replace) intro.activity = .browsing - phase = .location + #if targetEnvironment(macCatalyst) + finish(enableLocation: false) + #else + phase = .location + #endif } catch { intro.activity = .failed(.init(flow: .restoreBackup, error: error)) Self.logger(attachments: [.error(error, name: "restore-error")]) { @@ -419,10 +470,10 @@ public struct OnboardingView: View { /// What the onboarding intro is doing, and how it went. /// /// One value rather than a pair of "is running" flags beside a loose error: -/// restoring a backup and building a demo each take over the whole screen, so -/// only one can be underway, and a failure always belongs to whichever one -/// produced it. As separate properties, "restoring *and* building" and "failed -/// with no error" were both spellable. +/// restoring, joining iCloud data, and building a demo each take over the whole +/// screen, so only one can be underway, and a failure always belongs to +/// whichever one produced it. As separate properties, "restoring *and* +/// building" and "failed with no error" were both spellable. /// /// `@Observable` for the same reason `SaveErrorAlertState` is: the activity /// stays the single source of truth while `isShowingFailure` gives @@ -434,6 +485,7 @@ final class OnboardingIntroState { enum Activity { case browsing case restoringBackup + case joiningExistingData case buildingDemo case failed(Failure) } @@ -442,10 +494,11 @@ final class OnboardingIntroState { /// message, so the view formats it where it presents it — and anything /// else that wants to inspect it still can. struct Failure { - /// Which of the intro's two ways forward failed, since they say + /// Which of the intro's long-running ways forward failed, since they say /// different things about it. - enum Flow { + enum Flow: Equatable { case restoreBackup + case joinExistingData case demo } @@ -465,6 +518,11 @@ final class OnboardingIntroState { return false } + var isJoiningExistingData: Bool { + if case .joiningExistingData = activity { return true } + return false + } + var failure: Failure? { if case let .failed(failure) = activity { return failure } return nil @@ -486,26 +544,43 @@ struct OnboardingPage: Identifiable { let title: String let description: String - static let all: [OnboardingPage] = [ - OnboardingPage( - id: "welcome", - symbol: "globe.americas.fill", - title: String(localized: .onboardingWelcomeTitle), - description: String(localized: .onboardingWelcomeDescription), - ), - OnboardingPage( - id: "automatic", - symbol: "location.fill.viewfinder", - title: String(localized: .onboardingAutomaticTitle), - description: String(localized: .onboardingAutomaticDescription), - ), - OnboardingPage( - id: "privacy", - symbol: "lock.shield.fill", - title: String(localized: .onboardingPrivacyTitle), - description: String(localized: .onboardingPrivacyDescription), - ), - ] + static func pages(supportsRecording: Bool) -> [OnboardingPage] { + var pages = [ + OnboardingPage( + id: "welcome", + symbol: "globe.americas.fill", + title: String(localized: .onboardingWelcomeTitle), + description: String(localized: .onboardingWelcomeDescription), + ), + ] + if supportsRecording { + pages.append( + OnboardingPage( + id: "automatic", + symbol: "location.fill.viewfinder", + title: String(localized: .onboardingAutomaticTitle), + description: String(localized: .onboardingAutomaticDescription), + ), + ) + } + pages.append( + OnboardingPage( + id: "privacy", + symbol: "lock.shield.fill", + title: String(localized: .onboardingPrivacyTitle), + description: String(localized: .onboardingPrivacyDescription), + ), + ) + return pages + } + + static var currentPlatform: [OnboardingPage] { + #if targetEnvironment(macCatalyst) + pages(supportsRecording: false) + #else + pages(supportsRecording: true) + #endif + } } #if DEBUG diff --git a/Where/WhereUI/Sources/PhoneMainTabs.swift b/Where/WhereUI/Sources/PhoneMainTabs.swift new file mode 100644 index 00000000..a275f40d --- /dev/null +++ b/Where/WhereUI/Sources/PhoneMainTabs.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// The compact-width logged-in interface, preserving Where's three fixed +/// iPhone tabs. +struct PhoneMainTabs: View { + let report: YearReportModel + + @State private var selection = MainSection.locations + + var body: some View { + TabView(selection: $selection) { + Tab( + MainSection.locations.title, + systemImage: MainSection.locations.systemImage, + value: MainSection.locations, + ) { + LocationsView(report: report) + .reportingDeveloperTabBarInset() + } + + Tab( + MainSection.year.title, + systemImage: MainSection.year.systemImage, + value: MainSection.year, + ) { + YearView(report: report) + .reportingDeveloperTabBarInset() + } + + Tab( + MainSection.settings.title, + systemImage: MainSection.settings.systemImage, + value: MainSection.settings, + ) { + SettingsView(report: report) + .reportingDeveloperTabBarInset() + } + } + // Keep the tab bar fixed — don't minimize it as content scrolls. + .tabBarMinimizeBehavior(.never) + } +} + +#if DEBUG + #Preview { + PhoneMainTabs(report: PreviewSupport.loadedYearReportModel()) + .environment(PreviewSupport.loadedModel()) + .environment(PreviewSupport.loadedSession()) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index adfd283a..a148659d 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -636,6 +636,9 @@ .europeanUnion: 4, .other: 2, ], + appearances: [:], + generatedAt: day, + surface: nil, ) } } diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index b14cf0fc..0db4140e 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -1575,6 +1575,42 @@ } } }, + "onboarding.joinExistingData" : { + "comment" : "Button on the onboarding intro that opens the user's existing iCloud-backed Where data without creating starter data or enabling location.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Join Existing iCloud Data" + } + } + } + }, + "onboarding.joinExistingError.title" : { + "comment" : "Alert title shown when the app can't open the user's existing iCloud-backed data during onboarding.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't Join iCloud Data" + } + } + } + }, + "onboarding.joiningExistingData" : { + "comment" : "Loading caption shown while onboarding opens the user's existing iCloud-backed data.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Joining your iCloud data…" + } + } + } + }, "onboarding.location.description" : { "extractionState" : "manual", "localizations" : { @@ -1664,7 +1700,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Restore from a backup" + "value" : "Restore Backup" } } } @@ -3745,6 +3781,28 @@ } } }, + "settings.devices.empty.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Where on an iPhone or iPad using the same iCloud account to add a recording device." + } + } + } + }, + "settings.devices.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Recording Devices" + } + } + } + }, "settings.devices.error.title" : { "extractionState" : "manual", "localizations" : { @@ -4254,6 +4312,90 @@ } } }, + "settings.menuBar.approval.footer" : { + "comment" : "Shown below the Mac menu bar login-item controls when macOS still requires approval.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow Where in Login Items in System Settings to finish enabling it." + } + } + } + }, + "settings.menuBar.enabled" : { + "comment" : "Toggle label for the native Mac menu bar companion.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Where in the menu bar" + } + } + } + }, + "settings.menuBar.error.title" : { + "comment" : "Alert title when registering or unregistering the Mac menu bar login item fails.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn’t Update Menu Bar Item" + } + } + } + }, + "settings.menuBar.footer" : { + "comment" : "Description of the glance data shown by the Mac menu bar companion.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows today’s observed regions and year-to-date day counts without opening Where." + } + } + } + }, + "settings.menuBar.header" : { + "comment" : "Header for Mac-specific menu bar settings.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu Bar" + } + } + } + }, + "settings.menuBar.openLoginItems" : { + "comment" : "Button that opens the macOS Login Items settings pane.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Login Items Settings" + } + } + } + }, + "settings.menuBar.unavailable.footer" : { + "comment" : "Shown when the embedded Mac menu bar helper cannot be found.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The menu bar companion isn’t available in this build." + } + } + } + }, "settings.permissionAlert.message" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift index 71ec0fcb..9ca1140e 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift @@ -130,7 +130,7 @@ final class DevicesSettingsModel { } return DeviceSettingsRowModel( configuration: configuration, - isCurrent: configuration.id == session.currentRecordingDeviceID, + isCurrent: session.currentRecordingDeviceID == configuration.id, ) } } diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift index ef6ba39d..dde34a33 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift @@ -10,6 +10,9 @@ struct DevicesSettingsView: View { @Environment(WhereSession.self) private var session @Environment(\.openURL) private var openURL @State private var model: DevicesSettingsModel + #if targetEnvironment(macCatalyst) + @State private var menuBar = MenuBarSettingsModel() + #endif private let loadsLiveData: Bool init(session: WhereSession, focus: SettingsFocus? = nil) { @@ -38,8 +41,14 @@ struct DevicesSettingsView: View { var body: some View { @Bindable var session = session @Bindable var model = model + #if targetEnvironment(macCatalyst) + @Bindable var menuBar = menuBar + #endif SettingsFocusScope(focus: focus) { Form { + #if targetEnvironment(macCatalyst) + MenuBarSettingsSection(model: menuBar) + #endif switch model.state { case .idle, .loading: Section { @@ -61,8 +70,20 @@ struct DevicesSettingsView: View { } } case .loaded: - ForEach(model.rows) { row in - DeviceSettingsSection(model: model, row: row) + if model.rows.isEmpty { + Section { + ContentUnavailableView( + String(localized: .settingsDevicesEmptyTitle), + systemImage: "iphone", + description: Text( + String(localized: .settingsDevicesEmptyDescription), + ), + ) + } + } else { + ForEach(model.rows) { row in + DeviceSettingsSection(model: model, row: row) + } } } } diff --git a/Where/WhereUI/Sources/Settings/MenuBarSettingsModel.swift b/Where/WhereUI/Sources/Settings/MenuBarSettingsModel.swift new file mode 100644 index 00000000..34123420 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/MenuBarSettingsModel.swift @@ -0,0 +1,100 @@ +#if targetEnvironment(macCatalyst) + import Observation + import ServiceManagement + + /// Mirrors the embedded login item's real Service Management state and + /// applies the user's enable/disable request. + @MainActor + @Observable + final class MenuBarSettingsModel { + /// The states Settings needs to distinguish without exposing + /// `SMAppService` to the view. + enum Status: Equatable { + case disabled + case enabled + case requiresApproval + case unavailable + + var isRegistered: Bool { + switch self { + case .enabled, .requiresApproval: + true + case .disabled, .unavailable: + false + } + } + } + + private let service = SMAppService.loginItem(identifier: "com.stuff.where.menubar") + + private(set) var status: Status + private(set) var isApplying = false + private(set) var errorMessage: String? + var isEnabled: Bool + + var isShowingError: Bool { + get { errorMessage != nil } + set { + if !newValue { + errorMessage = nil + } + } + } + + init() { + let status = Self.status(for: service.status) + self.status = status + isEnabled = status.isRegistered + } + + func refresh() { + status = Self.status(for: service.status) + isEnabled = status.isRegistered + } + + func applyRequestedState() async { + let serviceStatus = Self.status(for: service.status) + guard isEnabled != serviceStatus.isRegistered else { + status = serviceStatus + return + } + + isApplying = true + defer { + isApplying = false + refresh() + } + + do { + if isEnabled { + try service.register() + } else { + try await service.unregister() + } + } catch is CancellationError { + return + } catch { + errorMessage = error.localizedDescription + } + } + + func openLoginItemsSettings() { + SMAppService.openSystemSettingsLoginItems() + } + + private static func status(for status: SMAppService.Status) -> Status { + switch status { + case .notRegistered: + .disabled + case .enabled: + .enabled + case .requiresApproval: + .requiresApproval + case .notFound: + .unavailable + @unknown default: + .unavailable + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Settings/MenuBarSettingsSection.swift b/Where/WhereUI/Sources/Settings/MenuBarSettingsSection.swift new file mode 100644 index 00000000..9b83fd3f --- /dev/null +++ b/Where/WhereUI/Sources/Settings/MenuBarSettingsSection.swift @@ -0,0 +1,65 @@ +#if targetEnvironment(macCatalyst) + import SwiftUI + + /// Mac-only control for the native, embedded menu-bar login item. + struct MenuBarSettingsSection: View { + @Bindable var model: MenuBarSettingsModel + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + Section { + Toggle( + String(localized: .settingsMenuBarEnabled), + isOn: $model.isEnabled, + ) + .disabled(model.isApplying || model.status == .unavailable) + + if model.status == .requiresApproval { + Button( + String(localized: .settingsMenuBarOpenLoginItems), + action: model.openLoginItemsSettings, + ) + } + } header: { + Text(String(localized: .settingsMenuBarHeader)) + } footer: { + Text(footer) + } + .task(id: model.isEnabled) { + await model.applyRequestedState() + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + model.refresh() + } + } + .alert( + String(localized: .settingsMenuBarErrorTitle), + isPresented: $model.isShowingError, + presenting: model.errorMessage, + ) { _ in + } message: { message in + Text(message) + } + } + + private var footer: String { + switch model.status { + case .disabled, .enabled: + String(localized: .settingsMenuBarFooter) + case .requiresApproval: + String(localized: .settingsMenuBarApprovalFooter) + case .unavailable: + String(localized: .settingsMenuBarUnavailableFooter) + } + } + } + + #if DEBUG + #Preview { + Form { + MenuBarSettingsSection(model: MenuBarSettingsModel()) + } + } + #endif +#endif diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index ae77b3c5..f543c185 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -184,10 +184,12 @@ struct SettingsView: View { private func subtitle(for destination: SettingsDestination) -> String? { switch destination { case .devices: - LocationStatusRow.statusTitle( - status: session.authorizationStatus, - isTracking: session.isTracking, - ) + session.supportsLocalRecording + ? LocationStatusRow.statusTitle( + status: session.authorizationStatus, + isTracking: session.isTracking, + ) + : nil case .year: report.selectedYear.formatted(.number.grouping(.never)) case .attachments, .loggedDays, .regions, .alerts, .appearance, .data, .about: diff --git a/Where/WhereUI/Sources/Widgets/MacSummaryWidgetView.swift b/Where/WhereUI/Sources/Widgets/MacSummaryWidgetView.swift new file mode 100644 index 00000000..f06be745 --- /dev/null +++ b/Where/WhereUI/Sources/Widgets/MacSummaryWidgetView.swift @@ -0,0 +1,97 @@ +import SnapshotKit +import SwiftUI +import WhereCore + +/// Combined Mac widget content: today's observed regions beside year-to-date +/// day counts, rendered entirely from the app-published snapshot. +public struct MacSummaryWidgetView: View { + public enum Layout: Sendable { + case compact + case wide + } + + private let snapshot: WidgetSnapshot + private let layout: Layout + + public init(snapshot: WidgetSnapshot, layout: Layout) { + self.snapshot = snapshot + self.layout = layout + } + + @Environment(\.stylesheet) private var stylesheet + + public var body: some View { + switch layout { + case .compact: + VStack(spacing: stylesheet.spacing.small) { + TodayWidgetView(snapshot: snapshot) + Divider() + YearTotalsWidgetView(snapshot: snapshot, maxRows: 1) + } + case .wide: + HStack(spacing: stylesheet.spacing.medium) { + TodayWidgetView(snapshot: snapshot) + Divider() + YearTotalsWidgetView(snapshot: snapshot, maxRows: 3) + } + } + } +} + +#if DEBUG + extension MacSummaryWidgetView: SnapshotProviding { + public static var snapshots: [SnapshotCase] { + let snapshot = PreviewSupport.sampleWidgetSnapshot( + dayRegions: [.california], + totals: [.california: 132, .newYork: 41, .canada: 9], + ) + return [ + whereSnapshot( + name: "Wide", + configurations: SnapshotConfiguration.combinations( + devices: [ + .init( + name: "MacMedium", + size: .fixed(CGSize(width: 338, height: 158)), + ), + ], + colorSchemes: [.light, .dark], + ), + settle: .immediate, + ) { + MacSummaryWidgetView(snapshot: snapshot, layout: .wide) + }, + whereSnapshot( + name: "Compact", + configurations: SnapshotConfiguration.combinations( + devices: [ + .init( + name: "MacSmall", + size: .fixed(CGSize(width: 158, height: 158)), + ), + ], + colorSchemes: [.light, .dark], + ), + settle: .immediate, + ) { + MacSummaryWidgetView(snapshot: snapshot, layout: .compact) + }, + ] + } + } + + #Preview { + MacSummaryWidgetView.snapshotPreviews + } +#endif + +#if DEBUG + extension MacSummaryWidgetView: WhereFlyoverProviding { + static let flyoverData = WhereFlyoverData.snapshots( + MacSummaryWidgetView.self, + title: "Mac Summary Widget", + viewport: .fixed(CGSize(width: 338, height: 158)), + navigationContainer: .none, + ) + } +#endif diff --git a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift index f3b55a29..471f9ecf 100644 --- a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift +++ b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift @@ -4,18 +4,72 @@ import Testing @MainActor struct CurrentRecordingDeviceProviderTests { - @Test func persistsOneInstallationIdentity() throws { + @Test func phonePersistsOneInstallationIdentityAndDefaultsOn() throws { let suiteName = "CurrentRecordingDeviceProviderTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let first = CurrentRecordingDeviceProvider.current(defaults: defaults) - let second = CurrentRecordingDeviceProvider.current(defaults: defaults) + let first = CurrentRecordingDeviceProvider.participation( + defaults: defaults, + idiom: .phone, + ) + let second = CurrentRecordingDeviceProvider.participation( + defaults: defaults, + idiom: .phone, + ) + let firstDevice = try #require(first.currentDevice) #expect(first == second) + #expect(first.defaultEnabledForNewInstallation) #expect(defaults.dictionaryRepresentation().values.contains { - ($0 as? String) == first.id.rawValue.uuidString + ($0 as? String) == firstDevice.id.rawValue.uuidString }) - #expect(first.systemName.isEmpty == false) + #expect(firstDevice.systemName.isEmpty == false) } + + @Test func tabletParticipatesButDefaultsOff() throws { + let suiteName = "CurrentRecordingDeviceProviderTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let participation = CurrentRecordingDeviceProvider.participation( + defaults: defaults, + idiom: .pad, + ) + + #expect(participation.currentDevice?.kind == .tablet) + #expect(participation.defaultEnabledForNewInstallation == false) + } + + @Test func macIsManagementOnlyAndDoesNotMintAnIdentity() throws { + let suiteName = "CurrentRecordingDeviceProviderTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let participation = CurrentRecordingDeviceProvider.participation( + defaults: defaults, + idiom: .mac, + ) + + #expect(participation == .managementOnly) + let persistedDefaults = defaults.persistentDomain(forName: suiteName) ?? [:] + #expect(persistedDefaults.isEmpty) + } + + @Test func demoKeepsAManagementOnlyHostManagementOnly() { + let participation = CurrentRecordingDeviceProvider.demoParticipation( + supportsLocalRecording: false, + ) + + #expect(participation == .managementOnly) + } + + #if targetEnvironment(macCatalyst) + @Test func catalystDemoUsesManagementOnlyParticipationForItsCurrentHost() { + #expect( + CurrentRecordingDeviceProvider.demoParticipationForCurrentHost + == .managementOnly, + ) + } + #endif } diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift index 38975aea..271cb799 100644 --- a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift +++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift @@ -16,7 +16,10 @@ struct DevicesSettingsModelTests { let services = WhereServices( store: store, locationSource: ScriptedLocationSource(authorizationStatus: .always), - currentDevice: .preview, + recordingParticipation: .recording( + device: .preview, + defaultEnabledForNewInstallation: true, + ), now: { Self.now }, ) let preferences = makePreferences() diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift index 89bb0fdd..1ec44860 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,121 @@ 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 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) + } } diff --git a/Where/WhereUI/Tests/WhereSessionTests.swift b/Where/WhereUI/Tests/WhereSessionTests.swift index fad35eb5..82c6b43d 100644 --- a/Where/WhereUI/Tests/WhereSessionTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTests.swift @@ -158,7 +158,7 @@ private actor SpyWidgetRefresher: WidgetTimelineRefreshing { publishedSnapshots.last } - func publish(_ snapshot: WidgetSnapshot) async { + func publish(_ snapshot: WidgetSnapshot) async throws { publishedSnapshots.append(snapshot) } } diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift index d112b8b5..c79fd44b 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -71,6 +71,61 @@ struct WhereSessionTrackingTests { #expect(!session.permissionDenied) } + @Test func newTabletInstallationDefaultsLocalRecordingOff() async throws { + let store = try SwiftDataStore.inMemory() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + recordingParticipation: .recording( + device: .preview, + defaultEnabledForNewInstallation: false, + ), + ) + let session = WhereSession(services: services, preferences: makePreferences()) + + await session.start() + + #expect(session.isTracking == false) + #expect(try await session.recordingDevices().first?.isEnabled == false) + } + + @Test func migratedTabletInstallationKeepsLegacyRecordingIntent() async throws { + let preferences = makePreferences() + preferences.hasOnboarded = true + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(authorizationStatus: .always), + recordingParticipation: .recording( + device: .preview, + defaultEnabledForNewInstallation: false, + ), + ) + let session = WhereSession(services: services, preferences: preferences) + + await session.start() + + #expect(session.isTracking) + #expect(try await session.recordingDevices().first?.isEnabled == true) + } + + @Test func managementOnlySessionNeverCreatesALocalRecordingDevice() async throws { + let store = try SwiftDataStore.inMemory() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + recordingParticipation: .managementOnly, + ) + let session = WhereSession(services: services, preferences: makePreferences()) + + await session.start() + + #expect(session.supportsLocalRecording == false) + #expect(session.currentRecordingDeviceID == nil) + #expect(session.isTracking == false) + #expect(try await store.recordingDevices().isEmpty) + #expect(try await store.recordingPolicyChanges().isEmpty) + } + @Test func stoppingTrackingPersistsAcrossLaunches() async throws { let preferences = makePreferences() let (session, _) = try makeSession(status: .always, preferences: preferences) @@ -96,25 +151,26 @@ struct WhereSessionTrackingTests { let preferences = makePreferences() preferences.wantsTracking = false let session = WhereSession(services: services, preferences: preferences) + let currentDeviceID = try #require(session.currentRecordingDeviceID) let enabling = Task { try await session.setRecordingEnabled( true, - for: session.currentRecordingDeviceID, + for: currentDeviceID, ) } await waitUntil { source.isAwaitingPermission } _ = try await session.setRecordingEnabled( false, - for: session.currentRecordingDeviceID, + for: currentDeviceID, ) source.resolvePermission(as: .always) _ = try await enabling.value let current = try #require( try await session.recordingDevices() - .first(where: { $0.id == session.currentRecordingDeviceID }), + .first(where: { $0.id == currentDeviceID }), ) #expect(current.isEnabled == false) #expect(current.device.status == .off) diff --git a/Where/WhereUI/Tests/WidgetSnapshotRankingTests.swift b/Where/WhereUI/Tests/WidgetSnapshotRankingTests.swift index 13060659..93c86cb7 100644 --- a/Where/WhereUI/Tests/WidgetSnapshotRankingTests.swift +++ b/Where/WhereUI/Tests/WidgetSnapshotRankingTests.swift @@ -8,7 +8,15 @@ struct WidgetSnapshotRankingTests { dayRegions: Set, totals: [Region: Int], ) -> WidgetSnapshot { - WidgetSnapshot(day: .now, year: 2026, dayRegions: dayRegions, totals: totals) + WidgetSnapshot( + day: .now, + year: 2026, + dayRegions: dayRegions, + totals: totals, + appearances: [:], + generatedAt: nil, + surface: nil, + ) } @Test func rankedTotalsOrderAndCapMatchTheApp() { diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md index 24797523..26e379a0 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 → atomically writes JSON → posts the advisory WhereSurface Darwin + notification → calls `WidgetCenter.reloadAllTimelines()`. 2. The provider reads the JSON on each timeline request and schedules `.after(nextMidnight)` so WidgetKit re-queries even without an app reload. ## Invariants - **Read-only App Group access** — only the app writes `widget-snapshot.json`. +- **The bundle is platform-curated.** iPhone/iPad expose the existing Today and + Day Counts configurations; Mac Catalyst exposes one combined summary + configuration in small and medium families. - **No stale-day invalidation in the provider.** A snapshot whose `day` rolled past today is still shown until the app republishes — intentional. - In-widget strings come from WhereUI (shared views + `WhereFormat`); the diff --git a/Where/WhereWidgets/README.md b/Where/WhereWidgets/README.md index 8b82b4f1..694a5cae 100644 --- a/Where/WhereWidgets/README.md +++ b/Where/WhereWidgets/README.md @@ -14,8 +14,9 @@ WidgetKit configuration, the timeline provider, and family-specific layout. | Widget | Kind | Families | |--------|------|----------| -| **Today** | `com.stuff.where.widgets.today` | small, inline, circular | -| **Day Counts** | `com.stuff.where.widgets.yearTotals` | small, medium, rectangular | +| **Today** (iPhone/iPad) | `com.stuff.where.widgets.today` | small, inline, circular | +| **Day Counts** (iPhone/iPad) | `com.stuff.where.widgets.yearTotals` | small, medium, rectangular | +| **Where Summary** (Mac) | `com.stuff.where.widgets.macSummary` | small, medium | ## Data flow @@ -41,11 +42,13 @@ app never wakes. ## Installation -`WhereWidgets` is a Tuist app-extension target in +`WhereWidgets` is a multi-destination Tuist app-extension target in [`Project.swift`](../../Project.swift) (bundle ID `com.stuff.where.widgets`). It depends on **WhereCore**, **WhereUI**, **RegionKit** (for the `Region` model its snapshot fixtures use), and **PeriscopeCore**. The main **Where** app embeds the extension and shares the App Group entitlement. +The bundle declaration exposes only the combined Today + year-to-date summary +when compiled for Mac Catalyst. ## Previews diff --git a/Where/WhereWidgets/Resources/Localizable.xcstrings b/Where/WhereWidgets/Resources/Localizable.xcstrings index 2acc1aea..1fea5f34 100644 --- a/Where/WhereWidgets/Resources/Localizable.xcstrings +++ b/Where/WhereWidgets/Resources/Localizable.xcstrings @@ -1,6 +1,30 @@ { "sourceLanguage" : "en", "strings" : { + "widget.gallery.macSummary.description" : { + "comment" : "Widget gallery description for the Mac summary widget.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today’s observed regions and year-to-date day counts." + } + } + } + }, + "widget.gallery.macSummary.name" : { + "comment" : "Widget gallery name for the Mac summary widget.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where Summary" + } + } + } + }, "widget.gallery.today.description" : { "comment" : "Widget gallery description for the Today widget.", "extractionState" : "manual", diff --git a/Where/WhereWidgets/Sources/MacSummaryWidget.swift b/Where/WhereWidgets/Sources/MacSummaryWidget.swift new file mode 100644 index 00000000..88c96737 --- /dev/null +++ b/Where/WhereWidgets/Sources/MacSummaryWidget.swift @@ -0,0 +1,55 @@ +#if targetEnvironment(macCatalyst) + import SwiftUI + import WhereUI + import WidgetKit + + /// Mac-only combined glance: today's regions and the leading year-to-date + /// day counts in one small or medium widget. + struct MacSummaryWidget: Widget { + static let kind = "com.stuff.where.widgets.macSummary" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: WhereWidgetProvider()) { entry in + MacSummaryWidgetContent(entry: entry) + .whereBroadwayRoot( + regionStyles: RegionStyleResolver( + appearances: entry.snapshot.appearances, + ), + ) + } + .configurationDisplayName(String(localized: .widgetGalleryMacSummaryName)) + .description(String(localized: .widgetGalleryMacSummaryDescription)) + .supportedFamilies([.systemSmall, .systemMedium]) + } + } + + private struct MacSummaryWidgetContent: View { + @Environment(\.widgetFamily) private var family + + let entry: WhereWidgetEntry + + var body: some View { + MacSummaryWidgetView( + snapshot: entry.snapshot, + layout: family == .systemSmall ? .compact : .wide, + ) + .containerBackground(.background, for: .widget) + } + } + + #if DEBUG + #Preview("Small", as: .systemSmall) { + MacSummaryWidget() + } timeline: { + WhereWidgetEntry.sample + WhereWidgetEntry.previewEmpty + } + + #Preview("Medium", as: .systemMedium) { + MacSummaryWidget() + } timeline: { + WhereWidgetEntry.sample + WhereWidgetEntry.previewEmpty + } + #endif +#endif diff --git a/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift b/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift index 4a33f6e7..c88217f5 100644 --- a/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift +++ b/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift @@ -7,15 +7,27 @@ import WidgetKit @main struct WhereWidgetsBundle: WidgetBundle { var body: some Widget { - TodayWidget() - YearTotalsWidget() + #if targetEnvironment(macCatalyst) + MacSummaryWidget() + #else + TodayWidget() + YearTotalsWidget() + #endif } } #if DEBUG - #Preview("Where widgets", as: .systemSmall) { - TodayWidget() - } timeline: { - WhereWidgetEntry.sample - } + #if targetEnvironment(macCatalyst) + #Preview("Where widgets", as: .systemMedium) { + MacSummaryWidget() + } timeline: { + WhereWidgetEntry.sample + } + #else + #Preview("Where widgets", as: .systemSmall) { + TodayWidget() + } timeline: { + WhereWidgetEntry.sample + } + #endif #endif diff --git a/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift b/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift index 59ea9f05..76a7fb20 100644 --- a/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift +++ b/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift @@ -18,6 +18,9 @@ enum WidgetSnapshotFixtures { year: calendar.component(.year, from: day), dayRegions: dayRegions, totals: totals, + appearances: [:], + generatedAt: referenceDate, + surface: nil, ) } diff --git a/Where/WhereWidgets/WhereWidgets-MacCatalyst.entitlements b/Where/WhereWidgets/WhereWidgets-MacCatalyst.entitlements new file mode 100644 index 00000000..3c728f04 --- /dev/null +++ b/Where/WhereWidgets/WhereWidgets-MacCatalyst.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.stuff.where + + + From 9e6eb0ec549c0245062d44a5970053868f1cab19 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 10:11:48 -0700 Subject: [PATCH 2/3] Coordinate shared snapshot access --- Project.swift | 8 +- Where/AGENTS.md | 6 +- Where/WhereCore/README.md | 6 +- .../Sources/Persistence/SwiftDataStore.swift | 8 +- .../Sources/Widgets/WidgetSnapshotStore.swift | 21 ++--- Where/WhereSurface/AGENTS.md | 6 +- Where/WhereSurface/README.md | 8 +- .../Sources/WhereSurfaceFileCoordinator.swift | 76 +++++++++++++++++++ .../Sources/WhereSurfaceStore.swift | 5 +- .../WhereSurfaceFileCoordinatorTests.swift | 47 ++++++++++++ Where/WhereWidgets/AGENTS.md | 4 +- 11 files changed, 160 insertions(+), 35 deletions(-) create mode 100644 Where/WhereSurface/Sources/WhereSurfaceFileCoordinator.swift create mode 100644 Where/WhereSurface/Tests/WhereSurfaceFileCoordinatorTests.swift diff --git a/Project.swift b/Project.swift index 5466beb8..fd678ef5 100644 --- a/Project.swift +++ b/Project.swift @@ -36,10 +36,10 @@ private let projectSettings: Settings = .settings( ], ) -/// App Group shared by the Where app, its widget extension, and its share -/// extension so every process sees the same on-disk SwiftData store (see -/// `SwiftDataStore.appGroupIdentifier`, which must match) and the widget -/// snapshot JSON. +/// App Group shared by the Where app and its supporting processes. The app and +/// share extension open the on-disk SwiftData store (see +/// `SwiftDataStore.appGroupIdentifier`); widgets and the menu-bar helper read +/// only the coordinated snapshot JSON. let whereAppGroupEntitlements: Entitlements = .dictionary([ "com.apple.security.application-groups": .array([.string("group.com.stuff.where")]), ]) diff --git a/Where/AGENTS.md b/Where/AGENTS.md index e4af575d..f4ac77e7 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -206,9 +206,9 @@ 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. A successful atomic publish posts the advisory Darwin -notification before WidgetKit reloads so the helper can refresh without -polling. +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 diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 018df04d..67ffd61b 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -121,9 +121,9 @@ one it belongs to rather than to a god-object: 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 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. + 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 diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 733118a6..1a7dd2ce 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -115,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 { diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift index dee220d6..499d58db 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift @@ -3,15 +3,15 @@ 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 @@ -45,11 +45,11 @@ public struct WidgetSnapshotStore: Sendable { 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 @@ -64,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/WhereSurface/AGENTS.md b/Where/WhereSurface/AGENTS.md index 928b260c..485c08fb 100644 --- a/Where/WhereSurface/AGENTS.md +++ b/Where/WhereSurface/AGENTS.md @@ -18,8 +18,10 @@ file complements the root [`AGENTS.md`](../../AGENTS.md) and feature - Keep `generatedAt` and `surface` optional in `WhereSurfaceDocument` so snapshots from older app versions decode. -- Treat `WhereSurfaceChangeNotification` as advisory and the atomically - replaced JSON file as authoritative. +- 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 diff --git a/Where/WhereSurface/README.md b/Where/WhereSurface/README.md index 97808dd4..095f7a5b 100644 --- a/Where/WhereSurface/README.md +++ b/Where/WhereSurface/README.md @@ -18,6 +18,8 @@ links `WhereCore`, `RegionKit`, SwiftData, CloudKit, or location services. 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. @@ -27,6 +29,6 @@ for an explicit user action. ## Testing -`WhereSurfaceTests` covers wire compatibility, Foundation-only decoding, and -file reads. The app's WhereCore tests cover construction, ranking, and -publication of the payload from real domain data. +`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/WhereSurfaceFileCoordinator.swift b/Where/WhereSurface/Sources/WhereSurfaceFileCoordinator.swift new file mode 100644 index 00000000..0c43d653 --- /dev/null +++ b/Where/WhereSurface/Sources/WhereSurfaceFileCoordinator.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Coordinates access to Where's App Group artifact across process boundaries. +/// +/// Each call creates an `NSFileCoordinator` for that operation. It serializes +/// reads and writes with participating widget and helper processes; atomic writes +/// additionally keep the authoritative JSON from ever being partially written. +public struct WhereSurfaceFileCoordinator: Sendable { + public init() {} + + /// Read the coordinated contents of `fileURL`, returning `nil` when the file + /// has not been published yet. + public func read(from fileURL: URL) throws -> Data? { + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinationError: NSError? + var accessError: (any Error)? + var data: Data? + + coordinator.coordinate( + readingItemAt: fileURL, + options: .withoutChanges, + error: &coordinationError, + ) { coordinatedURL in + do { + data = try Data(contentsOf: coordinatedURL) + } catch let error as NSError where Self.isMissingFile(error) { + data = nil + } catch { + accessError = error + } + } + + if let coordinationError { + guard Self.isMissingFile(coordinationError) else { + throw coordinationError + } + return nil + } + if let accessError { + throw accessError + } + return data + } + + /// Atomically update `fileURL` while holding a coordinated write claim. + public func write(_ data: Data, to fileURL: URL) throws { + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinationError: NSError? + var accessError: (any Error)? + + coordinator.coordinate( + writingItemAt: fileURL, + // Foundation reserves `.forReplacing` for replacing the coordinated + // item, not an atomic update of that item's contents. + options: [], + error: &coordinationError, + ) { coordinatedURL in + do { + try data.write(to: coordinatedURL, options: .atomic) + } catch { + accessError = error + } + } + + if let coordinationError { + throw coordinationError + } + if let accessError { + throw accessError + } + } + + private static func isMissingFile(_ error: NSError) -> Bool { + error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError + } +} diff --git a/Where/WhereSurface/Sources/WhereSurfaceStore.swift b/Where/WhereSurface/Sources/WhereSurfaceStore.swift index 6dcb40b4..ab3fc775 100644 --- a/Where/WhereSurface/Sources/WhereSurfaceStore.swift +++ b/Where/WhereSurface/Sources/WhereSurfaceStore.swift @@ -37,10 +37,7 @@ public struct WhereSurfaceStore: Sendable, WhereSurfaceReading { public func read() throws -> WhereSurfaceDocument? { let fileURL = directory.appending(path: Self.snapshotFileName) - guard FileManager.default.fileExists(atPath: fileURL.path) else { - return nil - } - let data = try Data(contentsOf: fileURL) + 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/WhereSurfaceFileCoordinatorTests.swift b/Where/WhereSurface/Tests/WhereSurfaceFileCoordinatorTests.swift new file mode 100644 index 00000000..8da3fa79 --- /dev/null +++ b/Where/WhereSurface/Tests/WhereSurfaceFileCoordinatorTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +import WhereSurface + +struct WhereSurfaceFileCoordinatorTests { + @Test func missingFileReturnsNil() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appending(path: "missing.json") + + let data = try WhereSurfaceFileCoordinator().read(from: fileURL) + + #expect(data == nil) + } + + @Test func writeThenReadRoundTrips() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appending(path: "snapshot.json") + let expected = Data("coordinated".utf8) + let coordinator = WhereSurfaceFileCoordinator() + + try coordinator.write(expected, to: fileURL) + + #expect(try coordinator.read(from: fileURL) == expected) + } + + @Test func writeReplacesExistingContents() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appending(path: "snapshot.json") + let coordinator = WhereSurfaceFileCoordinator() + try coordinator.write(Data("old".utf8), to: fileURL) + let replacement = Data("new".utf8) + + try coordinator.write(replacement, to: fileURL) + + #expect(try coordinator.read(from: fileURL) == replacement) + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appending(path: "WhereSurfaceFileCoordinatorTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } +} diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md index 26e379a0..374ac63b 100644 --- a/Where/WhereWidgets/AGENTS.md +++ b/Where/WhereWidgets/AGENTS.md @@ -22,8 +22,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Refresh contract 1. App commits a store change → `WidgetSnapshotPublisher` rebuilds the - snapshot → atomically writes JSON → posts the advisory WhereSurface Darwin - notification → calls `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. From e824345fb484ab27f90fd004cc6aa54a9eb0e8ef Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 16:19:51 -0700 Subject: [PATCH 3/3] Address device sync review feedback --- Project.swift | 2 + Where/Tools/upgrade-backup.rb | 46 ++++++++- Where/Where/AGENTS.md | 6 +- Where/Where/README.md | 10 +- Where/Where/Where-MacCatalyst.entitlements | 4 + Where/WhereCore/AGENTS.md | 14 +-- Where/WhereCore/README.md | 12 ++- .../Sources/Backup/BackupArchive.swift | 7 +- .../Sources/Backup/BackupService.swift | 7 +- .../Devices/DeviceRecordingController.swift | 45 +++++---- .../Sources/Persistence/SwiftDataStore.swift | 29 ++++++ .../Sources/Persistence/WhereStore.swift | 12 +++ .../WhereCore/Sources/RegionAttribution.swift | 94 ++++++++++++++----- Where/WhereCore/Sources/WhereServices.swift | 5 +- .../Widgets/WidgetSnapshotPublisher.swift | 19 +++- .../WhereCore/Tests/BackupServiceTests.swift | 65 ++++++++++++- .../Tests/LocationIngestorTests.swift | 7 ++ .../WhereCore/Tests/SwiftDataStoreTests.swift | 60 ++++++++++++ .../WhereCore/Tests/WhereServicesTests.swift | 7 ++ .../Tests/WidgetSnapshotPublisherTests.swift | 51 +++++++++- .../Sources/Logging/OnboardingViewLog.swift | 7 +- Where/WhereUI/Sources/Model/WhereModel.swift | 29 ++++-- .../Sources/Onboarding/OnboardingView.swift | 35 ++++--- .../Settings/DeviceSettingsSection.swift | 15 ++- .../Settings/DevicesSettingsModel.swift | 6 +- .../Tests/DevicesSettingsModelTests.swift | 17 ++++ Where/WhereUI/Tests/OnboardingTests.swift | 94 +++++++++++++++++++ Where/WhereUI/Tests/Support/TestStore.swift | 7 ++ 28 files changed, 600 insertions(+), 112 deletions(-) diff --git a/Project.swift b/Project.swift index fd678ef5..0aa314f0 100644 --- a/Project.swift +++ b/Project.swift @@ -49,6 +49,7 @@ let whereAppGroupEntitlements: Entitlements = .dictionary([ /// entitlement above: they write the shared local store and let the app's /// CloudKit-backed container publish those changes when it next opens. let whereAppEntitlements: Entitlements = .dictionary([ + "aps-environment": .string("development"), "com.apple.security.application-groups": .array([.string("group.com.stuff.where")]), "com.apple.developer.icloud-container-identifiers": .array([ .string("iCloud.com.stuff.where"), @@ -362,6 +363,7 @@ let project = Project( settings: .settings(base: [ "ASSETCATALOG_COMPILER_APPICON_NAME": "", "ENABLE_HARDENED_RUNTIME": "YES", + "REGISTER_APP_GROUPS": "YES", ]), ), .target( diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index 985c9916..e44e1556 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -24,10 +24,11 @@ # - Top level: ensures `dismissedIssues` / `trackedRegions` exist, synthesizes # `primaryRegions` from the tracked ids (null appearance, listed order) when # absent, adds empty device/policy tables, stamps legacy samples with null -# device provenance, and sets `formatVersion` to 3 (the current version). +# device provenance, normalizes ISO-8601 dates to lossless Unix epoch +# seconds, and sets `formatVersion` to 3 (the current version). # -# Idempotent: re-running on an already-upgraded archive is a no-op (it only -# touches legacy `date` / `key` fields and unmapped region ids). +# Idempotent: re-running on an already-upgraded archive produces the same +# manifest, including the numeric date representation. # # Usage (from the repo root): # ruby Where/Tools/upgrade-backup.rb INPUT.zip [OUTPUT.zip] @@ -61,6 +62,20 @@ "abruptChange" => %w[earlier later], }.freeze +# Every `Date` property reachable from `BackupArchive`. The v3 wire format uses +# Unix epoch seconds so subsecond policy/sample ordering survives a round trip. +DATE_FIELDS = Set.new(%w[ + exportedAt + timestamp + capturedAt + recordedAt + dismissedAt + registeredAt + lastSeenAt + archivedAt + effectiveAt +]).freeze + def die(message) warn "error: #{message}" exit 1 @@ -163,6 +178,30 @@ def upgrade_dismissals!(manifest) end end +def date_to_epoch_seconds(value) + return value if value.is_a?(Numeric) + + Time.iso8601(value).to_f +rescue ArgumentError, TypeError + die "could not parse date value: #{value.inspect}" +end + +def normalize_dates!(value) + case value + when Hash + value.each do |key, child| + value[key] = if DATE_FIELDS.include?(key) && !child.nil? + date_to_epoch_seconds(child) + else + normalize_dates!(child) + end + end + when Array + value.each { |element| normalize_dates!(element) } + end + value +end + def upgrade_manifest(manifest) warnings = [] upgrade_evidence!(manifest, warnings) @@ -184,6 +223,7 @@ def upgrade_manifest(manifest) end manifest["recordingDevices"] ||= [] manifest["recordingPolicyChanges"] ||= [] + normalize_dates!(manifest) manifest["formatVersion"] = CURRENT_FORMAT_VERSION warnings.uniq.each { |message| warn "warning: #{message}" } manifest diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index 2e5c16aa..3f43c5f8 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -51,9 +51,9 @@ layering, and the domain rules this target merely starts up. intents stack derives from whatever scope the launch resolves, in the `onServicesReady` hook. - **Only the app owns the CloudKit capability.** Keep its App Group, CloudKit - container (`iCloud.com.stuff.where`), and remote-notification background mode - together in `Project.swift`; widgets and the share extension stay App - Group-only and never open a CloudKit container. + container (`iCloud.com.stuff.where`), platform APNs entitlement, and + remote-notification background mode together in `Project.swift`; widgets and + the share extension stay App Group-only and never open a CloudKit container. - **Catalyst embeds, but never launches, the native `WhereMenuBar` helper.** `Project.swift` builds it first in the `Where-Catalyst` scheme and conditionally copies it to `Contents/Library/LoginItems`; Settings registers or unregisters diff --git a/Where/Where/README.md b/Where/Where/README.md index abe868bc..e231f238 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -51,11 +51,11 @@ with [`./Where/install`](../install) (macOS only, needs a signing team — see ## CloudKit rollout and device validation -The app target owns `iCloud.com.stuff.where` plus the remote-notification -background mode. Widgets and the share extension intentionally have only the -App Group entitlement: they write/read local shared artifacts, while the app's -single SwiftData container owns CloudKit mirroring. Debug uses `.localOnly`; -exercise sync with a Release-signed build. +The app target owns `iCloud.com.stuff.where` plus the platform APNs entitlement +and remote-notification background mode. Widgets and the share extension +intentionally have only the App Group entitlement: they write/read local shared +artifacts, while the app's single SwiftData container owns CloudKit mirroring. +Debug uses `.localOnly`; exercise sync with a Release-signed build. Before shipping a schema change: diff --git a/Where/Where/Where-MacCatalyst.entitlements b/Where/Where/Where-MacCatalyst.entitlements index f17a7a5a..9aaeabd5 100644 --- a/Where/Where/Where-MacCatalyst.entitlements +++ b/Where/Where/Where-MacCatalyst.entitlements @@ -2,6 +2,10 @@ + aps-environment + development + com.apple.developer.aps-environment + development com.apple.developer.icloud-container-identifiers iCloud.com.stuff.where diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 3af56cf2..12f9405b 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -78,9 +78,10 @@ internal shape. 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`, while local writes use their - journal/ingestor paths and `forIntents(sharingStoreOf:)` shares that publisher - rather than starting another observer or cache. + 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 @@ -98,9 +99,10 @@ internal shape. `LocationIngestor.captureTodayIfNeeded(now:)`. - **`DeviceRecordingController` owns automatic-recording policy and physical GPS state.** Keep policy events append-only, serialize mutations across - awaits, stamp every ingested GPS sample with the current installation id, - and apply `LocationHistoryReader` to every user-facing projection. Backups - alone read the lossless raw samples and full policy/device tables. + awaits, transform profile fields from the transaction's latest stored value, + stamp every ingested GPS sample with the current installation id, and apply + `LocationHistoryReader` to every user-facing projection. Backups alone read + the lossless raw samples and full policy/device tables. - **Management-only participation has no local recording identity.** Keep its ingestor inert and never register a current-device row; synced remote-device policy and profile edits remain available. diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 67ffd61b..74084c2d 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -24,11 +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. 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 — + 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 diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index 0b0eb970..1b724096 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -18,9 +18,10 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// `BackupService.readArchive`, which rejects any other version). /// /// v3 adds sample device provenance plus the synced recording-device and - /// append-only policy tables. There's no in-app decode fallback for an older - /// archive — it is reshaped out of band by `Tools/upgrade-backup.rb`, - /// matching the module's no-migration-on-read rule (see `AGENTS.md`). + /// append-only policy tables, and stores dates as lossless Unix epoch + /// seconds. There's no in-app decode fallback for an older archive — it is + /// reshaped out of band by `Tools/upgrade-backup.rb`, matching the module's + /// no-migration-on-read rule (see `AGENTS.md`). public static let currentFormatVersion = 3 public let formatVersion: Int diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 91dc8555..c9f711b5 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -56,14 +56,17 @@ public struct BackupService: Sendable { private static func makeEncoder() -> JSONEncoder { let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 + // Foundation's ISO-8601 strategy drops fractional seconds. Recording + // policy ordering and its sample cutoffs are subsecond-sensitive, so + // encode the `Date` value losslessly as Unix epoch seconds instead. + encoder.dateEncodingStrategy = .secondsSince1970 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] return encoder } private static func makeDecoder() -> JSONDecoder { let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .secondsSince1970 return decoder } diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index 95a1e839..6e16579f 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -81,7 +81,6 @@ public actor DeviceRecordingController { try requireActive() try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) - let device = try await store.recordingDevices().first(where: { $0.id == deviceID }) let changes = try await store.recordingPolicyChanges() let change = RecordingPolicyChange( id: UUID(), @@ -94,8 +93,8 @@ public actor DeviceRecordingController { ) try await store.perform { try await store.addRecordingPolicyChange(change) - if enabled, let device, device.archivedAt != nil { - try await store.setRecordingDevice(device.unarchived()) + if enabled { + try await store.updateRecordingDevice(deviceID) { $0.unarchived() } } } @@ -120,16 +119,12 @@ public actor DeviceRecordingController { defer { endExclusive() } try requireActive() try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) - guard let device = try await store.recordingDevices().first(where: { $0.id == deviceID }) - else { return try await configurationsLocked(includeArchived: false) } let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) - let renamed = device.renamed(trimmed.isEmpty ? nil : trimmed) - guard renamed != device else { - return try await configurationsLocked(includeArchived: false) - } try await store.perform { - try await store.setRecordingDevice(renamed) + try await store.updateRecordingDevice(deviceID) { + $0.renamed(trimmed.isEmpty ? nil : trimmed) + } } return try await configurationsLocked(includeArchived: false) } @@ -145,7 +140,7 @@ public actor DeviceRecordingController { defer { endExclusive() } try requireActive() try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) - guard let device = try await store.recordingDevices().first(where: { $0.id == deviceID }) + guard try await store.recordingDevices().contains(where: { $0.id == deviceID }) else { return try await configurationsLocked(includeArchived: false) } let date = now() @@ -161,7 +156,7 @@ public actor DeviceRecordingController { ) try await store.perform { try await store.addRecordingPolicyChange(change) - try await store.setRecordingDevice(device.archived(at: date)) + try await store.updateRecordingDevice(deviceID) { $0.archived(at: date) } } return try await configurationsLocked(includeArchived: false) } @@ -218,19 +213,21 @@ public actor DeviceRecordingController { let needsAcknowledgement = device.lastAppliedPolicyChangeID != latest.id || device.status != status let needsPeriodicCheckIn = checkIn.timeIntervalSince(device.lastSeenAt) >= 15 * 60 - let acknowledged = if needsAcknowledgement || needsPeriodicCheckIn { - device.acknowledging( - policyChangeID: latest.id, - status: status, - at: checkIn, - ) - } else { - device - } - if acknowledged != device { - try await store.perform { - try await store.setRecordingDevice(acknowledged) + var acknowledged = device + if needsAcknowledgement || needsPeriodicCheckIn { + let updated = try await store.perform { + try await store.updateRecordingDevice(currentDevice.id) { + $0.acknowledging( + policyChangeID: latest.id, + status: status, + at: max($0.lastSeenAt, checkIn), + ) + } + } + guard let updated else { + preconditionFailure("Current recording device disappeared during reconciliation.") } + acknowledged = updated } return RecordingDeviceConfiguration( device: acknowledged, diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 1a7dd2ce..54b4536d 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -608,6 +608,35 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } + public func updateRecordingDevice( + _ id: RecordingDeviceID, + transform: @Sendable (RecordingDevice) -> RecordingDevice, + ) async throws -> RecordingDevice? { + let context = mutationContext() + let rawID = id.rawValue + let records = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == rawID }), + ) + let candidates = records.compactMap { record -> (SDRecordingDevice, RecordingDevice)? in + guard let value = record.toValue() else { + Self.logFault(forCorrupt: record) + return nil + } + return (record, value) + } + guard let selected = candidates.max(by: { + $0.1.lastSeenAt < $1.1.lastSeenAt + }) else { return nil } + + let updated = transform(selected.1) + precondition(updated.id == id, "A recording-device update cannot change its identity.") + selected.0.update(from: updated) + for duplicate in records where duplicate !== selected.0 { + context.delete(duplicate) + } + return updated + } + public func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { let context = readContext() var descriptor = FetchDescriptor( diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index 2c93e4fd..0e2ca975 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -9,6 +9,7 @@ import RegionKit /// implementation has somewhere to surface I/O errors. /// /// All mutating methods (`add(sample:)`, `setRecordingDevice`, +/// `updateRecordingDevice`, /// `addRecordingPolicyChange`, `write(evidence:blob:)`, `setManualDay`, /// `clearManualDay`, `clear(in:)`, and the `EvidenceBlobStore` writers) /// MUST be called from inside a `perform { ... }` block — the block @@ -61,6 +62,17 @@ public protocol WhereStore: Sendable { /// inside `perform { ... }`. func setRecordingDevice(_ device: RecordingDevice) async throws + /// Transform the latest stored value for one device inside the current + /// transaction, returning the value that was written. Unlike a read followed + /// by ``setRecordingDevice(_:)``, this preserves fields another process or + /// CloudKit import changed before the transaction began. A missing device is + /// a no-op and returns `nil`. + @discardableResult + func updateRecordingDevice( + _ id: RecordingDeviceID, + transform: @Sendable (RecordingDevice) -> RecordingDevice, + ) async throws -> RecordingDevice? + /// Every append-only recording-policy event, oldest first. func recordingPolicyChanges() async throws -> [RecordingPolicyChange] diff --git a/Where/WhereCore/Sources/RegionAttribution.swift b/Where/WhereCore/Sources/RegionAttribution.swift index 5f9feea7..2bd658e3 100644 --- a/Where/WhereCore/Sources/RegionAttribution.swift +++ b/Where/WhereCore/Sources/RegionAttribution.swift @@ -18,10 +18,72 @@ final class RegionAttribution: RegionAttributing { var trackedIDs: Set } + /// Serializes every explicit and observed reconciliation. The widget's + /// remote-change path also reconciles before it publishes, so keeping the + /// read/rebuild/install sequence on one actor prevents an older rebuild + /// from landing after a newer tracked-region change. + private actor Reconciler { + private let store: any WhereStore + private let state: OSAllocatedUnfairLock + private var isReconciling = false + private var waiters: [CheckedContinuation] = [] + + init(store: any WhereStore, state: OSAllocatedUnfairLock) { + self.store = store + self.state = state + } + + func reconcile() async { + await beginExclusive() + defer { endExclusive() } + + let tracked: Set + do { + tracked = try await store.trackedRegions() + } catch { + // Degraded-but-handled: keep the last-good attributor rather + // than replacing it with an empty or partially read set. + RegionAttribution.logger { + .trackedRegionsReadFailed(description: String(describing: error)) + } + return + } + let ids = Set(tracked.map(\.rawValue)) + let changed = state.withLock { $0.trackedIDs != ids } + guard changed else { return } + // Canonical order so the rebuilt attributor's first-match priority + // is deterministic (see WhereServices.make). + let rebuilt = RegionAttribution.logger.measure(.rebuild, budget: .seconds(1)) { + RegionAttributor(for: Region.inCanonicalOrder(tracked)) + } + state.withLock { $0 = State(attributor: rebuilt, trackedIDs: ids) } + } + + /// Hold the reconciliation slot across the async store read and the + /// synchronous rebuild/install. Actor isolation alone is insufficient: + /// another call can otherwise enter while `trackedRegions()` suspends + /// and let an older read install after a newer one. + private func beginExclusive() async { + if isReconciling { + await withCheckedContinuation { waiters.append($0) } + } else { + isReconciling = true + } + } + + private func endExclusive() { + if waiters.isEmpty { + isReconciling = false + } else { + waiters.removeFirst().resume() + } + } + } + private static let logger = WhereLog.root(RegionAttributionLog.self) - private let store: any WhereStore private let state: OSAllocatedUnfairLock + private let reconciler: Reconciler /// Set once in `init` and only cancelled in `deinit`, so there's no /// concurrent access to guard. private nonisolated(unsafe) var observer: Task? @@ -33,11 +95,12 @@ final class RegionAttribution: RegionAttributing { /// store, so there's no flash of the wrong set at launch). /// - trackedIDs: the region ids `initial` was built from. init(store: any WhereStore, initial: RegionAttributor, trackedIDs: Set) { - self.store = store - state = OSAllocatedUnfairLock(initialState: State( + let state = OSAllocatedUnfairLock(initialState: State( attributor: initial, trackedIDs: trackedIDs, )) + self.state = state + reconciler = Reconciler(store: store, state: state) observer = Task { [weak self] in for await _ in store.changes() { await self?.reconcile() @@ -67,28 +130,9 @@ final class RegionAttribution: RegionAttributing { /// Re-read the tracked regions and rebuild the attributor when the set /// changed. Cheap when nothing changed (a fetch + a set compare); the file - /// parse runs only on an actual change. Serialized by the single observer - /// task; also exposed so callers/tests can reconcile deterministically. + /// parse runs only on an actual change. The reconciler actor serializes the + /// ordinary observer with explicit callers such as external publishing. func reconcile() async { - let tracked: Set - do { - tracked = try await store.trackedRegions() - } catch { - // Degraded-but-handled: keep the last-good attributor rather than - // silently freezing on an empty/stale set, and surface the failure so - // a persistent read error is observable instead of invisible. - Self.logger { .trackedRegionsReadFailed(description: String(describing: error)) } - return - } - let ids = Set(tracked.map(\.rawValue)) - let changed = state.withLock { $0.trackedIDs != ids } - guard changed else { return } - // Canonical order so the rebuilt attributor's first-match priority is - // deterministic (see WhereServices.make). Re-parsing every tracked - // region's GeoJSON is the expensive part, hence the span. - let rebuilt = Self.logger.measure(.rebuild, budget: .seconds(1)) { - RegionAttributor(for: Region.inCanonicalOrder(tracked)) - } - state.withLock { $0 = State(attributor: rebuilt, trackedIDs: ids) } + await reconciler.reconcile() } } diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index c80ef456..6fa4f606 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -295,7 +295,10 @@ public struct WhereServices: Sendable { // 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()) + await services.widgets.startObservingExternalChanges( + store.remoteChanges(), + beforePublishing: { await attribution.reconcile() }, + ) return services } diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift index 96a53621..e9033484 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift @@ -104,15 +104,22 @@ public actor WidgetSnapshotPublisher { } /// Rebuild after every store change imported from another process or - /// device. Local writes do not enter this stream: their journal/ingestor - /// paths already invoke the exact publish operation they need. - func startObservingExternalChanges(_ changes: AsyncStream) { + /// 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() } @@ -122,8 +129,10 @@ public actor WidgetSnapshotPublisher { /// 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() { - externalChangesTask?.cancel() + func stopObservingExternalChanges() async { + let task = externalChangesTask + task?.cancel() + await task?.value externalChangesTask = nil } diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index 8cd64bf4..3e5ebeac 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -6,9 +6,7 @@ import WhereCore struct BackupServiceTests { private static let calendar = WhereCoreTestSupport.calendar() - // Whole-second timestamps so the `.iso8601` date strategy (no - // fractional seconds) round-trips exactly. - private static let exportDate = Date(timeIntervalSince1970: 1_700_000_000) + private static let exportDate = Date(timeIntervalSince1970: 1_700_000_000.123_456) private static let evidenceWithBlobId = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! private static let evidenceNoBlobId = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! @@ -151,6 +149,63 @@ struct BackupServiceTests { #expect(result.blobs == blobs) } + @Test func rapidPolicyCutoffPreservesSubsecondOrderingAcrossArchiveRoundTrip() throws { + let service = BackupService() + let enabledAt = Date(timeIntervalSince1970: 1_700_000_000.125) + let disabledAt = enabledAt.addingTimeInterval(0.000_001) + let visibleSample = try LocationSample( + id: #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")), + timestamp: enabledAt.addingTimeInterval(0.000_000_5), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsVisit, + recordingDeviceID: Self.recordingDeviceID, + ) + let hiddenSample = try LocationSample( + id: #require(UUID(uuidString: "22222222-2222-2222-2222-222222222222")), + timestamp: disabledAt, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsVisit, + recordingDeviceID: Self.recordingDeviceID, + ) + // If these timestamps collapse, UUID tie-breaking selects the earlier + // enabled policy and exposes the sample at the disabled cutoff. + let policies = try [ + RecordingPolicyChange( + id: #require(UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")), + deviceID: Self.recordingDeviceID, + effectiveAt: enabledAt, + isEnabled: true, + ), + RecordingPolicyChange( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000000")), + deviceID: Self.recordingDeviceID, + effectiveAt: disabledAt, + isEnabled: false, + ), + ] + let samples = [visibleSample, hiddenSample] + let url = try service.makeArchiveFile( + samples: samples, + evidence: [], + manualDays: [], + recordingPolicyChanges: policies, + blobs: [:], + exportedAt: disabledAt, + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let archive = try service.readArchive(at: url).archive + + #expect(archive.recordingPolicyChanges == policies) + #expect(archive.samples == samples) + #expect(RecordingPolicyFilter.visibleSamples( + archive.samples, + policyChanges: archive.recordingPolicyChanges, + ).map(\.id) == [visibleSample.id]) + } + @Test func archiveNameIsDateAndTimeStamped() throws { let service = BackupService() let url = try service.makeArchiveFile( @@ -306,11 +361,11 @@ struct BackupServiceTests { ) let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 + encoder.dateEncodingStrategy = .secondsSince1970 let data = try encoder.encode(archive) let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .secondsSince1970 let decoded = try decoder.decode(BackupArchive.self, from: data) #expect(decoded == archive) diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index c475fae9..935abbda 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -544,6 +544,13 @@ private actor ToggleFailingStore: WhereStore { try await backing.setRecordingDevice(device) } + func updateRecordingDevice( + _ id: RecordingDeviceID, + transform: @Sendable (RecordingDevice) -> RecordingDevice, + ) async throws -> RecordingDevice? { + try await backing.updateRecordingDevice(id, transform: transform) + } + func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { try await backing.recordingPolicyChanges() } diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 60ec3087..8d53d18a 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -132,6 +132,66 @@ struct SwiftDataStoreTests { #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 { diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index ba7ff0ee..21758a8d 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -1471,6 +1471,13 @@ private actor ToggleFailingStore: WhereStore { try await backing.setRecordingDevice(device) } + func updateRecordingDevice( + _ id: RecordingDeviceID, + transform: @Sendable (RecordingDevice) -> RecordingDevice, + ) async throws -> RecordingDevice? { + try await backing.updateRecordingDevice(id, transform: transform) + } + func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { try await backing.recordingPolicyChanges() } diff --git a/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift b/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift index 6f3cacd9..20d3322c 100644 --- a/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift +++ b/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift @@ -58,6 +58,30 @@ struct WidgetSnapshotPublisherTests { } } + 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( @@ -275,14 +299,39 @@ struct WidgetSnapshotPublisherTests { 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()) + 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() diff --git a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift index fab63ca5..d0ba15c7 100644 --- a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift +++ b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift @@ -13,6 +13,9 @@ enum OnboardingViewLog: LogEvent { /// 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) @@ -28,7 +31,7 @@ enum OnboardingViewLog: LogEvent { .demoBuildFailed: .warning case .locationPermissionDenied: .info - case .scopeCreationFailed: .error + case .recordingChoiceFailed, .scopeCreationFailed: .error } } @@ -42,6 +45,8 @@ enum OnboardingViewLog: LogEvent { "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/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index ecdb4a1f..728c86f8 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -142,6 +142,26 @@ 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. /// @@ -152,14 +172,7 @@ public final class WhereModel { /// through the ordinary store-change stream. func joinExistingData() async throws { let scope = try await resolveScope() - scope.preferences.wantsTracking = false - if let currentDeviceID = scope.services.recording.currentDevice?.id { - _ = try await scope.services.recording.setEnabled( - false, - for: currentDeviceID, - initialEnabled: false, - ) - } + try await applyOnboardingRecordingChoice(false, in: scope) completeOnboarding() } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index b03c6867..6c86d3f0 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -322,13 +322,24 @@ public struct OnboardingView: View { gate.fail(error) return } - // This is also the initial synced policy for a newly registered - // installation. “Not Now” must therefore record false explicitly; - // leaving the old default true would make the next launch start - // recording despite the user's choice. - scope.preferences.wantsTracking = enableLocation + do { + // This is the explicit synced policy for this installation, + // not just the preference seed. A restored policy may already + // exist for the preserved installation id, and the user's new + // choice must supersede it before the gate resolves. + try await model.applyOnboardingRecordingChoice( + enableLocation, + in: scope, + ) + } catch { + Self.logger(attachments: [.error(error, name: "recording-choice-error")]) { + .recordingChoiceFailed(description: error.localizedDescription) + } + gate.fail(error) + return + } if enableLocation { - await enableTracking(in: scope) + await requestTrackingPermission(in: scope) } // Only commit when the user actually picked regions in the manual // flow. The restore path reaches here with an empty selection (it @@ -353,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 { diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift index dd1a799f..81ed32e3 100644 --- a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift @@ -10,6 +10,7 @@ struct DeviceSettingsSection: View { @Environment(WhereSession.self) private var session @Environment(\.openURL) private var openURL @State private var isConfirmingArchive = false + @FocusState private var isEditingNickname: Bool var body: some View { Section { @@ -32,8 +33,16 @@ struct DeviceSettingsSection: View { TextField(String(localized: .settingsDevicesName), text: $row.nickname) .settingsRow(DevicesSettingsView.Item.deviceName) .disabled(row.isBusy) + .focused($isEditingNickname) .onSubmit { - Task { await model.rename(row) } + isEditingNickname = false + } + .onChange(of: isEditingNickname) { wasEditing, isEditing in + guard wasEditing, !isEditing else { return } + commitNickname() + } + .onDisappear { + commitNickname() } LabeledContent(String(localized: .settingsDevicesStatus)) { @@ -135,6 +144,10 @@ struct DeviceSettingsSection: View { } } + private func commitNickname() { + Task { await model.rename(row) } + } + private var statusSymbol: String { if row.isPending { return "clock.arrow.trianglehead.counterclockwise.rotate.90" } return switch row.status { diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift index 9ca1140e..92a8c408 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift @@ -72,11 +72,15 @@ final class DevicesSettingsModel { } func rename(_ row: DeviceSettingsRowModel) async { + let nickname = row.nickname.trimmingCharacters(in: .whitespacesAndNewlines) + guard nickname != row.confirmedNickname else { + row.nickname = nickname + return + } guard !row.isBusy else { return } row.isBusy = true defer { row.isBusy = false } do { - let nickname = row.nickname.trimmingCharacters(in: .whitespacesAndNewlines) let configurations = try await session.renameRecordingDevice( row.id, to: nickname, diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift index 271cb799..84f3f200 100644 --- a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift +++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift @@ -80,4 +80,21 @@ struct DevicesSettingsModelTests { #expect(try await subject.store.recordingDevices() .first(where: { $0.id == remoteID })?.archivedAt == Self.now) } + + @Test func repeatedNicknameCommitNormalizesWithoutChangingTheConfirmedName() async throws { + let subject = try makeSubject() + await subject.session.start() + await subject.model.retry() + let row = try #require(subject.model.rows.first) + + row.nickname = "Pocket" + await subject.model.rename(row) + row.nickname = " Pocket " + await subject.model.rename(row) + + #expect(row.nickname == "Pocket") + #expect(row.confirmedNickname == "Pocket") + #expect(try await subject.store.recordingDevices() + .first(where: { $0.id == row.id })?.nickname == "Pocket") + } } diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift index 1ec44860..7da07655 100644 --- a/Where/WhereUI/Tests/OnboardingTests.swift +++ b/Where/WhereUI/Tests/OnboardingTests.swift @@ -118,6 +118,48 @@ struct OnboardingModelTests { #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( @@ -157,4 +199,56 @@ struct OnboardingModelTests { #expect(state.failure?.flow == .joinExistingData) #expect(state.isShowingFailure) } + + private struct RestoredPolicySubject { + let model: WhereModel + let services: WhereServices + let store: SwiftDataStore + let preferences: WherePreferences + } + + private func makeRestoredPolicySubject( + isEnabled: Bool, + ) async throws -> RestoredPolicySubject { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let store = try SwiftDataStore.inMemory() + let policyID = UUID() + try await store.perform { + try await store.setRecordingDevice(RecordingDevice( + id: CurrentRecordingDevice.preview.id, + systemName: CurrentRecordingDevice.preview.systemName, + nickname: nil, + kind: CurrentRecordingDevice.preview.kind, + registeredAt: now.addingTimeInterval(-120), + lastSeenAt: now.addingTimeInterval(-60), + archivedAt: nil, + lastAppliedPolicyChangeID: policyID, + status: isEnabled ? .recording : .off, + )) + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: policyID, + deviceID: CurrentRecordingDevice.preview.id, + effectiveAt: now.addingTimeInterval(-60), + isEnabled: isEnabled, + )) + } + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + now: { now }, + ) + let preferences = makePreferences() + preferences.wantsTracking = isEnabled + let model = WhereModel( + preferences: preferences, + makeBootstrap: { ScriptedBootstrap(services: services) }, + logSystem: .isolated(), + ) + return RestoredPolicySubject( + model: model, + services: services, + store: store, + preferences: preferences, + ) + } } diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 71e5f151..ee7908a7 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -97,6 +97,13 @@ actor TestStore: WhereStore { try await backing.setRecordingDevice(device) } + func updateRecordingDevice( + _ id: RecordingDeviceID, + transform: @Sendable (RecordingDevice) -> RecordingDevice, + ) async throws -> RecordingDevice? { + try await backing.updateRecordingDevice(id, transform: transform) + } + func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { try await backing.recordingPolicyChanges() }