diff --git a/.circleci/config.yml b/.circleci/config.yml index 2f6e0a19..93c550e8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,10 +65,10 @@ commands: python3 Tools/Tests/test_snapshot_shards.py ./snapshot-shards check - validate_runner_environment: + validate_build_environment: steps: - run: - name: Validate runner environment + name: Validate build environment command: | echo "CPU: $(sysctl -n machdep.cpu.brand_string)" echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" @@ -76,14 +76,27 @@ commands: echo "Architecture: $(uname -m)" sw_vers xcodebuild -version - echo "Available simulator runtimes:" - xcrun simctl list runtimes available - xcode_build="$(xcodebuild -version | awk '/Build version/{print $3}')" if [[ "$xcode_build" != "27A5228h" ]]; then echo "Expected Xcode build 27A5228h, found $xcode_build" >&2 exit 1 fi + + echo "Checkout: $(git rev-parse HEAD)" + echo "Origin: $(git remote get-url origin)" + if git config --local --get-regexp 'credential|extraheader' >/tmp/git-credential-config.txt 2>/dev/null; then + echo "Unexpected persisted Git credential configuration:" >&2 + sed -E 's/(authorization:).*/\1 [REDACTED]/I' /tmp/git-credential-config.txt >&2 + exit 1 + fi + + validate_simulator_environment: + steps: + - run: + name: Validate simulator environment + command: | + echo "Available simulator runtimes:" + xcrun simctl list runtimes available if ! xcrun simctl list runtimes available | grep -Eq '^iOS 27(\.0)? '; then echo "An available iOS 27 simulator runtime is required" >&2 exit 1 @@ -93,18 +106,21 @@ commands: echo "Selected simulator: $selected_simulator" xcrun simctl list devices available | grep "$selected_simulator" - echo "Checkout: $(git rev-parse HEAD)" - echo "Origin: $(git remote get-url origin)" - if git config --local --get-regexp 'credential|extraheader' >/tmp/git-credential-config.txt 2>/dev/null; then - echo "Unexpected persisted Git credential configuration:" >&2 - sed -E 's/(authorization:).*/\1 [REDACTED]/I' /tmp/git-credential-config.txt >&2 - exit 1 - fi + validate_runner_environment: + steps: + - validate_build_environment + - validate_simulator_environment prepare_builder: parameters: test_workdir: type: string + run_ci_helper_tests: + type: boolean + default: true + require_simulator: + type: boolean + default: true steps: - checkout_without_lfs - restore_cache: @@ -130,7 +146,10 @@ commands: - ~/.local/share/mise/installs - configure_test_workdir: test_workdir: << parameters.test_workdir >> - - test_ci_helpers + - when: + condition: << parameters.run_ci_helper_tests >> + steps: + - test_ci_helpers - run: name: Generate project and resolve Swift packages command: | @@ -153,7 +172,11 @@ commands: key: swiftpm-artifacts-v2-{{ arch }}-{{ checksum "Package.resolved" }} paths: - ~/Library/Caches/org.swift.swiftpm/artifacts - - validate_runner_environment + - validate_build_environment + - when: + condition: << parameters.require_simulator >> + steps: + - validate_simulator_environment prepare_worker: parameters: @@ -169,13 +192,16 @@ commands: run_with_timeout: parameters: + step_name: + type: string + default: Run tests seconds: type: integer command: type: string steps: - run: - name: Run tests + name: << parameters.step_name >> no_output_timeout: 45m command: | ruby -e ' @@ -307,6 +333,41 @@ jobs: test_workdir: test-output-ios artifact_name: test-ios + build-where-audiences: + executor: m4-pro-medium + parallelism: 2 + steps: + - prepare_builder: + test_workdir: test-output-audience-build + run_ci_helper_tests: false + require_simulator: false + - run: + name: Select Where audience + command: | + case "$CIRCLE_NODE_INDEX" in + 0) scheme="Where Beta" ;; + 1) scheme="Where App Store" ;; + *) + echo "No Where audience for shard $CIRCLE_NODE_INDEX" >&2 + exit 1 + ;; + esac + printf 'export WHERE_AUDIENCE_SCHEME=%q\n' "$scheme" >> "$BASH_ENV" + echo "Selected scheme: $scheme" + - run_with_timeout: + step_name: Build selected Where audience + seconds: 2700 + command: >- + set -o pipefail; + mise exec -- tuist xcodebuild build + -workspace Stuff.xcworkspace + -scheme "$WHERE_AUDIENCE_SCHEME" + -destination "generic/platform=iOS Simulator" + 2>&1 | tee "$TEST_WORKDIR/build.log" + - collect_diagnostics: + test_workdir: test-output-audience-build + artifact_name: where-audience-build + snapshot: executor: m4-pro-medium parallelism: 4 @@ -379,6 +440,12 @@ workflows: name: Build & Test (iOS) requires: - Build iOS Tests + # Each release audience needs a complete compile. Separate workers keep + # two serial release builds out of the workflow's critical path. + - build-where-audiences: + name: Build Where Audiences + requires: + - Build iOS Tests - snapshot: name: Snapshot Tests (iOS) requires: diff --git a/AGENTS.md b/AGENTS.md index ebc65ccf..85133b09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,8 +56,24 @@ easy to corrupt by hand. `./simulator` owns a per-checkout device (see the `./icons` is the single command for the Where app's alternate icons (see `./icons --help`). It keeps both asset catalogs and the picker's -`AppIcons.json` manifest in sync. Never hand-edit those files. Never add icon Swift. -Run `./ide --no-open` after adding one. +`AppIcons.json` manifest in sync — never hand-edit those or add icon Swift. +Run `./ide --no-open` after adding one. Icon-set names are independent of +primary/alternate status: each Where audience selects its primary icon in +[`Project.swift`](Project.swift), and every other set remains selectable as an +alternate. Change an audience's primary before asking `./icons` to remove that +asset; the command refuses to delete any configured primary. + +### Where build audiences + +The Where host targets have three explicit schemes — **Where Development** +(`Debug`), **Where Beta** (`Beta`), and **Where App Store** (`Release`) — whose +audience descriptors live in [`Project.swift`](Project.swift). Keep bundle IDs, +App Group, display name, primary icon, configuration, and the matching +`WHERE_DEVELOPMENT` / `WHERE_BETA` / `WHERE_APP_STORE` compiler condition in +that one descriptor. These custom conditions belong only to the app, widget, +and share-extension targets; package targets receive audience-dependent values +by injection. Development uses its own bundle family and local-only store; +Beta and App Store share the production bundle family and CloudKit store. ### Version and build metadata @@ -165,7 +181,7 @@ triage). **Always-on** rules every edit must honor stay in `AGENTS.md` or `AGENTS.md` says what it is and how it can be used. - Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper. Native-macOS test bundles are declared directly, like `LedgerCoreTests`, since that helper hosts iOS bundles in StuffTestHost). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). - **CI schemes**: CI runs explicit shared schemes rather than the autogenerated `Stuff-Workspace` scheme. **Stuff-iOS-Tests** covers the iOS bundles. **Ledger-macOS-Tests** (the Ledger app + `LedgerCoreTests`) runs in its own `test-macos` job. The workspace mixes iOS targets with the native-macOS **Ledger** ones. No single xcodebuild destination can build both. Add a new test bundle to the matching scheme in `Project.swift`. If you do not, CI will not run it. -- **CircleCI build handoff**: CircleCI builds both iOS schemes sequentially on one `m4pro.large` builder. The unit worker and parallel snapshot workers attach its products and run without compilation. Each worker validates the build manifest before it starts a simulator. `./snapshot-shards` owns three planned suite assignments and one intake shard. A new suite runs on the intake shard until rebalancing adds it to the plan. Each active snapshot worker must execute exactly its assigned suites. +- **CircleCI build handoff**: CircleCI builds both iOS test schemes sequentially on one `m4pro.large` builder. The unit worker and parallel snapshot workers use its products without compilation. Each test worker validates the build manifest before it starts a simulator. Two `m4pro.medium` build-only shards compile **Where Beta** and **Where App Store** after the shared builder completes. `./snapshot-shards` owns three planned suite assignments and one intake shard. A new suite runs on the intake shard until rebalancing adds it to the plan. Each active snapshot worker must execute exactly its assigned suites. - **Image snapshots are the exception: one bundle per module, one shared scheme.** Each module owning image references has its own `*SnapshotTests` target over its `SnapshotTests/` folder. All are listed in the single shared **StuffSnapshotTests** scheme and its dedicated CI `snapshot` job. Snapshots are slow and LFS-backed. They are **out of** `Stuff-iOS-Tests`. References under any `__Snapshots__/` directory are Git LFS (`.gitattributes`. The CI job hydrates them explicitly). Framework halves: `Shared/SnapshotKit` (shippable matrix + previews) and `Shared/SnapshotKitTesting` (test-only pipeline, whose own regression bundle **SnapshotKitTestingTests** pixel-probes without LFS and runs in `Stuff-iOS-Tests`). - **A new image suite gets a target, not a scheme.** Add the `*SnapshotTests` target. List only `SnapshotKitTesting` in `extraPackageProducts`. Add it to the `StuffSnapshotTests` scheme's build and test lists. Never add a scheme or CI job of its own. An image bundle links only what its module needs (the Periscope and Inspector suites don't build against WhereUI at all). References follow the sources automatically via `#filePath`. - **Separate snapshot bundles are safe because each `.xctest` gets its own `StuffTestHost` process** (measured on Xcode 27 — `ProcessInfo.processIdentifier` probes. Details in the snapshot-bundle comment in [`Project.swift`](Project.swift)). Each bundle statically embeds its own copy of `SnapshotKitTesting`'s capture state. Two copies in one process corrupt each other. Tripwire: if a toolchain ever shares one host process across bundles, re-measure before adding another image bundle. @@ -629,7 +645,8 @@ being written off as untestable from a cloud agent. ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs `format`, `architecture`, and `test-macos`. CircleCI ([`.circleci/config.yml`](.circleci/config.yml)) runs the iOS `test-ios` and -`snapshot` jobs, which moved there in PR #237. CircleCI passes +`snapshot` jobs, plus build-only checks for **Where Beta** and **Where App +Store**. The iOS tests moved there in PR #237. CircleCI passes `--skip-architecture` so Bumper does not run twice. Do not read either file as the whole of CI. See the [`running-tests`](.agents/skills/running-tests/SKILL.md) skill for simulator diff --git a/Project.swift b/Project.swift index 651b685a..3230f02d 100644 --- a/Project.swift +++ b/Project.swift @@ -23,6 +23,114 @@ private let sfSafeSymbolsPackage = Package.remote( /// Xcode falls back to its defaults. private let developmentTeam = Environment.developmentTeam.getString(default: "") +private struct WhereAudience { + enum Variant { + case debug + case release + } + + let schemeName: String + let configurationName: ConfigurationName + let variant: Variant + let condition: String + let value: String + let displayName: String + let appBundleID: String + let widgetBundleID: String + let shareBundleID: String + let appGroupIdentifier: String + let primaryAppIconName: String + + func configuration(settings: SettingsDictionary = [:]) -> Configuration { + switch variant { + case .debug: .debug(name: configurationName, settings: settings) + case .release: .release(name: configurationName, settings: settings) + } + } +} + +private let whereAudiences: [WhereAudience] = [ + WhereAudience( + schemeName: "Where Development", + configurationName: "Debug", + variant: .debug, + condition: "WHERE_DEVELOPMENT", + value: "development", + displayName: "Where Dev", + appBundleID: "com.stuff.where.development", + widgetBundleID: "com.stuff.where.development.widgets", + shareBundleID: "com.stuff.where.development.share", + appGroupIdentifier: "group.com.stuff.where.development", + primaryAppIconName: "AppIcon", + ), + WhereAudience( + schemeName: "Where Beta", + configurationName: "Beta", + variant: .release, + condition: "WHERE_BETA", + value: "beta", + displayName: "Where", + appBundleID: "com.stuff.where", + widgetBundleID: "com.stuff.where.widgets", + shareBundleID: "com.stuff.where.share", + appGroupIdentifier: "group.com.stuff.where", + primaryAppIconName: "AppIcon", + ), + WhereAudience( + schemeName: "Where App Store", + configurationName: "Release", + variant: .release, + condition: "WHERE_APP_STORE", + value: "appStore", + displayName: "Where", + appBundleID: "com.stuff.where", + widgetBundleID: "com.stuff.where.widgets", + shareBundleID: "com.stuff.where.share", + appGroupIdentifier: "group.com.stuff.where", + primaryAppIconName: "AppIcon", + ), +] + +private enum WhereHostTarget { + case app + case widget + case share + + func bundleID(for audience: WhereAudience) -> String { + switch self { + case .app: audience.appBundleID + case .widget: audience.widgetBundleID + case .share: audience.shareBundleID + } + } +} + +private func whereHostSettings( + _ target: WhereHostTarget, + base: SettingsDictionary = [:], +) -> Settings { + .settings( + base: base, + configurations: whereAudiences.map { audience in + var settings: SettingsDictionary = [ + "PRODUCT_BUNDLE_IDENTIFIER": .string(target.bundleID(for: audience)), + "SWIFT_ACTIVE_COMPILATION_CONDITIONS": .string( + "$(inherited) \(audience.condition)", + ), + "WHERE_APP_GROUP_IDENTIFIER": .string(audience.appGroupIdentifier), + "WHERE_AUDIENCE": .string(audience.value), + "WHERE_DISPLAY_NAME": .string(audience.displayName), + "WHERE_PRIMARY_APP_ICON_NAME": .string(audience.primaryAppIconName), + ] + if case .app = target { + settings["ASSETCATALOG_COMPILER_APPICON_NAME"] = .string(audience + .primaryAppIconName) + } + return audience.configuration(settings: settings) + }, + ) +} + /// Base build settings applied to every Tuist-generated target. /// /// `STRING_CATALOG_GENERATE_SYMBOLS` turns on Xcode's type-safe String Catalog @@ -40,14 +148,18 @@ private let projectSettings: Settings = .settings( "STRING_CATALOG_GENERATE_SYMBOLS": "YES", "DEVELOPMENT_TEAM": .string(developmentTeam), ], + configurations: whereAudiences.map { $0.configuration() }, + defaultConfiguration: "Debug", ) /// 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. +/// extension so every audience's processes see the same on-disk SwiftData +/// store and widget snapshot JSON. The host injects the matching build setting +/// into WhereCore; no package target owns a global App Group identifier. let whereAppGroupEntitlements: Entitlements = .dictionary([ - "com.apple.security.application-groups": .array([.string("group.com.stuff.where")]), + "com.apple.security.application-groups": .array([ + .string("$(WHERE_APP_GROUP_IDENTIFIER)"), + ]), ]) /// The app additionally owns the CloudKit container that mirrors its @@ -59,7 +171,9 @@ let whereAppEntitlements: Entitlements = .dictionary([ // the selected provisioning profile. Keeping the entitlement in the // target is what makes automatic signing request Push Notifications. "aps-environment": .string("development"), - "com.apple.security.application-groups": .array([.string("group.com.stuff.where")]), + "com.apple.security.application-groups": .array([ + .string("$(WHERE_APP_GROUP_IDENTIFIER)"), + ]), "com.apple.developer.icloud-container-identifiers": .array([ .string("iCloud.com.stuff.where"), ]), @@ -175,6 +289,38 @@ func testScheme( ) } +private let whereAudienceSchemes: [Scheme] = [ + // Reserve the autogenerated target scheme's name but keep audience-neutral + // entry points out of Xcode's visible scheme picker. + .scheme( + name: "Where", + shared: true, + hidden: true, + buildAction: .buildAction(targets: ["Where"]), + runAction: .runAction(configuration: "Debug", executable: "Where"), + ), +] + whereAudiences.map { audience in + let testAction: TestAction? = switch audience.variant { + case .debug: .targets( + ["WhereTests"], + arguments: .arguments(environmentVariables: packageResourceEnvironment), + configuration: audience.configurationName, + ) + case .release: nil + } + return .scheme( + name: audience.schemeName, + shared: true, + buildAction: .buildAction(targets: ["Where"]), + testAction: testAction, + runAction: .runAction( + configuration: audience.configurationName, + executable: "Where", + ), + archiveAction: .archiveAction(configuration: audience.configurationName), + ) +} + let project = Project( name: "Stuff", options: .options( @@ -191,6 +337,7 @@ let project = Project( bundleId: "com.stuff.where", deploymentTargets: deployment, infoPlist: .extendingDefault(with: [ + "CFBundleDisplayName": .string("$(WHERE_DISPLAY_NAME)"), "UILaunchScreen": .dictionary([:]), "UIApplicationSupportsIndirectInputEvents": .boolean(true), "UIBackgroundModes": .array([.string("remote-notification")]), @@ -199,6 +346,9 @@ let project = Project( // user reads off the screen should be one this manifest chose. "CFBundleShortVersionString": .string("1.0"), "CFBundleVersion": .string("1"), + "WhereAudience": .string("$(WHERE_AUDIENCE)"), + "WhereAppGroupIdentifier": .string("$(WHERE_APP_GROUP_IDENTIFIER)"), + "WherePrimaryAppIconName": .string("$(WHERE_PRIMARY_APP_ICON_NAME)"), "NSLocationWhenInUseUsageDescription": .string( "Where uses your location to figure out which region you're in.", ), @@ -234,10 +384,11 @@ let project = Project( // auto-write the `CFBundleAlternateIcons` plist entries, so the asset // catalog itself is the source of truth for which alternate icons exist // (the `./icons` script just adds/removes sets — no names list to keep - // in sync here). The primary stays `AppIcon`. Where ships no custom - // global accent color (it tints per-region in SwiftUI), so clear the - // name actool otherwise looks for — an unset `AccentColor` warns. - settings: .settings(base: [ + // in sync here). Each audience descriptor chooses its primary from + // those sets. Where ships no custom global accent color (it tints + // per-region in SwiftUI), so clear the name actool otherwise looks + // for — an unset `AccentColor` warns. + settings: whereHostSettings(.app, base: [ "ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS": "YES", "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "", ]), @@ -249,7 +400,9 @@ let project = Project( bundleId: "com.stuff.where.widgets", deploymentTargets: deployment, infoPlist: .extendingDefault(with: [ - "CFBundleDisplayName": .string("Where"), + "CFBundleDisplayName": .string("$(WHERE_DISPLAY_NAME)"), + "WhereAudience": .string("$(WHERE_AUDIENCE)"), + "WhereAppGroupIdentifier": .string("$(WHERE_APP_GROUP_IDENTIFIER)"), "NSExtension": .dictionary([ "NSExtensionPointIdentifier": .string("com.apple.widgetkit-extension"), ]), @@ -263,6 +416,7 @@ let project = Project( .package(product: "WhereCore"), .package(product: "WhereUI"), ], + settings: whereHostSettings(.widget), ), .target( name: "WhereShareExtension", @@ -271,7 +425,9 @@ let project = Project( bundleId: "com.stuff.where.share", deploymentTargets: deployment, infoPlist: .extendingDefault(with: [ - "CFBundleDisplayName": .string("Where"), + "CFBundleDisplayName": .string("$(WHERE_DISPLAY_NAME)"), + "WhereAudience": .string("$(WHERE_AUDIENCE)"), + "WhereAppGroupIdentifier": .string("$(WHERE_APP_GROUP_IDENTIFIER)"), "NSExtension": .dictionary([ "NSExtensionPointIdentifier": .string("com.apple.share-services"), "NSExtensionPrincipalClass": .string( @@ -299,6 +455,7 @@ let project = Project( .package(product: "WhereCore"), .package(product: "WhereUI"), ], + settings: whereHostSettings(.share), ), .target( name: "RegionViewer", @@ -695,7 +852,7 @@ let project = Project( // runs them), so declare them explicitly. This lets `tuist test // WhereCoreTests` / `tuist test WhereTests` / `tuist test WhereUITests` // target a single bundle without building the whole workspace. - schemes: [ + schemes: whereAudienceSchemes + [ // 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. diff --git a/README.md b/README.md index 3ad988ec..330a802c 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ It streams progress while tests run: See `./test --help` for the rest, including `--timings` and `--review` for reading a snapshot run. +CircleCI also compiles the `Where Beta` and `Where App Store` schemes on two +build-only shards. These shards do not run tests or start a simulator. + Each checkout gets a device of its own (a second clone, a worktree, and so on). Two runs on one machine never fight over booting, installing to, or erasing the same simulator. `./simulator --list` shows devices with their owning checkouts. diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 0d0c8a29..dd88e42a 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -266,9 +266,12 @@ synchronous, in-memory fixtures. Never use disk, CloudKit, or CoreLocation. ## Installing to a device `./Where/install` builds, signs, and installs the app onto a connected iPhone -from the CLI. It is macOS-only. It needs one-time `./ide --team-id ` setup. -It defaults to Debug with compiler optimizations forced on. DEBUG-only developer -surfaces survive at near-Release speed. Options: `./Where/install --help`. +from the CLI — macOS-only, one-time `./ide --team-id ` setup. It defaults +to the **Where Development** scheme with compiler optimizations forced on, so +DEBUG-only developer surfaces survive at near-Release speed. Pass +`--configuration Beta` for the TestFlight-style production identity or +`--configuration Release` for the App Store audience. Options: +`./Where/install --help`. ## Testing diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index fe8744b5..cac8909b 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -14,9 +14,9 @@ layering, and the domain rules this target merely starts up. - **Keep it tiny.** Domain behavior goes in `WhereCore`. Presentation goes in `WhereUI`. If a change here is more than wiring, it belongs in a module. The - target is a Tuist `.app` ([`Project.swift`](../../Project.swift), bundle ID - `com.stuff.where`). Its Info.plist keys, entitlements, and build settings - live in that manifest. There is no checked-in plist to edit. + target is a Tuist `.app` ([`Project.swift`](../../Project.swift)); its bundle + ID, Info.plist keys, entitlements, build settings, and audience schemes live + in that manifest — there is no checked-in plist to edit. - `Scripts/` holds this target's build-phase scripts, not dev commands (those are the repo-root executables). Today that is [`stamp-build-info.sh`](Scripts/stamp-build-info.sh). It stamps the commit @@ -40,6 +40,10 @@ layering, and the domain rules this target merely starts up. `WhereApp` forward through `WhereApplicationRuntime`. Never add mode switches to lifecycle callbacks, `RootView`, or feature code. In DEBUG, finish Inspector's latched store-family recovery before constructing that runtime. +- **Resolve `WhereBuildEnvironment.current` once in `AppDelegate.init`.** Its + audience condition must match the generated Info.plist, and the selected App + Group, storage policy, widget refresher, App Intents handoff, and primary icon + must be injected from that one value. - **Release always builds `RegularApplicationRuntime`.** Boot preference reads, Inspector configuration, and menu integration stay under `#if DEBUG`. - **Wire regular launch in `didFinishLaunching`, not a SwiftUI `.task`.** When @@ -65,12 +69,12 @@ layering, and the domain rules this target merely starts up. `onServicesReady` hook. - **Only the app owns the CloudKit capability.** Keep its App Group, CloudKit container (`iCloud.com.stuff.where`), Push Notifications entitlement, and - remote-notification background mode together in `Project.swift`. Widgets and - the share extension stay App Group-only. They must never open a CloudKit - container. -- **Choose the regular runtime's store explicitly.** Release uses `.cloudKit`. - Debug uses `.localOnly` unless built with `WHERE_CLOUDKIT_VALIDATION` - (`./Where/install --cloudkit`). The choice must survive every process relaunch. + remote-notification background mode together in `Project.swift`; widgets and + the share extension stay App Group-only and never open a CloudKit container. +- **Choose the regular runtime's store explicitly.** Beta and App Store use + `.cloudKit`; Development uses `.localOnly` unless built with + `WHERE_CLOUDKIT_VALIDATION` (`./Where/install --cloudkit`); the choice must + survive every process relaunch. - **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 fd770499..9542cfd7 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -13,7 +13,8 @@ For the rules that apply when editing this target, see [`AGENTS.md`](AGENTS.md). | File | Role | |------|------| | `Sources/WhereApp.swift` | `@main` `App`. One `WindowGroup` rendering the selected runtime's type-erased root. | -| `Sources/AppDelegate.swift` | The boot router. Resolves process reporting preferences, starts the reporting controller, selects one `WhereApplicationRuntime`, and forwards lifecycle callbacks. | +| `Sources/AppDelegate.swift` | The boot router. Resolves the audience and process reporting preferences, starts the reporting controller, selects one `WhereApplicationRuntime`, and forwards lifecycle callbacks. | +| `Sources/WhereBuildEnvironment.swift` | Validates the host-only audience condition and maps the generated Info.plist values to storage, App Group, widget refresh, and primary-icon dependencies. | | `Sources/DiagnosticReportingController.swift` | Owns the launch-only Bitdrift channels and revisioned Periscope remote sink for the process. | | `Sources/RegularApplicationRuntime.swift` | Owns the app's single `WhereModel`, `IntentServices`, and `LifecycleRunner`. Starts logging, installs the App Intents handoff, and indexes Spotlight. | | `Sources/WhereInspectorApplicationRuntime.swift` | DEBUG-only alternate runtime. Configures the standalone Inspector without constructing regular app systems. | @@ -54,18 +55,29 @@ Neither runtime swaps live. ## 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 once in [`Project.swift`](../../Project.swift), with +three audience schemes: **Where Development** (`Debug`, isolated bundle/App +Group, local-only data), **Where Beta** (`Beta`, production identity and +CloudKit), and **Where App Store** (`Release`, production identity and +CloudKit). The manifest injects audience values into the app and extensions; +only those host targets receive the matching `WHERE_*` compiler condition. +Generate the workspace with `./ide --no-open`, 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)). ## CloudKit rollout and device validation -The app target owns `iCloud.com.stuff.where`, the Push Notifications entitlement, and the remote-notification background mode. -Widgets and the share extension 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 or use `./Where/install --cloudkit`. -Release always selects `.cloudKit`. -The installer compiles the validation choice into that Debug app, so manual, background, and CloudKit-push relaunches keep using CloudKit until another build is installed without `--cloudkit`. +The app target owns `iCloud.com.stuff.where`, the Push Notifications +entitlement, and 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. Development uses `.localOnly`; exercise sync with +`./Where/install --cloudkit`, which keeps the isolated Development bundle and +App Group while selecting `.cloudKit`. Beta and App Store always select +`.cloudKit` with the production App Group. The installer compiles the +validation choice into that Development app, so manual, background, and +CloudKit-push relaunches keep using CloudKit until another build is installed +without `--cloudkit`. Before shipping a schema change: diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index ebeb7ef7..101d9d6e 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -17,6 +17,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { private let reportingControllers: [any WhereReportingController] override init() { + let buildEnvironment = WhereBuildEnvironment.current() let preferences = WherePreferences(store: UserDefaults.standard) let launchConfiguration = preferences.diagnosticReportingConfiguration.effective( isDebugBuild: Self.isDebugBuild, @@ -52,6 +53,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { fileManager: .default, regular: { RegularApplicationRuntime( + buildEnvironment: buildEnvironment, preferences: preferences, effectiveDiagnosticReportingConfiguration: launchConfiguration, applyRemoteLogging: applyRemoteLogging, @@ -59,11 +61,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate { ) }, inspector: { - WhereInspectorApplicationRuntime(modeController: modeController) + WhereInspectorApplicationRuntime( + buildEnvironment: buildEnvironment, + modeController: modeController, + ) }, ) #else let regularRuntime = RegularApplicationRuntime( + buildEnvironment: buildEnvironment, preferences: preferences, effectiveDiagnosticReportingConfiguration: launchConfiguration, applyRemoteLogging: applyRemoteLogging, diff --git a/Where/Where/Sources/RegularApplicationRuntime.swift b/Where/Where/Sources/RegularApplicationRuntime.swift index 80f381a9..62e467aa 100644 --- a/Where/Where/Sources/RegularApplicationRuntime.swift +++ b/Where/Where/Sources/RegularApplicationRuntime.swift @@ -15,9 +15,9 @@ import WhereUI @MainActor final class RegularApplicationRuntime: WhereApplicationRuntime { let model: WhereModel - - let intentServices = IntentServices() - private let widgetPresentationPublisher = WidgetPresentationPublisher() + let intentServices: IntentServices + private let buildEnvironment: WhereBuildEnvironment + private let widgetPresentationPublisher: WidgetPresentationPublisher private(set) var launcher: LifecycleRunner! #if DEBUG @@ -34,14 +34,23 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { private let inspectorModeController: InspectorModeController? init( + buildEnvironment: WhereBuildEnvironment, preferences: WherePreferences, effectiveDiagnosticReportingConfiguration: DiagnosticReportingConfiguration, applyRemoteLogging: @escaping DiagnosticReportingSettingsModel.ApplyRemoteLogging, inspectorModeController: InspectorModeController? = nil, ) { + self.buildEnvironment = buildEnvironment self.inspectorModeController = inspectorModeController + intentServices = IntentServices( + appGroupIdentifier: buildEnvironment.appGroupIdentifier, + ) + widgetPresentationPublisher = WidgetPresentationPublisher( + appGroupIdentifier: buildEnvironment.appGroupIdentifier, + ) model = Self.makeModel( - storeStorage: Self.storeStorage( + buildEnvironment: buildEnvironment, + storeStorage: buildEnvironment.storage( forCloudKitValidationBuild: Self.isCloudKitValidationBuild, ), preferences: preferences, @@ -50,19 +59,23 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { ) } - static func storeStorage( - forCloudKitValidationBuild validatesCloudKit: Bool, - ) -> SwiftDataStore.Storage { - validatesCloudKit ? .cloudKit : .localOnly - } #else init( + buildEnvironment: WhereBuildEnvironment, preferences: WherePreferences, effectiveDiagnosticReportingConfiguration: DiagnosticReportingConfiguration, applyRemoteLogging: @escaping DiagnosticReportingSettingsModel.ApplyRemoteLogging, ) { + self.buildEnvironment = buildEnvironment + intentServices = IntentServices( + appGroupIdentifier: buildEnvironment.appGroupIdentifier, + ) + widgetPresentationPublisher = WidgetPresentationPublisher( + appGroupIdentifier: buildEnvironment.appGroupIdentifier, + ) model = Self.makeModel( - storeStorage: .cloudKit, + buildEnvironment: buildEnvironment, + storeStorage: buildEnvironment.storage, preferences: preferences, effectiveDiagnosticReportingConfiguration: effectiveDiagnosticReportingConfiguration, applyRemoteLogging: applyRemoteLogging, @@ -71,6 +84,7 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { #endif private static func makeModel( + buildEnvironment: WhereBuildEnvironment, storeStorage: SwiftDataStore.Storage, preferences: WherePreferences, effectiveDiagnosticReportingConfiguration: DiagnosticReportingConfiguration, @@ -85,6 +99,7 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { WhereBootstrap( installationContextStore: $0, storeStorage: storeStorage, + widgetRefresher: buildEnvironment.makeWidgetRefresher(), locationOutbox: locationOutbox, ) }, @@ -130,10 +145,15 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { AnyView(RootView( model: model, launcher: launcher, + primaryAppIconName: buildEnvironment.primaryAppIconName, inspectorModeController: inspectorModeController, )) #else - AnyView(RootView(model: model, launcher: launcher)) + AnyView(RootView( + model: model, + launcher: launcher, + primaryAppIconName: buildEnvironment.primaryAppIconName, + )) #endif } } diff --git a/Where/Where/Sources/WhereBuildEnvironment.swift b/Where/Where/Sources/WhereBuildEnvironment.swift new file mode 100644 index 00000000..c9c0cc97 --- /dev/null +++ b/Where/Where/Sources/WhereBuildEnvironment.swift @@ -0,0 +1,101 @@ +import Foundation +import WhereCore + +/// Audience-specific values selected by this host target's compiler condition. +/// +/// Only the app and extension targets see `WHERE_*`; package modules receive +/// the concrete App Group, storage, and presentation values produced here. +struct WhereBuildEnvironment: Equatable { + enum Audience: String, Equatable { + case development + case beta + case appStore + } + + let audience: Audience + let appGroupIdentifier: String + let primaryAppIconName: String + let isRunningTests: Bool + + var storage: SwiftDataStore.Storage { + storage(forCloudKitValidationBuild: false) + } + + func storage( + forCloudKitValidationBuild validatesCloudKit: Bool, + ) -> SwiftDataStore.Storage { + if isRunningTests { + return .inMemory + } + switch audience { + case .development: + return validatesCloudKit + ? .cloudKit(appGroupIdentifier: appGroupIdentifier) + : .localOnly(appGroupIdentifier: appGroupIdentifier) + case .beta, .appStore: + return .cloudKit(appGroupIdentifier: appGroupIdentifier) + } + } + + func makeWidgetRefresher() -> any WidgetTimelineRefreshing { + if isRunningTests { + NoopWidgetTimelineRefresher() + } else { + WidgetCenterTimelineRefresher(appGroupIdentifier: appGroupIdentifier) + } + } + + static func current( + infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:], + processEnvironment: [String: String] = ProcessInfo.processInfo.environment, + ) -> WhereBuildEnvironment { + let audience = compiledAudience + let stampedAudience = requireString("WhereAudience", in: infoDictionary) + precondition( + stampedAudience == audience.rawValue, + "WhereAudience does not match the target's compiler condition", + ) + return WhereBuildEnvironment( + audience: audience, + appGroupIdentifier: requireString( + "WhereAppGroupIdentifier", + in: infoDictionary, + ), + primaryAppIconName: requireString( + "WherePrimaryAppIconName", + in: infoDictionary, + ), + isRunningTests: processEnvironment["XCTestConfigurationFilePath"] != nil, + ) + } + + private static func requireString( + _ key: String, + in infoDictionary: [String: Any], + ) -> String { + guard let value = infoDictionary[key] as? String, !value.isEmpty else { + preconditionFailure("Where's Info.plist has no non-empty \(key)") + } + return value + } + + #if WHERE_DEVELOPMENT && WHERE_BETA + #error("Exactly one Where audience compiler condition must be active") + #endif + #if WHERE_DEVELOPMENT && WHERE_APP_STORE + #error("Exactly one Where audience compiler condition must be active") + #endif + #if WHERE_BETA && WHERE_APP_STORE + #error("Exactly one Where audience compiler condition must be active") + #endif + + #if WHERE_DEVELOPMENT + private static let compiledAudience = Audience.development + #elseif WHERE_BETA + private static let compiledAudience = Audience.beta + #elseif WHERE_APP_STORE + private static let compiledAudience = Audience.appStore + #else + #error("A Where audience compiler condition must be active") + #endif +} diff --git a/Where/Where/Sources/WhereInspectorApplicationRuntime.swift b/Where/Where/Sources/WhereInspectorApplicationRuntime.swift index ef35973d..9cf7713e 100644 --- a/Where/Where/Sources/WhereInspectorApplicationRuntime.swift +++ b/Where/Where/Sources/WhereInspectorApplicationRuntime.swift @@ -15,6 +15,7 @@ private let modeController: InspectorModeController init( + buildEnvironment: WhereBuildEnvironment, modeController: InspectorModeController, fileManager: FileManager = .default, userDefaults: UserDefaults = .standard, @@ -24,7 +25,7 @@ preconditionFailure("Where has no bundle identifier") } guard let groupURL = fileManager.containerURL( - forSecurityApplicationGroupIdentifier: SwiftDataStore.appGroupIdentifier, + forSecurityApplicationGroupIdentifier: buildEnvironment.appGroupIdentifier, ) else { preconditionFailure("Where's App Group container is unavailable") } @@ -34,6 +35,7 @@ fileManager: fileManager, userDefaults: userDefaults, bundleIdentifier: bundleIdentifier, + appGroupIdentifier: buildEnvironment.appGroupIdentifier, groupURL: groupURL, whereStoreURL: whereStoreURL, periscopeStoreURL: PeriscopeStore.inspectorStoreURL, @@ -46,6 +48,7 @@ fileManager: FileManager, userDefaults: UserDefaults, bundleIdentifier: String, + appGroupIdentifier: String, groupURL: URL, whereStoreURL: URL, periscopeStoreURL: URL, @@ -89,7 +92,9 @@ storeURL: whereStoreURL, modelTypes: SwiftDataStore.inspectorModelTypes, makeContainer: { - try SwiftDataStore.makeContainer(storage: .localOnly) + try SwiftDataStore.makeContainer(storage: .localOnly( + appGroupIdentifier: appGroupIdentifier, + )) }, ), .init( diff --git a/Where/Where/Tests/WhereBuildEnvironmentTests.swift b/Where/Where/Tests/WhereBuildEnvironmentTests.swift new file mode 100644 index 00000000..77e235da --- /dev/null +++ b/Where/Where/Tests/WhereBuildEnvironmentTests.swift @@ -0,0 +1,45 @@ +import Testing +@testable import Where +import WhereCore + +struct WhereBuildEnvironmentTests { + private let infoDictionary: [String: Any] = [ + "WhereAudience": "development", + "WhereAppGroupIdentifier": "group.com.stuff.where.development", + "WherePrimaryAppIconName": "AppIconDevelopment", + ] + + @Test func developmentBuildInjectsItsIsolatedStorageAndIcon() { + let environment = WhereBuildEnvironment.current( + infoDictionary: infoDictionary, + processEnvironment: [:], + ) + + #expect(environment.audience == .development) + #expect(environment.appGroupIdentifier == "group.com.stuff.where.development") + #expect(environment.primaryAppIconName == "AppIconDevelopment") + #expect(environment.storage == .localOnly( + appGroupIdentifier: "group.com.stuff.where.development", + )) + } + + @Test func hostedTestsAlwaysReceiveInMemoryStorage() { + let environment = WhereBuildEnvironment.current( + infoDictionary: infoDictionary, + processEnvironment: ["XCTestConfigurationFilePath": "/tmp/WhereTests.xctest"], + ) + + #expect(environment.storage == .inMemory) + } + + @Test func cloudKitValidationBuildUsesTheDevelopmentAppGroup() { + let environment = WhereBuildEnvironment.current( + infoDictionary: infoDictionary, + processEnvironment: [:], + ) + + #expect(environment.storage(forCloudKitValidationBuild: true) == .cloudKit( + appGroupIdentifier: "group.com.stuff.where.development", + )) + } +} diff --git a/Where/Where/Tests/WhereTests.swift b/Where/Where/Tests/WhereTests.swift index 9c7732af..c04c92b3 100644 --- a/Where/Where/Tests/WhereTests.swift +++ b/Where/Where/Tests/WhereTests.swift @@ -46,24 +46,16 @@ struct WhereAppTests { } #if DEBUG - @Test func ordinaryDebugBuildUsesLocalOnlyStorageAcrossRelaunches() { - #expect( - RegularApplicationRuntime.storeStorage(forCloudKitValidationBuild: false) - == .localOnly, - ) - } - - @Test func cloudKitValidationBuildUsesCloudKitStorageAcrossRelaunches() { - #expect( - RegularApplicationRuntime.storeStorage(forCloudKitValidationBuild: true) - == .cloudKit, - ) - } - @Test func regularRuntimeUsesTheProcessReportingPreferencesForItsModel() { let preferences = WherePreferences(store: InMemoryKeyValueStore()) let effective = DiagnosticReportingConfiguration.defaults(isDebugBuild: true) let runtime = RegularApplicationRuntime( + buildEnvironment: WhereBuildEnvironment( + audience: .development, + appGroupIdentifier: "group.com.stuff.where.tests", + primaryAppIconName: "AppIcon", + isRunningTests: true, + ), preferences: preferences, effectiveDiagnosticReportingConfiguration: effective, applyRemoteLogging: { _, _ in }, @@ -258,6 +250,7 @@ struct WhereAppTests { fileManager: .default, userDefaults: defaults, bundleIdentifier: suiteName, + appGroupIdentifier: "group.com.stuff.where.tests", groupURL: groupURL, whereStoreURL: whereStoreURL, periscopeStoreURL: periscopeStoreURL, diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index caf4e787..a83e9da9 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -48,6 +48,9 @@ internal shape. `WhereServices.forIntents(sharingStoreOf:)`. A second container over the same file is how a fresh install once raced the launch into failure (root [Composition](../../AGENTS.md#composition-create-once-inject-down)). +- **On-disk storage always carries an explicit App Group identifier.** Audience + selection belongs to host targets; WhereCore must not own a production or + development default. - **Primary regions *are* the tracked-region set.** `primaryRegions()` / `setPrimaryRegions(_:)` read/write the same `SDTrackedRegion` rows as `trackedRegions()`. Picking scopes GPS attribution *and* carries each diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 577dac1d..5e8c128c 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -31,10 +31,12 @@ one it belongs to rather than to a god-object: URL, excluding other stores such as Periscope. `remoteChanges()` uses persistent-history transaction authors to emit only the external-import subset, so headless notifications and widgets rebuild without duplicating local work. - `SwiftDataStore.make(storage:)` opens an explicitly selected - CloudKit, local-only, or in-memory store. `SwiftDataStore.inMemory()` is the - convenience used by tests and previews. Each - process opens its on-disk store **once** and injects it where it's needed — + `SwiftDataStore.make(storage:)` opens an explicitly selected CloudKit, + local-only, or in-memory store. On-disk modes carry their App Group identifier; + the host chooses that policy and group, so WhereCore contains no audience + default. `SwiftDataStore.inMemory()` is the convenience used by tests and + previews. 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 @@ -223,7 +225,9 @@ import WhereCore // previews use the synchronous `@_spi(Testing)` `init` instead (an explicit // attributor, default four) via `@_spi(Testing) import WhereCore`. let services = try await WhereServices.make( - store: try SwiftDataStore.make(storage: .cloudKit), + store: try SwiftDataStore.make(storage: .cloudKit( + appGroupIdentifier: "group.com.stuff.where" + )), locationSource: CoreLocationSource(), installationContext: installationContext, // resolved once by the app composition root ) diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 71cd599a..295f6554 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -377,17 +377,20 @@ private enum GenerationScopedFetch { /// one-shot capture) queue instead of clobbering each other. @ModelActor public actor SwiftDataStore: WhereStore, EvidenceBlobStore { - /// Backing storage for a `SwiftDataStore`. Callers choose explicitly so a - /// developer build cannot accidentally validate local-only persistence - /// while appearing to exercise CloudKit. + /// Backing storage for a `SwiftDataStore`. + /// + /// On-disk cases carry their App Group identifier so a host can't select + /// local or CloudKit persistence without also naming the container it is + /// entitled to use. Audience selection stays in the app/extension targets; + /// WhereCore receives the finished storage configuration by injection. public enum Storage: Sendable, Equatable { /// In-memory only. No disk, no CloudKit. Used by tests and previews. case inMemory /// On-disk SwiftData store with CloudKit sync disabled. - case localOnly + case localOnly(appGroupIdentifier: String) /// On-disk SwiftData store backed by the user's private /// CloudKit database. - case cloudKit + case cloudKit(appGroupIdentifier: String) /// Whether a store of this mode can receive writes from outside this /// process — a sibling App Group process (the share extension) for any @@ -400,13 +403,23 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { case .localOnly, .cloudKit: true } } - } - /// 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`). - public static let appGroupIdentifier = "group.com.stuff.where" + fileprivate var appGroupIdentifier: String? { + switch self { + case .inMemory: nil + case let .localOnly(appGroupIdentifier), + let .cloudKit(appGroupIdentifier): appGroupIdentifier + } + } + + /// Whether this mode opens a CloudKit-backed container. + public var usesCloudKit: Bool { + switch self { + case .inMemory, .localOnly: false + case .cloudKit: true + } + } + } public static func makeContainer(storage: Storage) throws -> ModelContainer { // A plain `Schema` of the live models. SwiftData runs implicit @@ -438,7 +451,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // the app reads. An in-memory store has no container — leave it default. let groupContainer: ModelConfiguration.GroupContainer = switch storage { case .inMemory: .none - case .localOnly, .cloudKit: .identifier(appGroupIdentifier) + case let .localOnly(appGroupIdentifier), + let .cloudKit(appGroupIdentifier): .identifier(appGroupIdentifier) } // CloudKit mode backs the container with `NSPersistentCloudKitContainer`, // which enables persistent-history tracking and posts @@ -449,7 +463,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { schema: schema, isStoredInMemoryOnly: storage == .inMemory, groupContainer: groupContainer, - cloudKitDatabase: storage == .cloudKit ? .automatic : .none, + cloudKitDatabase: storage.usesCloudKit ? .automatic : .none, ) } @@ -487,8 +501,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // SwiftData falls back to the per-process sandbox — which reads as // "my old data is still here / the store didn't move" rather than an // error. Logging both makes that diagnosable instead of a guess. - let groupResolved = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) != nil + let appGroupIdentifier = storage.appGroupIdentifier + let groupResolved = appGroupIdentifier.flatMap { + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: $0, + ) + } != nil let url = container.configurations.first?.url.path(percentEncoded: false) ?? "unknown" logger { .openedOnDisk( @@ -527,7 +545,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// Test seam: an `.inMemory` store wired to drive its `changes()` /// fan-out from `remoteChangeSource`, so the remote-import path is /// exercisable without CloudKit or a device. The production equivalent - /// is `make(storage: .cloudKit)`, which wires a + /// is `make(storage: .cloudKit(appGroupIdentifier:))`, which wires a /// `PersistentStoreRemoteChangeSource`. `@_spi(Testing)` (per the /// agents.md) so the remote-change wiring stays folded into a factory — /// there's no public `startObservingRemoteChanges` to call twice. diff --git a/Where/WhereCore/Sources/Widgets/WidgetPresentationPublisher.swift b/Where/WhereCore/Sources/Widgets/WidgetPresentationPublisher.swift index ea777f04..e8342a95 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetPresentationPublisher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetPresentationPublisher.swift @@ -8,8 +8,10 @@ public actor WidgetPresentationPublisher { private let reloadTimelines: @Sendable () -> Void private var lastPublishedTheme: WhereTheme? - public init() { - makeStore = { try WidgetPresentationStore.shared() } + public init(appGroupIdentifier: String) { + makeStore = { + try WidgetPresentationStore.shared(appGroupIdentifier: appGroupIdentifier) + } reloadTimelines = { WidgetCenter.shared.reloadAllTimelines() } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetPresentationStore.swift b/Where/WhereCore/Sources/Widgets/WidgetPresentationStore.swift index 09a25272..7e2dba7c 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetPresentationStore.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetPresentationStore.swift @@ -12,7 +12,6 @@ public struct WidgetPresentationStore: Sendable { public init() {} } - private static let appGroupIdentifier = SwiftDataStore.appGroupIdentifier private static let fileName = "widget-presentation.json" private let directory: URL @@ -21,7 +20,7 @@ public struct WidgetPresentationStore: Sendable { self.directory = directory } - public static func shared() throws -> WidgetPresentationStore { + public static func shared(appGroupIdentifier: String) throws -> WidgetPresentationStore { guard let container = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupIdentifier, ) else { diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift index 5c27d4bc..e05901d3 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift @@ -20,10 +20,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 @@ -37,7 +33,7 @@ public struct WidgetSnapshotStore: Sendable { /// App Group-backed store shared by the app and widget. Throws /// `AppGroupUnavailableError` when the container can't be resolved. - public static func shared() throws -> WidgetSnapshotStore { + public static func shared(appGroupIdentifier: String) throws -> WidgetSnapshotStore { guard let container = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupIdentifier, ) else { diff --git a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift index 6e854221..6afd8ec8 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift @@ -27,12 +27,15 @@ public struct NoopWidgetTimelineRefresher: WidgetTimelineRefreshing { /// same snapshot, so any committed change can affect all of them. public struct WidgetCenterTimelineRefresher: WidgetTimelineRefreshing { private static let logger = WhereLog.widgets(WidgetTimelineRefresherLog.self) + private let appGroupIdentifier: String - public init() {} + public init(appGroupIdentifier: String) { + self.appGroupIdentifier = appGroupIdentifier + } public func publish(_ snapshot: WidgetSnapshot) async { do { - try WidgetSnapshotStore.shared().write(snapshot) + try WidgetSnapshotStore.shared(appGroupIdentifier: appGroupIdentifier).write(snapshot) Self.logger { .wroteSnapshot } } catch { Self.logger { .publishFailed(description: error.localizedDescription) } diff --git a/Where/WhereCore/Tests/WhereCoreTests.swift b/Where/WhereCore/Tests/WhereCoreTests.swift index c298ccac..429d7a92 100644 --- a/Where/WhereCore/Tests/WhereCoreTests.swift +++ b/Where/WhereCore/Tests/WhereCoreTests.swift @@ -37,6 +37,17 @@ struct SwiftDataStoreFactoryTests { let stored = try await store.allSamples() #expect(stored.map(\.id) == [sample.id]) } + + @Test func onDiskStorageCarriesItsAudienceAppGroup() { + let development = SwiftDataStore.Storage.localOnly( + appGroupIdentifier: "group.com.stuff.where.development", + ) + let production = SwiftDataStore.Storage.localOnly( + appGroupIdentifier: "group.com.stuff.where", + ) + + #expect(development != production) + } } struct SDLocationSampleTests { diff --git a/Where/WhereIntents/AGENTS.md b/Where/WhereIntents/AGENTS.md index 962f6dc4..d8479193 100644 --- a/Where/WhereIntents/AGENTS.md +++ b/Where/WhereIntents/AGENTS.md @@ -36,6 +36,9 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature `current()` (cancellation-aware). There is deliberately no self-open fallback. A `LogDayIntent` write therefore pings the same `changes()` signal the running UI refreshes from. +- **Inject the host App Group into `IntentServices`.** The Today intent's + optional widget-snapshot fast path uses it; no package target owns an + audience default. - **Resolve snippet services and `WhereTheme` together through `IntentServices.currentContext()`.** A view must never combine different handoff states. - **Every `perform()` wraps its work in `measureIntent(_:)`.** Each diff --git a/Where/WhereIntents/Sources/IntentServices.swift b/Where/WhereIntents/Sources/IntentServices.swift index 864d897c..d530736c 100644 --- a/Where/WhereIntents/Sources/IntentServices.swift +++ b/Where/WhereIntents/Sources/IntentServices.swift @@ -26,6 +26,8 @@ import WhereCore /// launch, the reset relaunch — replacing the cached stack, so intents always /// ride the current session's store instance. public actor IntentServices { + nonisolated let appGroupIdentifier: String + /// The store-sharing services and presentation identity resolved together. struct Context { let services: WhereServices @@ -42,7 +44,9 @@ public actor IntentServices { /// Create the instance the composition root owns (and tests build /// per-test); the app registers it with `AppDependencyManager` in /// `didFinishLaunching`, before the system can deliver an intent. - public init() {} + public init(appGroupIdentifier: String) { + self.appGroupIdentifier = appGroupIdentifier + } /// Install the store-sharing stack the app's composition root derived from /// the launch's services, resuming any parked intents. Idempotent per diff --git a/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift b/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift index 2c828c2c..ebefae4b 100644 --- a/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift +++ b/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift @@ -60,13 +60,16 @@ enum WhereIntentsLog: LogEvent { /// Indexing the tracked regions into Spotlight failed /// (degraded-but-handled: search integration is a nicety). case spotlightIndexFailed(description: String) + /// Reading the optional widget-snapshot fast path failed; the intent falls + /// back to its authoritative store report. + case widgetSnapshotReadFailed(description: String) static let eventName = "WhereIntents" var level: LogLevel { switch self { case .spotlightIndexed: .info - case .spotlightIndexFailed: + case .spotlightIndexFailed, .widgetSnapshotReadFailed: .warning } } @@ -77,6 +80,8 @@ enum WhereIntentsLog: LogEvent { "Indexed \(regionCount) region(s) for Spotlight" case let .spotlightIndexFailed(description): "Failed to index regions for Spotlight: \(description)" + case let .widgetSnapshotReadFailed(description): + "Failed to read the widget snapshot: \(description)" } } } diff --git a/Where/WhereIntents/Sources/TodayRegionsIntent.swift b/Where/WhereIntents/Sources/TodayRegionsIntent.swift index a5bbf3aa..a6489467 100644 --- a/Where/WhereIntents/Sources/TodayRegionsIntent.swift +++ b/Where/WhereIntents/Sources/TodayRegionsIntent.swift @@ -25,7 +25,23 @@ public struct TodayRegionsIntent: AppIntent { let context = try await intentServices.currentContext() let services = context.services let regions = try await measureIntent(.todayRegions) { - try await WhereIntentReader(services: services).todayRegions() + try await WhereIntentReader( + services: services, + todaySnapshot: { [appGroupIdentifier = intentServices.appGroupIdentifier] in + do { + return try WidgetSnapshotStore.shared( + appGroupIdentifier: appGroupIdentifier, + ).read() + } catch { + WhereIntentsLog.logger( + attachments: [.error(error, name: "snapshot-read-error")], + ) { + .widgetSnapshotReadFailed(description: String(describing: error)) + } + return nil + } + }, + ).todayRegions() } let ordered = orderedRegions(regions) return .result( diff --git a/Where/WhereIntents/Sources/WhereIntentReader.swift b/Where/WhereIntents/Sources/WhereIntentReader.swift index 1f8d992f..afeb0c84 100644 --- a/Where/WhereIntents/Sources/WhereIntentReader.swift +++ b/Where/WhereIntents/Sources/WhereIntentReader.swift @@ -11,11 +11,9 @@ struct WhereIntentReader { var calendar = Calendar.whereIntents var now: @Sendable () -> Date = { Date() } /// The published widget snapshot to use for the `todayRegions()` fast path. - /// Defaults to reading the shared App Group file; tests inject a value (or - /// `nil`) so the store fallback is exercised deterministically. - var todaySnapshot: @Sendable () -> WidgetSnapshot? = { - (try? WidgetSnapshotStore.shared())?.read() - } + /// The host injects the audience-specific App Group read; other callers + /// fall back to the report unless they provide one explicitly. + var todaySnapshot: @Sendable () -> WidgetSnapshot? = { nil } /// Day count for `region` in `year` — the `YearReport.totals` entry, or 0 /// when the region logged nothing. diff --git a/Where/WhereIntents/Tests/IntentServicesTests.swift b/Where/WhereIntents/Tests/IntentServicesTests.swift index 418a820f..b0c1c5b6 100644 --- a/Where/WhereIntents/Tests/IntentServicesTests.swift +++ b/Where/WhereIntents/Tests/IntentServicesTests.swift @@ -10,12 +10,14 @@ import Testing /// own `IntentServices` — the app-registered instance (see `AppDelegate` / /// `AppDependencyManager`) is never touched. struct IntentServicesTests { + private let appGroupIdentifier = "group.com.stuff.where.tests" + private func makeStack() throws -> WhereServices { try IntentTestSupport.services(store: SwiftDataStore.inMemory()) } @Test func currentReturnsTheInstalledStack() async throws { - let handoff = IntentServices() + let handoff = IntentServices(appGroupIdentifier: appGroupIdentifier) let stack = try makeStack() await handoff.install(stack, theme: .standard) @@ -25,7 +27,7 @@ struct IntentServicesTests { } @Test func currentParksUntilAStackIsInstalled() async throws { - let handoff = IntentServices() + let handoff = IntentServices(appGroupIdentifier: appGroupIdentifier) let parked = Task { try await handoff.current() } // Condition, not timing: the waiter is provably parked before the // install that must resume it. @@ -40,7 +42,7 @@ struct IntentServicesTests { } @Test func cancellingAParkedIntentThrowsAndUnparksIt() async throws { - let handoff = IntentServices() + let handoff = IntentServices(appGroupIdentifier: appGroupIdentifier) let parked = Task { try await handoff.current() } try await waitUntil { await handoff.waiterCount == 1 } @@ -53,7 +55,7 @@ struct IntentServicesTests { @Test func aLaterInstallReplacesTheCachedStack() async throws { // A reset relaunch installs a fresh session's stack; later intents must // ride it, not the stale one. - let handoff = IntentServices() + let handoff = IntentServices(appGroupIdentifier: appGroupIdentifier) let first = try makeStack() let second = try makeStack() await handoff.install(first, theme: .standard) @@ -67,7 +69,7 @@ struct IntentServicesTests { /// TLC property `AfterClearMustPark`: after `clear()`, a parked intent must /// resume on the next `install(_:)` rather than observing a cleared stack. @Test func clearWhileParkedResumesOnTheNextInstall() async throws { - let handoff = IntentServices() + let handoff = IntentServices(appGroupIdentifier: appGroupIdentifier) let parked = Task { try await handoff.current() } try await waitUntil { await handoff.waiterCount == 1 } @@ -81,7 +83,7 @@ struct IntentServicesTests { } @Test func themeUpdatesAtomicallyWithoutReplacingTheStack() async throws { - let handoff = IntentServices() + let handoff = IntentServices(appGroupIdentifier: appGroupIdentifier) let stack = try makeStack() await handoff.install(stack, theme: .standard) diff --git a/Where/WhereShareExtension/AGENTS.md b/Where/WhereShareExtension/AGENTS.md index 12f1d12d..2d0af3bf 100644 --- a/Where/WhereShareExtension/AGENTS.md +++ b/Where/WhereShareExtension/AGENTS.md @@ -9,11 +9,11 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Scope & dependencies -- **Tuist app-extension target** ([`Project.swift`](../../Project.swift), - bundle ID `com.stuff.where.share`), depending on **WhereCore**, **WhereUI**, - **PeriscopeCore**, and **SFSafeSymbols**. Embedded by the **Where** app. - Shares the `group.com.stuff.where` App Group entitlement. Logs via the - `WhereLog` facade (typed `ShareExtensionLog` events). As a separate process its +- **Tuist app-extension target** ([`Project.swift`](../../Project.swift), with + an audience-specific bundle ID and App Group), depending on **WhereCore**, + **WhereUI**, **PeriscopeCore**, and **SFSafeSymbols**. Embedded by the **Where** + app. Logs via the `WhereLog` facade (typed `ShareExtensionLog` events); as a + separate process its `Periscope.shared` is OSLog-only (no store). - Presentation reuses WhereUI's public `EvidenceKind.symbol`/`displayName`. Only extension chrome lives in this target's catalog. Reference it through its @@ -24,22 +24,23 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Invariants -- **Write directly through `SwiftDataStore.perform { write(evidence:blob:) }`. - Do not use `WhereServices`/`DayJournal`.** A short-lived share process must - not spin up the GPS ingestor, notifiers, or widget publisher. The store - commit's persistent-history ping is what the app reconciles from later. -- **Open `.localOnly` storage. Never use CloudKit.** The extension holds only - the App Group entitlement (no iCloud). It must not initialize the CloudKit - mirror. The app's container syncs the shared store's history. -- **`NSExtensionPrincipalClass` is `$(PRODUCT_MODULE_NAME).ShareViewController`.** - Keep the class name and Info.plist in sync. Save/cancel bridge to - `extensionContext` completion. The root view has no `@Environment(\.dismiss)`. -- **If a share has no loadable bytes, still compose** a metadata-only note - rather than failing. If a provider *reported* a reason for the empty result, - log it. `SharedItemLoader` reduces each callback to one `LoadedValue`. Then - "nothing, and here's why" must not be flattened into the same silence as "nothing - was offered". The load is also the extension's one span. Attachment count and - size are what the wait scales with. +- **Writes directly through `SwiftDataStore.perform { write(evidence:blob:) }`, + not `WhereServices`/`DayJournal`.** A short-lived share process must not spin + up the GPS ingestor, notifiers, or widget publisher; the store commit's + persistent-history ping is what the app reconciles from later. +- **Opens `.localOnly` storage, never CloudKit.** The extension holds only the + App Group entitlement (no iCloud), so it must not initialize the CloudKit + mirror; the app's container syncs the shared store's history. Resolve that + group through `WhereShareBuildEnvironment` and inject it into the store. +- **`NSExtensionPrincipalClass` is `$(PRODUCT_MODULE_NAME).ShareViewController`** + — keep the class name and Info.plist in sync. Save/cancel bridge to + `extensionContext` completion; the root view has no `@Environment(\.dismiss)`. +- **A share with no loadable bytes still composes** a metadata-only note rather + than failing — but a provider that *reported* a reason for the empty result + logs it. `SharedItemLoader` reduces each callback to one `LoadedValue`, so + "nothing, and here's why" can't be flattened into the same silence as "nothing + was offered". The load is also the extension's one span (attachment count and + size are what the wait scales with). ## Testing diff --git a/Where/WhereShareExtension/README.md b/Where/WhereShareExtension/README.md index 26a9b484..8b9d7ea6 100644 --- a/Where/WhereShareExtension/README.md +++ b/Where/WhereShareExtension/README.md @@ -14,7 +14,7 @@ Host app Share sheet └─▶ SharedItemLoader (extract bytes from NSItemProviders) └─▶ ShareEvidenceView + Model (SwiftUI compose sheet) └─▶ SwiftDataStore.perform { write(evidence:blob:) } - └─▶ App Group store (group.com.stuff.where) + └─▶ audience-selected App Group store ``` - **`SharedItemLoader`** takes one attachment per `NSItemProvider` that yields bytes (so a multi-item share — the activation rule allows up to 20 — keeps them all), preferring the most preview-friendly representation each registered: PDF → image → concrete file (`.pkpass`, `.eml`, …) → text → URL (kept as its string). A share with nothing loadable still composes as a metadata-only note, with whatever reason the provider gave logged as a warning. The whole load is one Periscope span, since it is what the wait between tapping Share and seeing the compose form is spent on. @@ -35,8 +35,13 @@ The app's CloudKit container picks the write up from the shared store's history. ## Installation -`WhereShareExtension` is a Tuist app-extension target in [`Project.swift`](../../Project.swift) (bundle ID `com.stuff.where.share`), depending on **WhereCore**, **WhereUI**, and **PeriscopeCore**. -The main **Where** app embeds the extension and shares the `group.com.stuff.where` App Group entitlement so both processes open the same SwiftData store. +`WhereShareExtension` is a Tuist app-extension target in +[`Project.swift`](../../Project.swift), with a bundle ID and App Group selected +by the Where audience (Development is isolated; Beta and App Store share the +production family), +depending on **WhereCore**, **WhereUI**, and **PeriscopeCore**. The main **Where** app +embeds the extension with the same audience-selected App Group entitlement so +both processes open the same SwiftData store. ## Limitations diff --git a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift index 67c9657b..3201f018 100644 --- a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift +++ b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift @@ -46,7 +46,7 @@ final class ShareEvidenceModel { init( items: [NSExtensionItem], - storage: SwiftDataStore.Storage = .localOnly, + storage: SwiftDataStore.Storage, now: @Sendable () -> Date = { Date() }, ) { self.items = items diff --git a/Where/WhereShareExtension/Sources/ShareViewController.swift b/Where/WhereShareExtension/Sources/ShareViewController.swift index 77db519e..5a056f6a 100644 --- a/Where/WhereShareExtension/Sources/ShareViewController.swift +++ b/Where/WhereShareExtension/Sources/ShareViewController.swift @@ -19,7 +19,13 @@ final class ShareViewController: UIViewController { let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? [] Self.logger { .opened(itemCount: items.count) } - let model = ShareEvidenceModel(items: items) + let buildEnvironment = WhereShareBuildEnvironment.current() + let model = ShareEvidenceModel( + items: items, + storage: .localOnly( + appGroupIdentifier: buildEnvironment.appGroupIdentifier, + ), + ) let root = ShareEvidenceView( model: model, onSave: { [weak self] in self?.complete() }, diff --git a/Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift b/Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift new file mode 100644 index 00000000..431e85d5 --- /dev/null +++ b/Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Audience values resolved by the share host and injected into WhereCore. +struct WhereShareBuildEnvironment { + let appGroupIdentifier: String + + static func current( + infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:], + ) -> WhereShareBuildEnvironment { + let stampedAudience = requireString("WhereAudience", in: infoDictionary) + precondition( + stampedAudience == compiledAudience, + "WhereAudience does not match the share target's compiler condition", + ) + return WhereShareBuildEnvironment(appGroupIdentifier: requireString( + "WhereAppGroupIdentifier", + in: infoDictionary, + )) + } + + private static func requireString( + _ key: String, + in infoDictionary: [String: Any], + ) -> String { + guard let value = infoDictionary[key] as? String, !value.isEmpty else { + preconditionFailure("WhereShareExtension's Info.plist has no non-empty \(key)") + } + return value + } + + #if WHERE_DEVELOPMENT && WHERE_BETA + #error("Exactly one Where audience compiler condition must be active") + #endif + #if WHERE_DEVELOPMENT && WHERE_APP_STORE + #error("Exactly one Where audience compiler condition must be active") + #endif + #if WHERE_BETA && WHERE_APP_STORE + #error("Exactly one Where audience compiler condition must be active") + #endif + + #if WHERE_DEVELOPMENT + private static let compiledAudience = "development" + #elseif WHERE_BETA + private static let compiledAudience = "beta" + #elseif WHERE_APP_STORE + private static let compiledAudience = "appStore" + #else + #error("A Where audience compiler condition must be active") + #endif +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 7d48ecc6..6889cb10 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -18,9 +18,12 @@ Layering, localization, preview, and testing conventions live in the feature [`Package.swift`](../../Package.swift). - WhereUI maps Core's persisted `RegionSymbol` values to SFSafeSymbols' `SFSymbol` and re-exports SFSafeSymbols for its presentation API consumers. -- Composition is the one exception. `WhereScope` and `WhereModel` decide which - world the app is logged in to and assemble it. That is launch wiring, not - domain logic. See [Scopes and the launch](../AGENTS.md#scopes-and-the-launch). +- 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). +- The app injects its configured primary icon name at `RootView`; icon-picker + code treats every manifest entry as an asset and derives primary versus + alternate status from that injected name. - Keep `FileInstallationRecordingContextStore` as the UIKit/FileManager adapter for Core's installation-context protocol. Resolve one instance at the app root. Inject it into both `WhereModel` and `WhereBootstrap`. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 7f10d70c..6d638a52 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -72,6 +72,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's developer relaunches. The Logs destination is always present. Before its durable store is ready it reports whether the open is still running, unavailable, or failed with the actual error. +- **App icons** — `AppIcons.json` catalogs asset names, while the host injects + the current audience's primary asset at `RootView`. The picker maps that one + asset to UIKit's `nil` primary-icon value and treats every other catalogued + asset as an alternate, so primary status may differ by build audience. - **`WhereLaunch`** — the launch, reset, and exit-demo plans themselves. Every step declares a budget (`BudgetedLaunchStep`) and joins the plan through `.measured()`, so each run is one Periscope span named after diff --git a/Where/WhereUI/Sources/Launch/LaunchSplashView.swift b/Where/WhereUI/Sources/Launch/LaunchSplashView.swift index e3e934a7..70f72595 100644 --- a/Where/WhereUI/Sources/Launch/LaunchSplashView.swift +++ b/Where/WhereUI/Sources/Launch/LaunchSplashView.swift @@ -45,6 +45,7 @@ struct LaunchSplashView: View { @Environment(\.isCapturingSnapshot) private var isCapturingSnapshot @MotionIsStatic private var motionIsStatic @Environment(\.stylesheet) private var stylesheet + @Environment(\.primaryAppIconName) private var primaryAppIconName @State private var pulsing = false @State private var showCaption: Bool @@ -106,7 +107,9 @@ struct LaunchSplashView: View { } var body: some View { - let imageName = injectedPreviewImageName ?? AppIconCatalog.liveSelectedPreviewImageName() + let imageName = injectedPreviewImageName ?? AppIconCatalog.liveSelectedPreviewImageName( + primaryAppIconName: primaryAppIconName, + ) ZStack { background RadarPingBackground(tint: splash.iconGlow) diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index e880233a..41270a4f 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -251,6 +251,7 @@ public final class WhereBootstrap: WhereScopeAssembling { private let installationContextStore: any InstallationRecordingContextStoring private let storeStorage: SwiftDataStore.Storage + private let widgetRefresher: any WidgetTimelineRefreshing private let locationOutbox: any LocationOutbox private var locationSource: CoreLocationSource? private var preparedStore: SwiftDataStore? @@ -258,10 +259,12 @@ public final class WhereBootstrap: WhereScopeAssembling { public init( installationContextStore: any InstallationRecordingContextStoring, storeStorage: SwiftDataStore.Storage, + widgetRefresher: any WidgetTimelineRefreshing, locationOutbox: any LocationOutbox, ) { self.installationContextStore = installationContextStore self.storeStorage = storeStorage + self.widgetRefresher = widgetRefresher self.locationOutbox = locationOutbox } @@ -309,7 +312,7 @@ public final class WhereBootstrap: WhereScopeAssembling { reminderScheduler: UserNotificationReminderScheduler(), summaryScheduler: UserNotificationDailySummaryScheduler(), issueAlertScheduler: UserNotificationDataIssueAlertScheduler(), - widgetRefresher: WidgetCenterTimelineRefresher(), + widgetRefresher: widgetRefresher, locationOutbox: locationOutbox, importRecoveryPersistence: installationContextStore, ) @@ -325,9 +328,9 @@ public final class WhereBootstrap: WhereScopeAssembling { public func discoverRecordingDevices() async throws -> [RecordingDevice] { let readiness = CloudKitImportReadiness() - if storeStorage == .cloudKit { readiness.start() } + if storeStorage.usesCloudKit { readiness.start() } let store = try await prepareStore() - if storeStorage == .cloudKit, await readiness.waitForImport() == false { + if storeStorage.usesCloudKit, await readiness.waitForImport() == false { throw CloudKitImportReadiness.Timeout() } return try await store.recordingDevices() diff --git a/Where/WhereUI/Sources/Resources/AppIcons.json b/Where/WhereUI/Sources/Resources/AppIcons.json index 729b2b5a..83536a64 100644 --- a/Where/WhereUI/Sources/Resources/AppIcons.json +++ b/Where/WhereUI/Sources/Resources/AppIcons.json @@ -3,19 +3,19 @@ { "id" : "classic", "displayName" : "Classic", - "alternateIconName" : null, + "assetName" : "AppIcon", "previewImageName" : "AppIconClassic" }, { "id" : "pride", "displayName" : "Pride", - "alternateIconName" : "AppIconPride", + "assetName" : "AppIconPride", "previewImageName" : "AppIconPride" }, { "id" : "solid", "displayName" : "Solid", - "alternateIconName" : "AppIconSolid", + "assetName" : "AppIconSolid", "previewImageName" : "AppIconSolid" } ] diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 9af9507d..c77ad91e 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -53,6 +53,7 @@ public struct RootView: View { ) #endif private let launcher: LifecycleRunner + private let primaryAppIconName: String #if DEBUG private let inspectorModeController: InspectorModeController? /// Hosted snapshots have no active SwiftUI scene even though their @@ -66,10 +67,12 @@ public struct RootView: View { public init( model: WhereModel, launcher: LifecycleRunner, + primaryAppIconName: String, inspectorModeController: InspectorModeController? = nil, ) { _model = State(initialValue: model) self.launcher = launcher + self.primaryAppIconName = primaryAppIconName self.inspectorModeController = inspectorModeController presentationVisibilityOverride = nil } @@ -78,10 +81,12 @@ public struct RootView: View { public init( model: WhereModel, launcher: LifecycleRunner, + primaryAppIconName: String, presentationVisibilityOverride: Bool, ) { _model = State(initialValue: model) self.launcher = launcher + self.primaryAppIconName = primaryAppIconName inspectorModeController = nil self.presentationVisibilityOverride = presentationVisibilityOverride } @@ -89,9 +94,11 @@ public struct RootView: View { public init( model: WhereModel, launcher: LifecycleRunner, + primaryAppIconName: String, ) { _model = State(initialValue: model) self.launcher = launcher + self.primaryAppIconName = primaryAppIconName } #endif @@ -112,6 +119,7 @@ public struct RootView: View { WhereBootstrap( installationContextStore: $0, storeStorage: .inMemory, + widgetRefresher: NoopWidgetTimelineRefresher(), locationOutbox: NoOpLocationOutbox(), ) }, @@ -119,6 +127,7 @@ public struct RootView: View { ) _model = State(initialValue: model) launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) + primaryAppIconName = "AppIcon" #if DEBUG inspectorModeController = nil presentationVisibilityOverride = nil @@ -206,6 +215,7 @@ public struct RootView: View { // re-inject when a reset rebuilds it. The DEBUG developer overlay // reads it optionally — it can appear before login. .environment(model.session) + .environment(\.primaryAppIconName, primaryAppIconName) #if DEBUG .environment(inspectorModeController) .environment(\.cardDesignerModel, cardDesigner) @@ -346,6 +356,7 @@ public struct RootView: View { RootView( model: model, launcher: launcher, + primaryAppIconName: "AppIcon", presentationVisibilityOverride: true, ) } @@ -358,6 +369,7 @@ public struct RootView: View { RootView( model: recordingWarningModel, launcher: recordingWarningLauncher, + primaryAppIconName: "AppIcon", presentationVisibilityOverride: true, ) } diff --git a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift index b2b163cf..75803ad1 100644 --- a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift @@ -250,7 +250,7 @@ extension AboutSettingsView: SettingsSection { // The navigation bar's scroll-edge shadow adapts after the // form reaches its full-content height. Wait through that // otherwise quiet transition before accessibility annotation. - settle: .settledAtLeast(minDuration: 0.75), + settle: .settledAtLeast(minDuration: 1.5), ) { NavigationStack { AboutSettingsView( diff --git a/Where/WhereUI/Sources/Settings/AppIconModel.swift b/Where/WhereUI/Sources/Settings/AppIconModel.swift index 80a41048..7ad4db9c 100644 --- a/Where/WhereUI/Sources/Settings/AppIconModel.swift +++ b/Where/WhereUI/Sources/Settings/AppIconModel.swift @@ -19,15 +19,22 @@ final class AppIconModel { var applyError: String? private let setter: any AlternateIconSetting + private let primaryAppIconName: String init( + primaryAppIconName: String, options: [AppIconOption]? = nil, setter: any AlternateIconSetting = UIApplication.shared, ) { let resolved = options ?? AppIconCatalog.loadedOptions() self.options = resolved self.setter = setter - selectedID = AppIconModel.matchSelection(in: resolved, current: setter.alternateIconName) + self.primaryAppIconName = primaryAppIconName + selectedID = AppIconModel.matchSelection( + in: resolved, + current: setter.alternateIconName, + primaryAppIconName: primaryAppIconName, + ) } /// Whether the device supports alternate icons at all (false is rare on @@ -52,7 +59,9 @@ final class AppIconModel { func apply(_ option: AppIconOption) async -> Bool { guard option.id != selectedID, supportsAlternateIcons else { return false } do { - try await setter.setAlternateIconName(option.alternateIconName) + try await setter.setAlternateIconName(option.alternateIconName( + primaryAppIconName: primaryAppIconName, + )) selectedID = option.id return true } catch { @@ -75,8 +84,13 @@ final class AppIconModel { private static func matchSelection( in options: [AppIconOption], current alternateIconName: String?, + primaryAppIconName: String, ) -> AppIconID { - AppIconCatalog.selectedOption(in: options, current: alternateIconName)?.id ?? AppIconID("") + AppIconCatalog.selectedOption( + in: options, + current: alternateIconName, + primaryAppIconName: primaryAppIconName, + )?.id ?? AppIconID("") } } @@ -86,6 +100,7 @@ final class AppIconModel { /// canvas never touch the springboard. static func preview(activeAlternateIconName: String? = nil) -> AppIconModel { AppIconModel( + primaryAppIconName: "AppIcon", setter: InMemoryAlternateIconSetting(alternateIconName: activeAlternateIconName), ) } diff --git a/Where/WhereUI/Sources/Settings/AppIconOption.swift b/Where/WhereUI/Sources/Settings/AppIconOption.swift index d0ceb36c..975b079a 100644 --- a/Where/WhereUI/Sources/Settings/AppIconOption.swift +++ b/Where/WhereUI/Sources/Settings/AppIconOption.swift @@ -36,19 +36,19 @@ extension AppIconID: Codable { /// One selectable app icon, decoded from the bundled `AppIcons.json` manifest /// that the `./icons` script maintains. /// -/// `alternateIconName` is the asset-catalog appiconset name passed to -/// `setAlternateIconName`; `nil` marks the primary icon. `previewImageName` -/// names an imageset in `AppIconPreviews.xcassets` — a parallel catalog, since -/// SwiftUI `Image` can't load appiconset images — rendered by the picker. +/// `assetName` is the asset-catalog appiconset name. Whether it is primary or +/// alternate depends on the host's injected primary icon for this build. +/// `previewImageName` names an imageset in `AppIconPreviews.xcassets` — a +/// parallel catalog, since SwiftUI `Image` can't load appiconset images. struct AppIconOption: Identifiable, Hashable, Codable { let id: AppIconID let displayName: String - let alternateIconName: String? + let assetName: String let previewImageName: String - /// Whether this is the primary (default) icon, i.e. `setAlternateIconName(nil)`. - var isPrimary: Bool { - alternateIconName == nil + /// The value to pass to UIKit for a build with `primaryAppIconName`. + func alternateIconName(primaryAppIconName: String) -> String? { + assetName == primaryAppIconName ? nil : assetName } } @@ -98,9 +98,12 @@ enum AppIconCatalog { static func selectedOption( in options: [AppIconOption], current alternateIconName: String?, + primaryAppIconName: String, ) -> AppIconOption? { - options.first { $0.alternateIconName == alternateIconName } - ?? options.first { $0.isPrimary } + options.first { + $0.alternateIconName(primaryAppIconName: primaryAppIconName) == alternateIconName + } + ?? options.first { $0.assetName == primaryAppIconName } ?? options.first } @@ -109,11 +112,14 @@ enum AppIconCatalog { /// manifest and falling back to the bundled "Classic" art. Shared by every /// in-app surface that renders the selected icon (launch splash and shared /// loading states) so they stay in lockstep. - @MainActor static func liveSelectedPreviewImageName() -> String { + @MainActor static func liveSelectedPreviewImageName( + primaryAppIconName: String, + ) -> String { let options = loadedOptions() let selected = selectedOption( in: options, current: UIApplication.shared.alternateIconName, + primaryAppIconName: primaryAppIconName, ) return selected?.previewImageName ?? "AppIconClassic" } diff --git a/Where/WhereUI/Sources/Settings/AppIconView.swift b/Where/WhereUI/Sources/Settings/AppIconView.swift index 133edcc1..7c9aeaeb 100644 --- a/Where/WhereUI/Sources/Settings/AppIconView.swift +++ b/Where/WhereUI/Sources/Settings/AppIconView.swift @@ -19,7 +19,14 @@ struct AppIconView: View { @Environment(\.stylesheet) private var stylesheet @MainActor - init(model: AppIconModel = AppIconModel()) { + init(primaryAppIconName: String) { + _model = State(initialValue: AppIconModel( + primaryAppIconName: primaryAppIconName, + )) + } + + @MainActor + init(model: AppIconModel) { _model = State(initialValue: model) } @@ -315,7 +322,9 @@ struct AppIconImage: View { configurations: .fullContentScreenDefaults, settle: .immediate, ) { - NavigationStack { AppIconView(model: .preview()) } + NavigationStack { + AppIconView(model: .preview()) + } } } } diff --git a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift index e6d29eeb..d4719b0e 100644 --- a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift @@ -6,6 +6,7 @@ import WhereCore /// Settings drill-in for presentation choices: theme, Locations-card overlays, /// and alternate app icon. struct AppearanceSettingsView: View { + @Environment(\.primaryAppIconName) private var primaryAppIconName let report: YearReportModel var focus: SettingsFocus? @@ -109,7 +110,7 @@ struct AppearanceSettingsView: View { .navigationTitle(String(localized: .settingsAppearanceGroup)) .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $showAppIcon) { - AppIconView() + AppIconView(primaryAppIconName: primaryAppIconName) } .alert( String(localized: .settingsAppearanceLocationForecastsDisableErrorTitle), diff --git a/Where/WhereUI/Sources/Settings/FeaturePreviews/Personalization/PersonalizationFeaturesView.swift b/Where/WhereUI/Sources/Settings/FeaturePreviews/Personalization/PersonalizationFeaturesView.swift index 3b8e2cf3..1d99dd35 100644 --- a/Where/WhereUI/Sources/Settings/FeaturePreviews/Personalization/PersonalizationFeaturesView.swift +++ b/Where/WhereUI/Sources/Settings/FeaturePreviews/Personalization/PersonalizationFeaturesView.swift @@ -19,11 +19,14 @@ struct PersonalizationFeaturesView: View { init( report: YearReportModel, focus: SettingsFocus?, - iconModel: AppIconModel = AppIconModel(), + primaryAppIconName: String, + iconModel: AppIconModel? = nil, ) { self.report = report self.focus = focus - _iconModel = State(initialValue: iconModel) + _iconModel = State(initialValue: iconModel ?? AppIconModel( + primaryAppIconName: primaryAppIconName, + )) } var body: some View { @@ -171,6 +174,7 @@ extension PersonalizationFeaturesView: SettingsSection { PersonalizationFeaturesView( report: PreviewSupport.loadedYearReportModel(), focus: nil, + primaryAppIconName: "AppIcon", iconModel: .preview(activeAlternateIconName: "AppIconPride"), ) } diff --git a/Where/WhereUI/Sources/Settings/PrimaryAppIconEnvironment.swift b/Where/WhereUI/Sources/Settings/PrimaryAppIconEnvironment.swift new file mode 100644 index 00000000..e8675db7 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/PrimaryAppIconEnvironment.swift @@ -0,0 +1,5 @@ +import SwiftUI + +extension EnvironmentValues { + @Entry var primaryAppIconName: String = "AppIcon" +} diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 6188ba03..798342eb 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -27,6 +27,7 @@ struct SettingsView: View { @Environment(WhereModel.self) private var model @Environment(\.lifecycle) private var lifecycle @Environment(\.isInDemoMode) private var isInDemoMode + @Environment(\.primaryAppIconName) private var primaryAppIconName init( report: YearReportModel, @@ -298,7 +299,11 @@ struct SettingsView: View { focus: route.focus, ) case .personalization: - PersonalizationFeaturesView(report: report, focus: route.focus) + PersonalizationFeaturesView( + report: report, + focus: route.focus, + primaryAppIconName: primaryAppIconName, + ) case .data: DataSettingsView(report: report, backup: backup, focus: route.focus) case .privacyDiagnostics: diff --git a/Where/WhereUI/Sources/Shared/AppIconActivityIndicator.swift b/Where/WhereUI/Sources/Shared/AppIconActivityIndicator.swift index 6a6fba73..f15fde7c 100644 --- a/Where/WhereUI/Sources/Shared/AppIconActivityIndicator.swift +++ b/Where/WhereUI/Sources/Shared/AppIconActivityIndicator.swift @@ -22,9 +22,15 @@ struct AppIconActivityIndicator: View { private let imageName: String @MainActor - init(size: CGFloat = 88, previewImageName: String? = nil) { + init( + primaryAppIconName: String, + size: CGFloat = 88, + previewImageName: String? = nil, + ) { self.size = size - imageName = previewImageName ?? AppIconCatalog.liveSelectedPreviewImageName() + imageName = previewImageName ?? AppIconCatalog.liveSelectedPreviewImageName( + primaryAppIconName: primaryAppIconName, + ) } var body: some View { @@ -53,12 +59,18 @@ struct AppIconActivityIndicator: View { #if DEBUG #Preview("Light") { - AppIconActivityIndicator(previewImageName: "AppIconClassic") - .environment(\.colorScheme, .light) + AppIconActivityIndicator( + primaryAppIconName: "AppIcon", + previewImageName: "AppIconClassic", + ) + .environment(\.colorScheme, .light) } #Preview("Dark") { - AppIconActivityIndicator(previewImageName: "AppIconClassic") - .environment(\.colorScheme, .dark) + AppIconActivityIndicator( + primaryAppIconName: "AppIcon", + previewImageName: "AppIconClassic", + ) + .environment(\.colorScheme, .dark) } #endif diff --git a/Where/WhereUI/Sources/Shared/AppIconLoadingView.swift b/Where/WhereUI/Sources/Shared/AppIconLoadingView.swift index af957ee7..a676c99a 100644 --- a/Where/WhereUI/Sources/Shared/AppIconLoadingView.swift +++ b/Where/WhereUI/Sources/Shared/AppIconLoadingView.swift @@ -6,13 +6,15 @@ import SwiftUI /// scan, a summary generating — so they share one look and one accessibility /// shape instead of each rebuilding a spinner-plus-label. struct AppIconLoadingView: View { + @Environment(\.primaryAppIconName) private var primaryAppIconName + let caption: String @Environment(\.stylesheet) private var stylesheet var body: some View { VStack(spacing: stylesheet.spacing.xxLarge) { - AppIconActivityIndicator() + AppIconActivityIndicator(primaryAppIconName: primaryAppIconName) Text(caption) .font(.callout) .foregroundStyle(.secondary) diff --git a/Where/WhereUI/Tests/AppIconModelTests.swift b/Where/WhereUI/Tests/AppIconModelTests.swift index fdcc3bd4..905c9dc7 100644 --- a/Where/WhereUI/Tests/AppIconModelTests.swift +++ b/Where/WhereUI/Tests/AppIconModelTests.swift @@ -9,13 +9,13 @@ struct AppIconModelTests { AppIconOption( id: AppIconID("classic"), displayName: "Classic", - alternateIconName: nil, + assetName: "AppIcon", previewImageName: "AppIconClassic", ), AppIconOption( id: AppIconID("ocean"), displayName: "Ocean", - alternateIconName: "AppIconOcean", + assetName: "AppIconOcean", previewImageName: "AppIconOcean", ), ] @@ -23,36 +23,71 @@ struct AppIconModelTests { @Test func initialSelectionDerivesFromTheLiveIcon() { let setter = FakeIconSetter(alternateIconName: "AppIconOcean") - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) #expect(model.selectedID == AppIconID("ocean")) } @Test func initialSelectionFallsBackToThePrimary() { let setter = FakeIconSetter(alternateIconName: nil) - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) #expect(model.selectedID == AppIconID("classic")) } + @Test func nilLiveNameResolvesToANonClassicBuildPrimary() { + let setter = FakeIconSetter(alternateIconName: nil) + let model = AppIconModel( + primaryAppIconName: "AppIconOcean", + options: options(), + setter: setter, + ) + + #expect(model.selectedID == AppIconID("ocean")) + } + @Test func selectedOptionMatchesTheLiveAlternateName() { - let selected = AppIconCatalog.selectedOption(in: options(), current: "AppIconOcean") + let selected = AppIconCatalog.selectedOption( + in: options(), + current: "AppIconOcean", + primaryAppIconName: "AppIcon", + ) #expect(selected?.id == AppIconID("ocean")) } @Test func selectedOptionFallsBackToThePrimaryWhenNil() { - let selected = AppIconCatalog.selectedOption(in: options(), current: nil) + let selected = AppIconCatalog.selectedOption( + in: options(), + current: nil, + primaryAppIconName: "AppIcon", + ) #expect(selected?.id == AppIconID("classic")) } @Test func selectedOptionFallsBackToThePrimaryForAnUnknownName() { // An alternate icon set by an older build but since dropped from the // manifest resolves to the primary rather than nothing. - let selected = AppIconCatalog.selectedOption(in: options(), current: "AppIconGone") + let selected = AppIconCatalog.selectedOption( + in: options(), + current: "AppIconGone", + primaryAppIconName: "AppIcon", + ) #expect(selected?.id == AppIconID("classic")) } @Test func applySetsTheIconAndUpdatesSelection() async { let setter = FakeIconSetter(alternateIconName: nil) - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) await model.apply(options()[1]) @@ -63,7 +98,11 @@ struct AppIconModelTests { @Test func applyingThePrimaryClearsTheAlternateIcon() async { let setter = FakeIconSetter(alternateIconName: "AppIconOcean") - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) await model.apply(options()[0]) @@ -71,9 +110,27 @@ struct AppIconModelTests { #expect(model.selectedID == AppIconID("classic")) } + @Test func classicIsAnAlternateWhenAnotherAssetIsPrimary() async { + let setter = FakeIconSetter(alternateIconName: nil) + let model = AppIconModel( + primaryAppIconName: "AppIconOcean", + options: options(), + setter: setter, + ) + + await model.apply(options()[0]) + + #expect(setter.alternateIconName == "AppIcon") + #expect(model.selectedID == AppIconID("classic")) + } + @Test func applyIsANoOpWhenAlreadySelected() async { let setter = FakeIconSetter(alternateIconName: nil) - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) await model.apply(options()[0]) @@ -83,7 +140,11 @@ struct AppIconModelTests { @Test func applySurfacesErrorsAndLeavesSelectionUnchanged() async { let setter = FakeIconSetter(alternateIconName: nil) setter.errorToThrow = FakeIconError.boom - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) await model.apply(options()[1]) @@ -97,7 +158,11 @@ struct AppIconModelTests { @Test func unsupportedDevicesDoNotAttemptAChange() async { let setter = FakeIconSetter(supportsAlternateIcons: false, alternateIconName: nil) - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) await model.apply(options()[1]) @@ -108,12 +173,14 @@ struct AppIconModelTests { @Test func bundledManifestLoadsAndIsConsistent() throws { let options = try AppIconCatalog.load() - #expect(!options.isEmpty) + #expect(options.isEmpty == false) #expect(options.contains { $0.id == AppIconID("classic") }) - #expect(options.filter(\.isPrimary).count == 1) + #expect(options.contains { $0.assetName == "AppIcon" }) let ids = options.map(\.id) #expect(Set(ids).count == ids.count) + let assetNames = options.map(\.assetName) + #expect(Set(assetNames).count == assetNames.count) } /// Guards the core manifest-driven invariant: every option the picker lists diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md index 4bc74fbc..ba521adb 100644 --- a/Where/WhereWidgets/AGENTS.md +++ b/Where/WhereWidgets/AGENTS.md @@ -9,8 +9,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Scope & dependencies -- **Tuist app-extension target** ([`Project.swift`](../../Project.swift), - bundle ID `com.stuff.where.widgets`), depending on **WhereCore**, +- **Tuist app-extension target** ([`Project.swift`](../../Project.swift), with + an audience-specific bundle ID and App Group), depending on **WhereCore**, **WhereUI**, **RegionKit**, and **PeriscopeCore**. - Must **not** import SwiftData, open the user's store, or duplicate aggregation logic. The app publishes. The extension only reads and renders. @@ -29,8 +29,11 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Invariants -- **Read-only App Group access.** Only the app writes `widget-snapshot.json` and - `widget-presentation.json`. +- **Read-only App Group access** — only the app writes `widget-snapshot.json` + and `widget-presentation.json`. +- **Resolve the host App Group from `WhereWidgetBuildEnvironment`.** Inject it + into providers and both stores; never put an audience default in a package + target. - **No stale-day invalidation in the provider.** A snapshot whose `day` rolled past today is still shown until the app republishes. That is intentional. - In-widget strings come from WhereUI (shared views + `WhereFormat`). The diff --git a/Where/WhereWidgets/README.md b/Where/WhereWidgets/README.md index 4d2ab8e6..b102f0cd 100644 --- a/Where/WhereWidgets/README.md +++ b/Where/WhereWidgets/README.md @@ -5,7 +5,7 @@ It provides home-screen and lock-screen widgets that show today's region presenc Widgets never open the SwiftData store. The app publishes an aggregated [`WidgetSnapshot`](../WhereCore/Sources/Widgets/WidgetDataReader.swift) JSON file -into the shared App Group (`group.com.stuff.where`). This extension reads it via +into its audience's shared App Group; this extension reads it via [`WidgetSnapshotStore`](../WhereCore/Sources/Widgets/WidgetSnapshotStore.swift). The device-local theme is independently published through `WidgetPresentationStore`. An appearance change does not rebuild the data snapshot. @@ -44,9 +44,13 @@ Appearance changes publish only that small value before reloading timelines. ## Installation -`WhereWidgets` is a 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. +`WhereWidgets` is a Tuist app-extension target in +[`Project.swift`](../../Project.swift). Its bundle ID and App Group follow the +selected Where audience (Development is isolated; Beta and App Store share the +production family). +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. ## Previews diff --git a/Where/WhereWidgets/Sources/TodayWidget.swift b/Where/WhereWidgets/Sources/TodayWidget.swift index 6b839abb..a5b4bba0 100644 --- a/Where/WhereWidgets/Sources/TodayWidget.swift +++ b/Where/WhereWidgets/Sources/TodayWidget.swift @@ -8,9 +8,21 @@ import WidgetKit /// maps the widget family to a view. struct TodayWidget: Widget { static let kind = "com.stuff.where.widgets.today" + let appGroupIdentifier: String + + init() { + appGroupIdentifier = WhereWidgetBuildEnvironment.current().appGroupIdentifier + } + + init(appGroupIdentifier: String) { + self.appGroupIdentifier = appGroupIdentifier + } var body: some WidgetConfiguration { - StaticConfiguration(kind: Self.kind, provider: WhereWidgetProvider()) { entry in + StaticConfiguration( + kind: Self.kind, + provider: WhereWidgetProvider(appGroupIdentifier: appGroupIdentifier), + ) { entry in TodayWidgetContent(entry: entry) // Seed the Broadway context so the shared WhereUI content views // resolve trait-aware `@Environment(\.stylesheet)` tokens instead @@ -61,7 +73,7 @@ private struct TodayWidgetContent: View { #if DEBUG #Preview("Small", as: .systemSmall) { - TodayWidget() + TodayWidget(appGroupIdentifier: "group.com.stuff.where.preview") } timeline: { WhereWidgetEntry.sample WhereWidgetEntry.previewMultiRegion @@ -69,14 +81,14 @@ private struct TodayWidgetContent: View { } #Preview("Inline", as: .accessoryInline) { - TodayWidget() + TodayWidget(appGroupIdentifier: "group.com.stuff.where.preview") } timeline: { WhereWidgetEntry.previewMultiRegion WhereWidgetEntry.previewEmpty } #Preview("Circular", as: .accessoryCircular) { - TodayWidget() + TodayWidget(appGroupIdentifier: "group.com.stuff.where.preview") } timeline: { WhereWidgetEntry.previewMultiRegion WhereWidgetEntry.sample diff --git a/Where/WhereWidgets/Sources/WhereWidgetBuildEnvironment.swift b/Where/WhereWidgets/Sources/WhereWidgetBuildEnvironment.swift new file mode 100644 index 00000000..e2c957a4 --- /dev/null +++ b/Where/WhereWidgets/Sources/WhereWidgetBuildEnvironment.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Audience values resolved by the widget host and injected into WhereCore. +struct WhereWidgetBuildEnvironment { + let appGroupIdentifier: String + + static func current( + infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:], + ) -> WhereWidgetBuildEnvironment { + let stampedAudience = requireString("WhereAudience", in: infoDictionary) + precondition( + stampedAudience == compiledAudience, + "WhereAudience does not match the widget target's compiler condition", + ) + return WhereWidgetBuildEnvironment(appGroupIdentifier: requireString( + "WhereAppGroupIdentifier", + in: infoDictionary, + )) + } + + private static func requireString( + _ key: String, + in infoDictionary: [String: Any], + ) -> String { + guard let value = infoDictionary[key] as? String, !value.isEmpty else { + preconditionFailure("WhereWidgets' Info.plist has no non-empty \(key)") + } + return value + } + + #if WHERE_DEVELOPMENT && WHERE_BETA + #error("Exactly one Where audience compiler condition must be active") + #endif + #if WHERE_DEVELOPMENT && WHERE_APP_STORE + #error("Exactly one Where audience compiler condition must be active") + #endif + #if WHERE_BETA && WHERE_APP_STORE + #error("Exactly one Where audience compiler condition must be active") + #endif + + #if WHERE_DEVELOPMENT + private static let compiledAudience = "development" + #elseif WHERE_BETA + private static let compiledAudience = "beta" + #elseif WHERE_APP_STORE + private static let compiledAudience = "appStore" + #else + #error("A Where audience compiler condition must be active") + #endif +} diff --git a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift index a70ede87..fffcabe7 100644 --- a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift +++ b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift @@ -23,6 +23,7 @@ struct WhereWidgetEntry: TimelineEntry { struct WhereWidgetProvider: TimelineProvider { private static let logger = WhereLog.root(WhereWidgetsLog.self) private static let calendar = WidgetSnapshotFixtures.calendar + let appGroupIdentifier: String func placeholder(in _: Context) -> WhereWidgetEntry { .sample @@ -56,8 +57,12 @@ struct WhereWidgetProvider: TimelineProvider { let now = Date() var theme = WhereTheme.standard do { - let store = try WidgetSnapshotStore.shared() - theme = try WidgetPresentationStore.shared().readTheme() + let store = try WidgetSnapshotStore.shared( + appGroupIdentifier: appGroupIdentifier, + ) + theme = try WidgetPresentationStore.shared( + appGroupIdentifier: appGroupIdentifier, + ).readTheme() if let snapshot = store.read() { return WhereWidgetEntry(date: now, snapshot: snapshot, theme: theme) } diff --git a/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift b/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift index 4a33f6e7..7b5588c6 100644 --- a/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift +++ b/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift @@ -6,15 +6,17 @@ import WidgetKit /// the app writes; see `WhereWidgetProvider` for the data path. @main struct WhereWidgetsBundle: WidgetBundle { + private let buildEnvironment = WhereWidgetBuildEnvironment.current() + var body: some Widget { - TodayWidget() - YearTotalsWidget() + TodayWidget(appGroupIdentifier: buildEnvironment.appGroupIdentifier) + YearTotalsWidget(appGroupIdentifier: buildEnvironment.appGroupIdentifier) } } #if DEBUG #Preview("Where widgets", as: .systemSmall) { - TodayWidget() + TodayWidget(appGroupIdentifier: "group.com.stuff.where.preview") } timeline: { WhereWidgetEntry.sample } diff --git a/Where/WhereWidgets/Sources/YearTotalsWidget.swift b/Where/WhereWidgets/Sources/YearTotalsWidget.swift index 4578ca80..c18a2fa9 100644 --- a/Where/WhereWidgets/Sources/YearTotalsWidget.swift +++ b/Where/WhereWidgets/Sources/YearTotalsWidget.swift @@ -8,9 +8,21 @@ import WidgetKit /// budget. struct YearTotalsWidget: Widget { static let kind = "com.stuff.where.widgets.yearTotals" + let appGroupIdentifier: String + + init() { + appGroupIdentifier = WhereWidgetBuildEnvironment.current().appGroupIdentifier + } + + init(appGroupIdentifier: String) { + self.appGroupIdentifier = appGroupIdentifier + } var body: some WidgetConfiguration { - StaticConfiguration(kind: Self.kind, provider: WhereWidgetProvider()) { entry in + StaticConfiguration( + kind: Self.kind, + provider: WhereWidgetProvider(appGroupIdentifier: appGroupIdentifier), + ) { entry in YearTotalsWidgetContent(entry: entry) // Seed the Broadway context so the shared WhereUI content views // resolve trait-aware `@Environment(\.stylesheet)` tokens instead @@ -69,21 +81,21 @@ private struct YearTotalsWidgetContent: View { #if DEBUG #Preview("Small", as: .systemSmall) { - YearTotalsWidget() + YearTotalsWidget(appGroupIdentifier: "group.com.stuff.where.preview") } timeline: { WhereWidgetEntry.sample WhereWidgetEntry.previewEmpty } #Preview("Medium", as: .systemMedium) { - YearTotalsWidget() + YearTotalsWidget(appGroupIdentifier: "group.com.stuff.where.preview") } timeline: { WhereWidgetEntry.sample WhereWidgetEntry.previewEmpty } #Preview("Rectangular", as: .accessoryRectangular) { - YearTotalsWidget() + YearTotalsWidget(appGroupIdentifier: "group.com.stuff.where.preview") } timeline: { WhereWidgetEntry.sample WhereWidgetEntry.previewEmpty diff --git a/Where/install b/Where/install index 1bb12ee4..debda470 100755 --- a/Where/install +++ b/Where/install @@ -22,10 +22,10 @@ set -euo pipefail cd "$(dirname "$0")/.." WORKSPACE="Stuff.xcworkspace" -SCHEME="Where" -BUNDLE_ID="com.stuff.where" CONFIGURATION="Debug" +SCHEME="" +BUNDLE_ID="" OPTIMIZE=true # force compiler optimizations on regardless of configuration DEVICE="" # name, UDID, or identifier; empty = auto-pick the sole device LAUNCH=true @@ -44,7 +44,7 @@ while running at roughly Release speed — and launches the app. Options: --device NAME Target a specific device by name (exact match), UDID, or identifier (default: the sole paired iPhone) - --configuration NAME Build configuration (default: Debug) + --configuration NAME Debug, Beta, or Release (default: Debug) --optimize Force compiler optimizations on (default) --no-optimize Build without forcing optimizations (use the configuration's own optimization level) @@ -58,6 +58,7 @@ Examples: ./Where/install ./Where/install --cloudkit ./Where/install --device "Kai's iPhone" + ./Where/install --configuration Beta ./Where/install --configuration Release ./Where/install --no-optimize --no-launch USAGE @@ -87,6 +88,27 @@ while [ $# -gt 0 ]; do shift done +# Each audience has an explicit scheme while retaining the familiar underlying +# configuration names used by build products and the build-info stamp. +case "$CONFIGURATION" in + Debug) + SCHEME="Where Development" + BUNDLE_ID="com.stuff.where.development" + ;; + Beta) + SCHEME="Where Beta" + BUNDLE_ID="com.stuff.where" + ;; + Release) + SCHEME="Where App Store" + BUNDLE_ID="com.stuff.where" + ;; + *) + echo "error: unsupported Where configuration '$CONFIGURATION' (expected Debug, Beta, or Release)" >&2 + exit 1 + ;; +esac + # Pre-flight: a device build must be signed by a real team. Read the value mise # injects (TUIST_DEVELOPMENT_TEAM from .mise.local.toml) and fail early with an # actionable hint rather than deep inside xcodebuild's signing phase. @@ -139,7 +161,7 @@ fi # Build + sign for a generic iOS device. -allowProvisioningUpdates lets xcodebuild # create/download the profiles for the app and its extensions (App Groups, # location) instead of requiring them to exist already. -echo "==> xcodebuild ($CONFIGURATION$([ "$OPTIMIZE" = true ] && echo ', optimized')) for device" +echo "==> xcodebuild $SCHEME ($CONFIGURATION$([ "$OPTIMIZE" = true ] && echo ', optimized')) for device" mise exec -- xcodebuild build \ -workspace "$WORKSPACE" \ -scheme "$SCHEME" \ diff --git a/icons b/icons index 451a8f57..f91d74fc 100755 --- a/icons +++ b/icons @@ -4,7 +4,7 @@ set -euo pipefail # icons — add or remove a selectable Where app icon, reproducibly. # # Edits only data + asset folders (never Swift): -# - the app's AppIcon.xcassets (the alternate appiconset iOS swaps to) +# - the app's AppIcon.xcassets (the appiconsets each audience can use) # - WhereUI's AppIconPreviews.xcassets (the imageset the picker renders, since # SwiftUI Image() can't load appiconsets) # - AppIcons.json (the manifest the picker reads) @@ -19,6 +19,7 @@ cd "$(dirname "$0")" APP_CATALOG="Where/Where/Resources/AppIcon.xcassets" PREVIEW_CATALOG="Where/WhereUI/Sources/Resources/AppIconPreviews.xcassets" MANIFEST="Where/WhereUI/Sources/Resources/AppIcons.json" +PROJECT_MANIFEST="Project.swift" usage() { cat <<'USAGE' @@ -39,8 +40,10 @@ WhereUI preview catalog, and AppIcons.json in sync. --dark 1024x1024 PNG for the dark appearance. --tinted 1024x1024 PNG for the tinted (monochrome) appearance. -The primary "Classic" icon (AppIcon / id "classic") is reserved: it can't be -added or removed. +The base "Classic" icon (AppIcon / id "classic") is reserved: it can't be +added or removed. Which appiconset is primary is selected per audience in +Project.swift; the script refuses to remove any configured primary and manages +the assets available to every audience. Examples: ./icons --add art/ocean.png --name Ocean --dark art/ocean-dark.png @@ -113,6 +116,7 @@ fi MODE="$MODE" \ APP_CATALOG="$APP_CATALOG" PREVIEW_CATALOG="$PREVIEW_CATALOG" MANIFEST="$MANIFEST" \ + PROJECT_MANIFEST="$PROJECT_MANIFEST" \ LIGHT="$LIGHT" NAME="$NAME" ID="$ID" DARK="$DARK" TINTED="$TINTED" TARGET="$TARGET" \ python3 - <<'PY' import json @@ -126,6 +130,7 @@ MODE = os.environ["MODE"] APP_CATALOG = os.environ["APP_CATALOG"] PREVIEW_CATALOG = os.environ["PREVIEW_CATALOG"] MANIFEST = os.environ["MANIFEST"] +PROJECT_MANIFEST = os.environ["PROJECT_MANIFEST"] PRIMARY_SET = "AppIcon" PRIMARY_ID = "classic" @@ -139,7 +144,7 @@ def die(msg): def require_repo_layout(): missing = [ path - for path in (MANIFEST, APP_CATALOG, PREVIEW_CATALOG) + for path in (MANIFEST, PROJECT_MANIFEST, APP_CATALOG, PREVIEW_CATALOG) if not os.path.exists(path) ] if missing: @@ -155,6 +160,16 @@ def load_manifest(): die(f"{MANIFEST} is not valid JSON: {error}") +def configured_primary_sets(): + require_repo_layout() + with open(PROJECT_MANIFEST) as f: + source = f.read() + names = set(re.findall(r'primaryAppIconName:\s*"([^"]+)"', source)) + if not names: + die(f"couldn't find any Where audience primary icons in {PROJECT_MANIFEST}") + return names + + def save_manifest(data): out = json.dumps(data, indent=2, ensure_ascii=False, separators=(",", " : ")) with open(MANIFEST, "w") as f: @@ -197,8 +212,10 @@ def do_list(): id_width = max(len(i["id"]) for i in icons) name_width = max(len(i["displayName"]) for i in icons) for icon in icons: - alt = icon.get("alternateIconName") or "(primary)" - print(f" {icon['id']:<{id_width}} {icon['displayName']:<{name_width}} {alt}") + print( + f" {icon['id']:<{id_width}} " + f"{icon['displayName']:<{name_width}} {icon['assetName']}" + ) def do_add(): @@ -215,7 +232,7 @@ def do_add(): set_name = PRIMARY_SET + pascal_case(name) if set_name == PRIMARY_SET or icon_id == PRIMARY_ID: - die(f'"{PRIMARY_ID}" / "{PRIMARY_SET}" is the reserved primary icon') + die(f'"{PRIMARY_ID}" / "{PRIMARY_SET}" is the reserved base icon') require_1024(light) if dark: @@ -228,7 +245,7 @@ def do_add(): for icon in icons: if icon["id"] == icon_id: die(f'an icon with id "{icon_id}" already exists (use --id to pick another)') - if icon.get("alternateIconName") == set_name: + if icon["assetName"] == set_name: die(f'an icon named "{set_name}" already exists') appiconset = os.path.join(APP_CATALOG, set_name + ".appiconset") @@ -274,7 +291,7 @@ def do_add(): icons.append({ "id": icon_id, "displayName": name, - "alternateIconName": set_name, + "assetName": set_name, "previewImageName": set_name, }) save_manifest(data) @@ -285,7 +302,7 @@ def do_add(): def do_remove(): target = os.environ["TARGET"] if target.lower() in (PRIMARY_ID, PRIMARY_SET.lower()): - die('the primary "Classic" icon can\'t be removed') + die('the base "Classic" icon can\'t be removed') data = load_manifest() icons = data.get("icons", []) @@ -297,17 +314,22 @@ def do_remove(): in { icon["id"].lower(), icon["displayName"].lower(), - (icon.get("alternateIconName") or "").lower(), + icon["assetName"].lower(), } ), None, ) if match is None: die(f'no icon matching "{target}" (try ./icons --list)') - if match.get("alternateIconName") is None: - die('the primary "Classic" icon can\'t be removed') - - set_name = match["alternateIconName"] + if match["assetName"] == PRIMARY_SET: + die('the base "Classic" icon can\'t be removed') + + set_name = match["assetName"] + if set_name in configured_primary_sets(): + die( + f'"{set_name}" is configured as a Where audience primary icon; ' + f'change {PROJECT_MANIFEST} before removing it' + ) for path in ( os.path.join(APP_CATALOG, set_name + ".appiconset"), os.path.join(PREVIEW_CATALOG, set_name + ".imageset"), @@ -318,7 +340,7 @@ def do_remove(): data["icons"] = [icon for icon in icons if icon is not match] save_manifest(data) print(f'Removed "{match["displayName"]}" (id: {match["id"]}).') - print("If it was the active icon, the app falls back to Classic on next launch.") + print("If it was active, the app falls back to its configured primary on next launch.") print("Run `./ide --no-open` to regenerate.")