From 16ee041078a9a3d220686fc847fb29c1258f52fc Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 13:51:21 -0700 Subject: [PATCH 1/4] Add audience-specific Where builds Define Development, Beta, and App Store schemes with host-only compiler conditions and injected audience configuration. Isolate development persistence, make primary icons audience-aware, and protect configured primaries in the icon tooling. --- AGENTS.md | 18 +- Project.swift | 177 ++++++++++++++++-- Where/AGENTS.md | 7 +- Where/Where/AGENTS.md | 10 +- Where/Where/README.md | 14 +- Where/Where/Sources/AppDelegate.swift | 13 +- .../Sources/RegularApplicationRuntime.swift | 53 +++++- .../Where/Sources/WhereBuildEnvironment.swift | 93 +++++++++ .../WhereInspectorApplicationRuntime.swift | 9 +- .../Tests/WhereBuildEnvironmentTests.swift | 34 ++++ Where/Where/Tests/WhereTests.swift | 1 + Where/WhereCore/AGENTS.md | 3 + Where/WhereCore/README.md | 8 +- .../Sources/Persistence/SwiftDataStore.swift | 88 ++++----- .../Sources/Widgets/WidgetSnapshotStore.swift | 6 +- .../Widgets/WidgetTimelineRefresher.swift | 7 +- Where/WhereCore/Tests/WhereCoreTests.swift | 22 ++- .../WhereIntents/Sources/IntentServices.swift | 5 +- .../Sources/Logging/WhereIntentsLog.swift | 7 +- .../Sources/TodayRegionsIntent.swift | 18 +- .../Sources/WhereIntentReader.swift | 8 +- .../Tests/IntentServicesTests.swift | 10 +- Where/WhereShareExtension/AGENTS.md | 10 +- Where/WhereShareExtension/README.md | 4 +- .../Sources/ShareEvidenceModel.swift | 2 +- .../Sources/ShareViewController.swift | 8 +- .../Sources/WhereShareBuildEnvironment.swift | 50 +++++ Where/WhereUI/AGENTS.md | 3 + Where/WhereUI/README.md | 4 + .../Sources/Launch/LaunchSplashView.swift | 5 +- .../WhereUI/Sources/Launch/WhereLaunch.swift | 21 ++- Where/WhereUI/Sources/Resources/AppIcons.json | 6 +- Where/WhereUI/Sources/RootView.swift | 20 +- .../Sources/Settings/AppIconModel.swift | 21 ++- .../Sources/Settings/AppIconOption.swift | 31 +-- .../Sources/Settings/AppIconView.swift | 10 +- .../Settings/AppearanceSettingsView.swift | 3 +- .../Settings/PrimaryAppIconEnvironment.swift | 5 + .../Shared/AppIconActivityIndicator.swift | 24 ++- .../Sources/Shared/AppIconLoadingView.swift | 4 +- Where/WhereUI/Tests/AppIconModelTests.swift | 95 ++++++++-- Where/WhereWidgets/AGENTS.md | 7 +- Where/WhereWidgets/README.md | 6 +- Where/WhereWidgets/Sources/TodayWidget.swift | 20 +- .../Sources/WhereWidgetBuildEnvironment.swift | 50 +++++ .../Sources/WhereWidgetProvider.swift | 5 +- .../Sources/WhereWidgetsBundle.swift | 8 +- .../Sources/YearTotalsWidget.swift | 20 +- Where/install | 30 ++- icons | 54 ++++-- 50 files changed, 923 insertions(+), 214 deletions(-) create mode 100644 Where/Where/Sources/WhereBuildEnvironment.swift create mode 100644 Where/Where/Tests/WhereBuildEnvironmentTests.swift create mode 100644 Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift create mode 100644 Where/WhereUI/Sources/Settings/PrimaryAppIconEnvironment.swift create mode 100644 Where/WhereWidgets/Sources/WhereWidgetBuildEnvironment.swift diff --git a/AGENTS.md b/AGENTS.md index cadc70491..5621637b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,23 @@ 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 or add icon Swift. -Run `./ide --no-open` after adding one. +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 diff --git a/Project.swift b/Project.swift index be89a47c4..2d35f8ee8 100644 --- a/Project.swift +++ b/Project.swift @@ -15,6 +15,114 @@ private let stuffPackage = Package.local(path: .relativeToRoot(".")) /// 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 @@ -32,14 +140,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 environment the LFS reference images were recorded on, and the single @@ -148,6 +260,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( @@ -164,6 +308,7 @@ let project = Project( bundleId: "com.stuff.where", deploymentTargets: deployment, infoPlist: .extendingDefault(with: [ + "CFBundleDisplayName": .string("$(WHERE_DISPLAY_NAME)"), "UILaunchScreen": .dictionary([:]), "UIApplicationSupportsIndirectInputEvents": .boolean(true), // Stated explicitly rather than left to Tuist's `1.0` / `1` @@ -171,6 +316,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.", ), @@ -205,10 +353,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": "", ]), @@ -220,7 +369,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"), ]), @@ -234,6 +385,7 @@ let project = Project( .package(product: "WhereCore"), .package(product: "WhereUI"), ], + settings: whereHostSettings(.widget), ), .target( name: "WhereShareExtension", @@ -242,7 +394,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( @@ -269,6 +423,7 @@ let project = Project( .package(product: "WhereCore"), .package(product: "WhereUI"), ], + settings: whereHostSettings(.share), ), .target( name: "RegionViewer", @@ -605,7 +760,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/Where/AGENTS.md b/Where/AGENTS.md index 97106ed75..047898ee0 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -277,8 +277,11 @@ loaded, and distinct edge states, not just the happy path. `./Where/install` builds, signs, and installs the app onto a connected iPhone from the CLI — macOS-only, one-time `./ide --team-id ` setup. It defaults -to Debug with compiler optimizations forced on, so DEBUG-only developer -surfaces survive at near-Release speed. Options: `./Where/install --help`. +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 06f4ce41d..3b6a13745 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 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`), and 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), which 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`. - **Regular launch is wired in `didFinishLaunching`, not a SwiftUI `.task`.** When diff --git a/Where/Where/README.md b/Where/Where/README.md index 3ab663bcd..4088d963d 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -20,6 +20,7 @@ target, see [`AGENTS.md`](AGENTS.md). |------|------| | `Sources/WhereApp.swift` | `@main` `App`. One `WindowGroup` rendering the selected runtime's type-erased root. | | `Sources/AppDelegate.swift` | The boot router. Selects one `WhereApplicationRuntime` in its initializer 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/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. | | `Sources/WhereApplicationRuntime.swift` | The class-bound launch/root-view protocol shared by both runtimes. | @@ -61,7 +62,12 @@ relaunch; 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)). diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index eb7009e4c..0afbdf077 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -10,6 +10,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { let runtime: any WhereApplicationRuntime override init() { + let buildEnvironment = WhereBuildEnvironment.current() #if DEBUG guard let applicationIdentifier = Bundle.main.bundleIdentifier else { preconditionFailure("Where has no bundle identifier") @@ -21,14 +22,20 @@ final class AppDelegate: NSObject, UIApplicationDelegate { modeController: modeController, fileManager: .default, regular: { - RegularApplicationRuntime(inspectorModeController: modeController) + RegularApplicationRuntime( + buildEnvironment: buildEnvironment, + inspectorModeController: modeController, + ) }, inspector: { - WhereInspectorApplicationRuntime(modeController: modeController) + WhereInspectorApplicationRuntime( + buildEnvironment: buildEnvironment, + modeController: modeController, + ) }, ) #else - runtime = RegularApplicationRuntime() + runtime = RegularApplicationRuntime(buildEnvironment: buildEnvironment) #endif super.init() } diff --git a/Where/Where/Sources/RegularApplicationRuntime.swift b/Where/Where/Sources/RegularApplicationRuntime.swift index c6812a562..2ba138c6a 100644 --- a/Where/Where/Sources/RegularApplicationRuntime.swift +++ b/Where/Where/Sources/RegularApplicationRuntime.swift @@ -14,23 +14,51 @@ import WhereUI /// runner that make up the shipping application. @MainActor final class RegularApplicationRuntime: WhereApplicationRuntime { - let model = WhereModel( - preferences: WherePreferences(store: UserDefaults.standard), - makeBootstrap: { WhereBootstrap() }, - logSystem: .shared, - ) - - let intentServices = IntentServices() + let model: WhereModel + let intentServices: IntentServices + private let buildEnvironment: WhereBuildEnvironment private(set) var launcher: LifecycleRunner! #if DEBUG private let inspectorModeController: InspectorModeController? - init(inspectorModeController: InspectorModeController? = nil) { + init( + buildEnvironment: WhereBuildEnvironment, + inspectorModeController: InspectorModeController? = nil, + ) { + self.buildEnvironment = buildEnvironment self.inspectorModeController = inspectorModeController + intentServices = IntentServices( + appGroupIdentifier: buildEnvironment.appGroupIdentifier, + ) + model = WhereModel( + preferences: WherePreferences(store: UserDefaults.standard), + makeBootstrap: { + WhereBootstrap( + storage: buildEnvironment.storage, + widgetRefresher: buildEnvironment.makeWidgetRefresher(), + ) + }, + logSystem: .shared, + ) } #else - init() {} + init(buildEnvironment: WhereBuildEnvironment) { + self.buildEnvironment = buildEnvironment + intentServices = IntentServices( + appGroupIdentifier: buildEnvironment.appGroupIdentifier, + ) + model = WhereModel( + preferences: WherePreferences(store: UserDefaults.standard), + makeBootstrap: { + WhereBootstrap( + storage: buildEnvironment.storage, + widgetRefresher: buildEnvironment.makeWidgetRefresher(), + ) + }, + logSystem: .shared, + ) + } #endif func didFinishLaunching( @@ -60,10 +88,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 000000000..fab62d280 --- /dev/null +++ b/Where/Where/Sources/WhereBuildEnvironment.swift @@ -0,0 +1,93 @@ +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 { + if isRunningTests { + return .inMemory + } + switch audience { + case .development: + return .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 ef35973d2..9cf7713e3 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 000000000..99af9809b --- /dev/null +++ b/Where/Where/Tests/WhereBuildEnvironmentTests.swift @@ -0,0 +1,34 @@ +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) + } +} diff --git a/Where/Where/Tests/WhereTests.swift b/Where/Where/Tests/WhereTests.swift index 944e1e985..af88ad74c 100644 --- a/Where/Where/Tests/WhereTests.swift +++ b/Where/Where/Tests/WhereTests.swift @@ -205,6 +205,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 0357a0d3e..8b43c5ddf 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -37,6 +37,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 a49033d66..f105cfd14 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -25,8 +25,10 @@ one it belongs to rather than to a god-object: crossing it is a SwiftData record). Mutations run inside `perform { … }` (one atomic transaction) and `changes()` emits once per commit and on a CloudKit remote import for the Where store URL, excluding other process stores such as - Periscope. `SwiftDataStore.make()` is the production, CloudKit-backed - implementation; `SwiftDataStore.inMemory()` backs tests and previews. Each + Periscope. `SwiftDataStore.make(storage:)` opens either an explicitly named + local-only or CloudKit-backed App Group; `.inMemory` backs tests and previews. + The host chooses that policy and group, so WhereCore contains no audience + default. 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 @@ -154,7 +156,7 @@ 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(), // production; use .inMemory() in tests + store: try SwiftDataStore.make(storage: storage), locationSource: CoreLocationSource(), ) diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 90939052e..441bd9c1f 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -62,43 +62,20 @@ import SwiftData /// one-shot capture) queue instead of clobbering each other. @ModelActor public actor SwiftDataStore: WhereStore, EvidenceBlobStore { - /// Backing storage for a `SwiftDataStore`. CloudKit mode is the - /// production default; the other two are for tests and local - /// development. - public enum Storage: Sendable { - /// In-memory only. No disk, no CloudKit. Test/preview default. + /// 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. Production default. - case cloudKit - - /// Build- and test-aware default suitable for app-level wiring. - /// - /// - When tests are running (detected via the - /// `XCTestConfigurationFilePath` env var, which both XCTest - /// and Swift Testing under `xcodebuild` / `swift test` set), - /// returns `.inMemory` so tests can't accidentally write - /// into the user's local on-disk store. - /// - In debug app builds, returns `.localOnly` so iteration is - /// fast and CloudKit doesn't sync experimental records. - /// - In release builds, returns `.cloudKit` for production - /// sync. - /// - /// Tests that want a specific mode (or that construct stores - /// outside `WhereServices`) should still pass `.inMemory` - /// explicitly via `SwiftDataStore.inMemory()`. - public static var `default`: Storage { - if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { - return .inMemory - } - #if DEBUG - return .localOnly - #else - return .cloudKit - #endif - } + /// CloudKit database. + 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 @@ -111,13 +88,22 @@ 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 + } + } + + fileprivate 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 @@ -149,7 +135,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 @@ -160,7 +147,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { schema: schema, isStoredInMemoryOnly: storage == .inMemory, groupContainer: groupContainer, - cloudKitDatabase: storage == .cloudKit ? .automatic : .none, + cloudKitDatabase: storage.usesCloudKit ? .automatic : .none, ) } @@ -174,9 +161,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { return SwiftDataStore(modelContainer: container) } - /// App-wiring factory: builds a store for the given storage mode - /// (defaulting to the build/test-aware `Storage.default`) and wraps - /// it in a `SwiftDataStore`. The `@ModelActor`-generated + /// App-wiring factory: builds a store for the explicitly selected storage + /// mode and wraps it in a `SwiftDataStore`. The `@ModelActor`-generated /// `init(modelContainer:)` is not reachable from other modules, so /// this is the supported entry point for opening a store. /// @@ -188,7 +174,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// caller opening another container over the same file (two containers /// racing to *create* the store on a fresh install is how the launch /// once failed with `SwiftDataError`). - public static func make(storage: Storage = .default) throws -> SwiftDataStore { + public static func make(storage: Storage) throws -> SwiftDataStore { let container = try logger.measure(.open) { try makeContainer(storage: storage) } if storage == .inMemory { logger { .openedInMemory(mode: String(describing: storage)) } @@ -199,8 +185,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( @@ -235,7 +225,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/WidgetSnapshotStore.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift index 5c27d4bc5..e05901d33 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 6e8542212..6afd8ec8a 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 ef29025c5..9a174fd8d 100644 --- a/Where/WhereCore/Tests/WhereCoreTests.swift +++ b/Where/WhereCore/Tests/WhereCoreTests.swift @@ -23,16 +23,7 @@ struct YearReportTests { } } -struct StorageDefaultTests { - @Test func storageDefault_isInMemoryUnderTestRunner() { - // We're running under either XCTest or Swift Testing via - // `tuist test` / `xcodebuild test` / `swift test`, all of - // which set `XCTestConfigurationFilePath`. If this assertion - // ever fails, `Storage.default` would let a real test build - // write to the user's local SwiftData store — bad. - #expect(SwiftDataStore.Storage.default == .inMemory) - } - +struct StorageConfigurationTests { @Test func make_inMemory_roundTripsASample() async throws { let store = try SwiftDataStore.make(storage: .inMemory) let sample = LocationSample( @@ -46,6 +37,17 @@ struct StorageDefaultTests { 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/Sources/IntentServices.swift b/Where/WhereIntents/Sources/IntentServices.swift index 476cba7f7..f4ee7a2f2 100644 --- a/Where/WhereIntents/Sources/IntentServices.swift +++ b/Where/WhereIntents/Sources/IntentServices.swift @@ -26,6 +26,7 @@ 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 private var installed: WhereServices? /// Intents parked in `current()` awaiting installation, keyed so a @@ -36,7 +37,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 d1aab52ff..f4abc2d69 100644 --- a/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift +++ b/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift @@ -68,6 +68,9 @@ enum WhereIntentsLog: LogEvent { /// The recent-activity summary couldn't be produced (e.g. Apple /// Intelligence is off or the model is warming). case recentActivityUnavailable(reason: 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" @@ -75,7 +78,7 @@ enum WhereIntentsLog: LogEvent { switch self { case .spotlightIndexed: .info - case .spotlightIndexFailed, .recentActivityUnavailable: + case .spotlightIndexFailed, .recentActivityUnavailable, .widgetSnapshotReadFailed: .warning } } @@ -88,6 +91,8 @@ enum WhereIntentsLog: LogEvent { "Failed to index regions for Spotlight: \(description)" case let .recentActivityUnavailable(reason): "Recent-activity summary unavailable: \(reason)" + 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 8b145df7a..bb76ecb29 100644 --- a/Where/WhereIntents/Sources/TodayRegionsIntent.swift +++ b/Where/WhereIntents/Sources/TodayRegionsIntent.swift @@ -24,7 +24,23 @@ public struct TodayRegionsIntent: AppIntent { public func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView { let services = try await intentServices.current() 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 ed7f75a19..6ee71d53d 100644 --- a/Where/WhereIntents/Sources/WhereIntentReader.swift +++ b/Where/WhereIntents/Sources/WhereIntentReader.swift @@ -12,11 +12,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 64b02d775..c3c2fe1ff 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) @@ -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) diff --git a/Where/WhereShareExtension/AGENTS.md b/Where/WhereShareExtension/AGENTS.md index c4d68bf4b..1ee903014 100644 --- a/Where/WhereShareExtension/AGENTS.md +++ b/Where/WhereShareExtension/AGENTS.md @@ -9,10 +9,9 @@ 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**, - and **PeriscopeCore**. Embedded by the **Where** app; shares the - `group.com.stuff.where` App Group entitlement. Logs via the `WhereLog` facade +- **Tuist app-extension target** ([`Project.swift`](../../Project.swift), with + an audience-specific bundle ID and App Group), depending on **WhereCore**, + **WhereUI**, and **PeriscopeCore**. 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.symbolName`/`displayName`; @@ -30,7 +29,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature 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. + 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)`. diff --git a/Where/WhereShareExtension/README.md b/Where/WhereShareExtension/README.md index ebeaf6171..5aefa190b 100644 --- a/Where/WhereShareExtension/README.md +++ b/Where/WhereShareExtension/README.md @@ -54,7 +54,9 @@ 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`), +[`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 and shares the `group.com.stuff.where` App Group entitlement so both processes open the same SwiftData store. diff --git a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift index 67c9657b2..3201f0184 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 77db519e0..5a056f6a8 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 000000000..431e85d56 --- /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 1dec361ed..48b534ae6 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -16,6 +16,9 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) - Composition is the one exception: `WhereScope` and `WhereModel` decide which world the app is logged in to and assemble it. That's launch wiring, not domain logic — see [Scopes and the launch](../AGENTS.md#scopes-and-the-launch). +- 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. - The DEBUG developer accordion may only latch or clear `InspectorModeController` for the next launch. It must not host a live SwiftData inspector or switch the current runtime. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index d9ccdb363..e4ec40d44 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -40,6 +40,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 how long it should take (`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 e3e934a78..70f725953 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 66184d3ee..df257210c 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -236,9 +236,17 @@ public protocol WhereScopeAssembling { public final class WhereBootstrap: WhereScopeAssembling { private static let logger = WhereLog.root(WhereLaunchLog.self) + private let storage: SwiftDataStore.Storage + private let widgetRefresher: any WidgetTimelineRefreshing private var locationSource: CoreLocationSource? - public init() {} + public init( + storage: SwiftDataStore.Storage, + widgetRefresher: any WidgetTimelineRefreshing, + ) { + self.storage = storage + self.widgetRefresher = widgetRefresher + } /// Install the `CLLocationManager` + delegate right away, without touching /// the store. Idempotent. @@ -267,8 +275,8 @@ public final class WhereBootstrap: WhereScopeAssembling { let source = locationSource ?? CoreLocationSource() locationSource = nil do { - let store = try await Task.detached(priority: .userInitiated) { - try SwiftDataStore.make() + let store = try await Task.detached(priority: .userInitiated) { [storage] in + try SwiftDataStore.make(storage: storage) }.value let services = try await WhereServices.make( store: store, @@ -280,7 +288,7 @@ public final class WhereBootstrap: WhereScopeAssembling { reminderScheduler: UserNotificationReminderScheduler(), summaryScheduler: UserNotificationDailySummaryScheduler(), issueAlertScheduler: UserNotificationDataIssueAlertScheduler(), - widgetRefresher: WidgetCenterTimelineRefresher(), + widgetRefresher: widgetRefresher, locationOutbox: FileLocationOutbox.applicationSupport(), ) Self.logger { .servicesAssembled } @@ -306,9 +314,8 @@ public final class WhereBootstrap: WhereScopeAssembling { ) } - /// Where a real scope's log store belongs, mirroring - /// `SwiftDataStore.Storage.default`'s test-runner guard: under a test host - /// it must stay in memory. A suite that logs in would otherwise write its + /// Where a real scope's log store belongs. Under a test host it must stay + /// in memory. A suite that logs in would otherwise write its /// records into the user's `Periscope.store`, and opening that from a test /// host's sandbox neither succeeds nor fails promptly — it stalls the /// bundle instead of failing it. diff --git a/Where/WhereUI/Sources/Resources/AppIcons.json b/Where/WhereUI/Sources/Resources/AppIcons.json index 729b2b5ae..83536a640 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 20677e3b6..a30550e52 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -51,6 +51,7 @@ public struct RootView: View { ) #endif private let launcher: LifecycleRunner + private let primaryAppIconName: String #if DEBUG private let inspectorModeController: InspectorModeController? #endif @@ -60,19 +61,23 @@ 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 } #else public init( model: WhereModel, launcher: LifecycleRunner, + primaryAppIconName: String, ) { _model = State(initialValue: model) self.launcher = launcher + self.primaryAppIconName = primaryAppIconName } #endif @@ -85,11 +90,17 @@ public struct RootView: View { // or the hosted UI test never gets to. let model = WhereModel( preferences: WherePreferences(store: UserDefaults.standard), - makeBootstrap: { WhereBootstrap() }, + makeBootstrap: { + WhereBootstrap( + storage: .inMemory, + widgetRefresher: NoopWidgetTimelineRefresher(), + ) + }, logSystem: .shared, ) _model = State(initialValue: model) launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) + primaryAppIconName = "AppIcon" #if DEBUG inspectorModeController = nil #endif @@ -166,6 +177,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) @@ -287,7 +299,11 @@ public struct RootView: View { settle: .settledAtLeast(minDuration: 1.5), onReadyToSnapshot: { await launcher.run() }, ) { - RootView(model: model, launcher: launcher) + RootView( + model: model, + launcher: launcher, + primaryAppIconName: "AppIcon", + ) } } } diff --git a/Where/WhereUI/Sources/Settings/AppIconModel.swift b/Where/WhereUI/Sources/Settings/AppIconModel.swift index 80a410489..7ad4db9c8 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 ccd37fd8e..c8c27b2e6 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,22 +98,29 @@ 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 } /// The preview-catalog image name of the currently selected icon, resolved /// from the live `UIApplication.shared.alternateIconName` against the - /// manifest and falling back to the bundled "Classic" art. Shared by every + /// manifest and falling back to the base preview art if packaging is broken. + /// Shared by every /// in-app surface that renders the selected icon (launch splash, the /// recent-activity loading indicator) 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 fa1aed08f..6bde4041d 100644 --- a/Where/WhereUI/Sources/Settings/AppIconView.swift +++ b/Where/WhereUI/Sources/Settings/AppIconView.swift @@ -18,8 +18,10 @@ struct AppIconView: View { @Environment(\.stylesheet) private var stylesheet @MainActor - init(model: AppIconModel = AppIconModel()) { - _model = State(initialValue: model) + init(primaryAppIconName: String, model: AppIconModel? = nil) { + _model = State(initialValue: model ?? AppIconModel( + primaryAppIconName: primaryAppIconName, + )) } private var appIcon: WhereStylesheet.AppIconStyle { @@ -310,7 +312,9 @@ struct AppIconImage: View { extension AppIconView: SnapshotProviding { static var snapshots: [SnapshotCase] { whereSnapshot(name: "Default", configurations: .screenDefaults, settle: .immediate) { - NavigationStack { AppIconView(model: .preview()) } + NavigationStack { + AppIconView(primaryAppIconName: "AppIcon", model: .preview()) + } } } } diff --git a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift index c5740dcf5..61d1dbcf3 100644 --- a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift @@ -4,6 +4,7 @@ import WhereCore /// Settings drill-in for presentation choices: which alternate app icon is used /// (the icon picker pushes on from here). struct AppearanceSettingsView: View { + @Environment(\.primaryAppIconName) private var primaryAppIconName var focus: SettingsFocus? @State private var showAppIcon = false @@ -52,7 +53,7 @@ struct AppearanceSettingsView: View { .navigationTitle(String(localized: .settingsAppearanceGroup)) .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $showAppIcon) { - AppIconView() + AppIconView(primaryAppIconName: primaryAppIconName) } } } diff --git a/Where/WhereUI/Sources/Settings/PrimaryAppIconEnvironment.swift b/Where/WhereUI/Sources/Settings/PrimaryAppIconEnvironment.swift new file mode 100644 index 000000000..e8675db74 --- /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/Shared/AppIconActivityIndicator.swift b/Where/WhereUI/Sources/Shared/AppIconActivityIndicator.swift index 4833333a4..62eadffd1 100644 --- a/Where/WhereUI/Sources/Shared/AppIconActivityIndicator.swift +++ b/Where/WhereUI/Sources/Shared/AppIconActivityIndicator.swift @@ -23,9 +23,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 { @@ -54,12 +60,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 af957ee7a..a676c99a0 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 fdcc3bd44..905c9dc70 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 24797523c..f0416247c 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. @@ -28,6 +28,9 @@ 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`. +- **Resolve the host App Group from `WhereWidgetBuildEnvironment`.** Inject it + into providers and `WidgetSnapshotStore`; 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 — intentional. - In-widget strings come from WhereUI (shared views + `WhereFormat`); the diff --git a/Where/WhereWidgets/README.md b/Where/WhereWidgets/README.md index 8b82b4f1d..20cd54118 100644 --- a/Where/WhereWidgets/README.md +++ b/Where/WhereWidgets/README.md @@ -5,7 +5,7 @@ today's region presence and year-to-date day counts per region. Widgets never open the SwiftData store. The app publishes a single 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). All rendering lives in [`WhereUI`](../WhereUI/) — this target only wires WidgetKit configuration, the timeline provider, and family-specific layout. @@ -42,7 +42,9 @@ app never wakes. ## Installation `WhereWidgets` is a Tuist app-extension target in -[`Project.swift`](../../Project.swift) (bundle ID `com.stuff.where.widgets`). +[`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. diff --git a/Where/WhereWidgets/Sources/TodayWidget.swift b/Where/WhereWidgets/Sources/TodayWidget.swift index 33a13478c..62c838e15 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 @@ -60,7 +72,7 @@ private struct TodayWidgetContent: View { #if DEBUG #Preview("Small", as: .systemSmall) { - TodayWidget() + TodayWidget(appGroupIdentifier: "group.com.stuff.where.preview") } timeline: { WhereWidgetEntry.sample WhereWidgetEntry.previewMultiRegion @@ -68,14 +80,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 000000000..e2c957a4c --- /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 3cc6022f9..b8df6cbec 100644 --- a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift +++ b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift @@ -16,6 +16,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 @@ -48,7 +49,9 @@ struct WhereWidgetProvider: TimelineProvider { private func loadEntry() -> WhereWidgetEntry { let now = Date() do { - let store = try WidgetSnapshotStore.shared() + let store = try WidgetSnapshotStore.shared( + appGroupIdentifier: appGroupIdentifier, + ) if let snapshot = store.read() { return WhereWidgetEntry(date: now, snapshot: snapshot) } diff --git a/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift b/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift index 4a33f6e7f..7b5588c64 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 98f180276..e964a8525 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 @@ -68,21 +80,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 67bd84e81..6b7b14514 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 @@ -43,7 +43,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) @@ -54,6 +54,7 @@ Options: Examples: ./Where/install ./Where/install --device "Kai's iPhone" + ./Where/install --configuration Beta ./Where/install --configuration Release ./Where/install --no-optimize --no-launch USAGE @@ -82,6 +83,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. @@ -126,7 +148,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 451a8f57f..f91d74fc3 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.") From 028861180b205aefe046e6afdfcfa1cdca699ddd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 16 Aug 2026 13:53:04 -0700 Subject: [PATCH 2/4] Validate Where release audiences in CI --- .circleci/config.yml | 92 ++++++++++++++++++++++++++++++++++++++------ AGENTS.md | 5 ++- README.md | 3 ++ 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2f6e0a190..30f69f627 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)" @@ -84,6 +84,22 @@ commands: 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 +109,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 +149,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 +175,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 +195,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 +336,39 @@ 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: >- + mise exec -- tuist xcodebuild build + -workspace Stuff.xcworkspace + -scheme "$WHERE_AUDIENCE_SCHEME" + -destination 'generic/platform=iOS Simulator' + - collect_diagnostics: + test_workdir: test-output-audience-build + artifact_name: where-audience-build + snapshot: executor: m4-pro-medium parallelism: 4 @@ -379,6 +441,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 74358ccfd..85133b093 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,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. @@ -645,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/README.md b/README.md index 3ad988ec4..330a802c2 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. From e68516574e029278afc0c36bb4662459472afef9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 16 Aug 2026 16:02:34 -0700 Subject: [PATCH 3/4] Fix Where audience CI builds --- .circleci/config.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 30f69f627..93c550e81 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,9 +76,6 @@ 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 @@ -361,10 +358,12 @@ jobs: 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' + -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 From ef2deba24c4b804f5761f4605a0441712bd6fe0c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 16 Aug 2026 16:02:39 -0700 Subject: [PATCH 4/4] Stabilize About accessibility snapshots --- Where/WhereUI/Sources/Settings/AboutSettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift index b2b163cf4..75803ad19 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(