diff --git a/AGENTS.md b/AGENTS.md index cadc7049..5621637b 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 be89a47c..2d35f8ee 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 97106ed7..047898ee 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 06f4ce41..3b6a1374 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 3ab663bc..4088d963 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 eb7009e4..0afbdf07 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 c6812a56..2ba138c6 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 00000000..fab62d28 --- /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 ef35973d..9cf7713e 100644 --- a/Where/Where/Sources/WhereInspectorApplicationRuntime.swift +++ b/Where/Where/Sources/WhereInspectorApplicationRuntime.swift @@ -15,6 +15,7 @@ private let modeController: InspectorModeController init( + buildEnvironment: WhereBuildEnvironment, modeController: InspectorModeController, fileManager: FileManager = .default, userDefaults: UserDefaults = .standard, @@ -24,7 +25,7 @@ preconditionFailure("Where has no bundle identifier") } guard let groupURL = fileManager.containerURL( - forSecurityApplicationGroupIdentifier: SwiftDataStore.appGroupIdentifier, + forSecurityApplicationGroupIdentifier: buildEnvironment.appGroupIdentifier, ) else { preconditionFailure("Where's App Group container is unavailable") } @@ -34,6 +35,7 @@ fileManager: fileManager, userDefaults: userDefaults, bundleIdentifier: bundleIdentifier, + appGroupIdentifier: buildEnvironment.appGroupIdentifier, groupURL: groupURL, whereStoreURL: whereStoreURL, periscopeStoreURL: PeriscopeStore.inspectorStoreURL, @@ -46,6 +48,7 @@ fileManager: FileManager, userDefaults: UserDefaults, bundleIdentifier: String, + appGroupIdentifier: String, groupURL: URL, whereStoreURL: URL, periscopeStoreURL: URL, @@ -89,7 +92,9 @@ storeURL: whereStoreURL, modelTypes: SwiftDataStore.inspectorModelTypes, makeContainer: { - try SwiftDataStore.makeContainer(storage: .localOnly) + try SwiftDataStore.makeContainer(storage: .localOnly( + appGroupIdentifier: appGroupIdentifier, + )) }, ), .init( diff --git a/Where/Where/Tests/WhereBuildEnvironmentTests.swift b/Where/Where/Tests/WhereBuildEnvironmentTests.swift new file mode 100644 index 00000000..99af9809 --- /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 944e1e98..af88ad74 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 0357a0d3..8b43c5dd 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 a49033d6..f105cfd1 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 90939052..441bd9c1 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 5c27d4bc..e05901d3 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift @@ -20,10 +20,6 @@ public struct WidgetSnapshotStore: Sendable { public init() {} } - /// The single App Group identifier every Where process shares (app, widget - /// extension, share extension). Sourced from `SwiftDataStore` so there's one - /// canonical value rather than a per-store literal that could drift. - private static let appGroupIdentifier = SwiftDataStore.appGroupIdentifier private static let fileName = "widget-snapshot.json" /// Directory the snapshot file lives in. Exposed via `init` so tests can @@ -37,7 +33,7 @@ public struct WidgetSnapshotStore: Sendable { /// App Group-backed store shared by the app and widget. Throws /// `AppGroupUnavailableError` when the container can't be resolved. - public static func shared() throws -> WidgetSnapshotStore { + public static func shared(appGroupIdentifier: String) throws -> WidgetSnapshotStore { guard let container = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupIdentifier, ) else { diff --git a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift index 6e854221..6afd8ec8 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift @@ -27,12 +27,15 @@ public struct NoopWidgetTimelineRefresher: WidgetTimelineRefreshing { /// same snapshot, so any committed change can affect all of them. public struct WidgetCenterTimelineRefresher: WidgetTimelineRefreshing { private static let logger = WhereLog.widgets(WidgetTimelineRefresherLog.self) + private let appGroupIdentifier: String - public init() {} + public init(appGroupIdentifier: String) { + self.appGroupIdentifier = appGroupIdentifier + } public func publish(_ snapshot: WidgetSnapshot) async { do { - try WidgetSnapshotStore.shared().write(snapshot) + try WidgetSnapshotStore.shared(appGroupIdentifier: appGroupIdentifier).write(snapshot) Self.logger { .wroteSnapshot } } catch { Self.logger { .publishFailed(description: error.localizedDescription) } diff --git a/Where/WhereCore/Tests/WhereCoreTests.swift b/Where/WhereCore/Tests/WhereCoreTests.swift index ef29025c..9a174fd8 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 476cba7f..f4ee7a2f 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 d1aab52f..f4abc2d6 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 8b145df7..bb76ecb2 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 ed7f75a1..6ee71d53 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 64b02d77..c3c2fe1f 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 c4d68bf4..1ee90301 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 ebeaf617..5aefa190 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 67c9657b..3201f018 100644 --- a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift +++ b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift @@ -46,7 +46,7 @@ final class ShareEvidenceModel { init( items: [NSExtensionItem], - storage: SwiftDataStore.Storage = .localOnly, + storage: SwiftDataStore.Storage, now: @Sendable () -> Date = { Date() }, ) { self.items = items diff --git a/Where/WhereShareExtension/Sources/ShareViewController.swift b/Where/WhereShareExtension/Sources/ShareViewController.swift index 77db519e..5a056f6a 100644 --- a/Where/WhereShareExtension/Sources/ShareViewController.swift +++ b/Where/WhereShareExtension/Sources/ShareViewController.swift @@ -19,7 +19,13 @@ final class ShareViewController: UIViewController { let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? [] Self.logger { .opened(itemCount: items.count) } - let model = ShareEvidenceModel(items: items) + let buildEnvironment = WhereShareBuildEnvironment.current() + let model = ShareEvidenceModel( + items: items, + storage: .localOnly( + appGroupIdentifier: buildEnvironment.appGroupIdentifier, + ), + ) let root = ShareEvidenceView( model: model, onSave: { [weak self] in self?.complete() }, diff --git a/Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift b/Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift new file mode 100644 index 00000000..431e85d5 --- /dev/null +++ b/Where/WhereShareExtension/Sources/WhereShareBuildEnvironment.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Audience values resolved by the share host and injected into WhereCore. +struct WhereShareBuildEnvironment { + let appGroupIdentifier: String + + static func current( + infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:], + ) -> WhereShareBuildEnvironment { + let stampedAudience = requireString("WhereAudience", in: infoDictionary) + precondition( + stampedAudience == compiledAudience, + "WhereAudience does not match the share target's compiler condition", + ) + return WhereShareBuildEnvironment(appGroupIdentifier: requireString( + "WhereAppGroupIdentifier", + in: infoDictionary, + )) + } + + private static func requireString( + _ key: String, + in infoDictionary: [String: Any], + ) -> String { + guard let value = infoDictionary[key] as? String, !value.isEmpty else { + preconditionFailure("WhereShareExtension's Info.plist has no non-empty \(key)") + } + return value + } + + #if WHERE_DEVELOPMENT && WHERE_BETA + #error("Exactly one Where audience compiler condition must be active") + #endif + #if WHERE_DEVELOPMENT && WHERE_APP_STORE + #error("Exactly one Where audience compiler condition must be active") + #endif + #if WHERE_BETA && WHERE_APP_STORE + #error("Exactly one Where audience compiler condition must be active") + #endif + + #if WHERE_DEVELOPMENT + private static let compiledAudience = "development" + #elseif WHERE_BETA + private static let compiledAudience = "beta" + #elseif WHERE_APP_STORE + private static let compiledAudience = "appStore" + #else + #error("A Where audience compiler condition must be active") + #endif +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 1dec361e..48b534ae 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 d9ccdb36..e4ec40d4 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 e3e934a7..70f72595 100644 --- a/Where/WhereUI/Sources/Launch/LaunchSplashView.swift +++ b/Where/WhereUI/Sources/Launch/LaunchSplashView.swift @@ -45,6 +45,7 @@ struct LaunchSplashView: View { @Environment(\.isCapturingSnapshot) private var isCapturingSnapshot @MotionIsStatic private var motionIsStatic @Environment(\.stylesheet) private var stylesheet + @Environment(\.primaryAppIconName) private var primaryAppIconName @State private var pulsing = false @State private var showCaption: Bool @@ -106,7 +107,9 @@ struct LaunchSplashView: View { } var body: some View { - let imageName = injectedPreviewImageName ?? AppIconCatalog.liveSelectedPreviewImageName() + let imageName = injectedPreviewImageName ?? AppIconCatalog.liveSelectedPreviewImageName( + primaryAppIconName: primaryAppIconName, + ) ZStack { background RadarPingBackground(tint: splash.iconGlow) diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index 66184d3e..df257210 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 729b2b5a..83536a64 100644 --- a/Where/WhereUI/Sources/Resources/AppIcons.json +++ b/Where/WhereUI/Sources/Resources/AppIcons.json @@ -3,19 +3,19 @@ { "id" : "classic", "displayName" : "Classic", - "alternateIconName" : null, + "assetName" : "AppIcon", "previewImageName" : "AppIconClassic" }, { "id" : "pride", "displayName" : "Pride", - "alternateIconName" : "AppIconPride", + "assetName" : "AppIconPride", "previewImageName" : "AppIconPride" }, { "id" : "solid", "displayName" : "Solid", - "alternateIconName" : "AppIconSolid", + "assetName" : "AppIconSolid", "previewImageName" : "AppIconSolid" } ] diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 20677e3b..a30550e5 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 80a41048..7ad4db9c 100644 --- a/Where/WhereUI/Sources/Settings/AppIconModel.swift +++ b/Where/WhereUI/Sources/Settings/AppIconModel.swift @@ -19,15 +19,22 @@ final class AppIconModel { var applyError: String? private let setter: any AlternateIconSetting + private let primaryAppIconName: String init( + primaryAppIconName: String, options: [AppIconOption]? = nil, setter: any AlternateIconSetting = UIApplication.shared, ) { let resolved = options ?? AppIconCatalog.loadedOptions() self.options = resolved self.setter = setter - selectedID = AppIconModel.matchSelection(in: resolved, current: setter.alternateIconName) + self.primaryAppIconName = primaryAppIconName + selectedID = AppIconModel.matchSelection( + in: resolved, + current: setter.alternateIconName, + primaryAppIconName: primaryAppIconName, + ) } /// Whether the device supports alternate icons at all (false is rare on @@ -52,7 +59,9 @@ final class AppIconModel { func apply(_ option: AppIconOption) async -> Bool { guard option.id != selectedID, supportsAlternateIcons else { return false } do { - try await setter.setAlternateIconName(option.alternateIconName) + try await setter.setAlternateIconName(option.alternateIconName( + primaryAppIconName: primaryAppIconName, + )) selectedID = option.id return true } catch { @@ -75,8 +84,13 @@ final class AppIconModel { private static func matchSelection( in options: [AppIconOption], current alternateIconName: String?, + primaryAppIconName: String, ) -> AppIconID { - AppIconCatalog.selectedOption(in: options, current: alternateIconName)?.id ?? AppIconID("") + AppIconCatalog.selectedOption( + in: options, + current: alternateIconName, + primaryAppIconName: primaryAppIconName, + )?.id ?? AppIconID("") } } @@ -86,6 +100,7 @@ final class AppIconModel { /// canvas never touch the springboard. static func preview(activeAlternateIconName: String? = nil) -> AppIconModel { AppIconModel( + primaryAppIconName: "AppIcon", setter: InMemoryAlternateIconSetting(alternateIconName: activeAlternateIconName), ) } diff --git a/Where/WhereUI/Sources/Settings/AppIconOption.swift b/Where/WhereUI/Sources/Settings/AppIconOption.swift index ccd37fd8..c8c27b2e 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 fa1aed08..6bde4041 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 c5740dcf..61d1dbcf 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 00000000..e8675db7 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/PrimaryAppIconEnvironment.swift @@ -0,0 +1,5 @@ +import SwiftUI + +extension EnvironmentValues { + @Entry var primaryAppIconName: String = "AppIcon" +} diff --git a/Where/WhereUI/Sources/Shared/AppIconActivityIndicator.swift b/Where/WhereUI/Sources/Shared/AppIconActivityIndicator.swift index 4833333a..62eadffd 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 af957ee7..a676c99a 100644 --- a/Where/WhereUI/Sources/Shared/AppIconLoadingView.swift +++ b/Where/WhereUI/Sources/Shared/AppIconLoadingView.swift @@ -6,13 +6,15 @@ import SwiftUI /// scan, a summary generating — so they share one look and one accessibility /// shape instead of each rebuilding a spinner-plus-label. struct AppIconLoadingView: View { + @Environment(\.primaryAppIconName) private var primaryAppIconName + let caption: String @Environment(\.stylesheet) private var stylesheet var body: some View { VStack(spacing: stylesheet.spacing.xxLarge) { - AppIconActivityIndicator() + AppIconActivityIndicator(primaryAppIconName: primaryAppIconName) Text(caption) .font(.callout) .foregroundStyle(.secondary) diff --git a/Where/WhereUI/Tests/AppIconModelTests.swift b/Where/WhereUI/Tests/AppIconModelTests.swift index fdcc3bd4..905c9dc7 100644 --- a/Where/WhereUI/Tests/AppIconModelTests.swift +++ b/Where/WhereUI/Tests/AppIconModelTests.swift @@ -9,13 +9,13 @@ struct AppIconModelTests { AppIconOption( id: AppIconID("classic"), displayName: "Classic", - alternateIconName: nil, + assetName: "AppIcon", previewImageName: "AppIconClassic", ), AppIconOption( id: AppIconID("ocean"), displayName: "Ocean", - alternateIconName: "AppIconOcean", + assetName: "AppIconOcean", previewImageName: "AppIconOcean", ), ] @@ -23,36 +23,71 @@ struct AppIconModelTests { @Test func initialSelectionDerivesFromTheLiveIcon() { let setter = FakeIconSetter(alternateIconName: "AppIconOcean") - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) #expect(model.selectedID == AppIconID("ocean")) } @Test func initialSelectionFallsBackToThePrimary() { let setter = FakeIconSetter(alternateIconName: nil) - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) #expect(model.selectedID == AppIconID("classic")) } + @Test func nilLiveNameResolvesToANonClassicBuildPrimary() { + let setter = FakeIconSetter(alternateIconName: nil) + let model = AppIconModel( + primaryAppIconName: "AppIconOcean", + options: options(), + setter: setter, + ) + + #expect(model.selectedID == AppIconID("ocean")) + } + @Test func selectedOptionMatchesTheLiveAlternateName() { - let selected = AppIconCatalog.selectedOption(in: options(), current: "AppIconOcean") + let selected = AppIconCatalog.selectedOption( + in: options(), + current: "AppIconOcean", + primaryAppIconName: "AppIcon", + ) #expect(selected?.id == AppIconID("ocean")) } @Test func selectedOptionFallsBackToThePrimaryWhenNil() { - let selected = AppIconCatalog.selectedOption(in: options(), current: nil) + let selected = AppIconCatalog.selectedOption( + in: options(), + current: nil, + primaryAppIconName: "AppIcon", + ) #expect(selected?.id == AppIconID("classic")) } @Test func selectedOptionFallsBackToThePrimaryForAnUnknownName() { // An alternate icon set by an older build but since dropped from the // manifest resolves to the primary rather than nothing. - let selected = AppIconCatalog.selectedOption(in: options(), current: "AppIconGone") + let selected = AppIconCatalog.selectedOption( + in: options(), + current: "AppIconGone", + primaryAppIconName: "AppIcon", + ) #expect(selected?.id == AppIconID("classic")) } @Test func applySetsTheIconAndUpdatesSelection() async { let setter = FakeIconSetter(alternateIconName: nil) - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) await model.apply(options()[1]) @@ -63,7 +98,11 @@ struct AppIconModelTests { @Test func applyingThePrimaryClearsTheAlternateIcon() async { let setter = FakeIconSetter(alternateIconName: "AppIconOcean") - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) await model.apply(options()[0]) @@ -71,9 +110,27 @@ struct AppIconModelTests { #expect(model.selectedID == AppIconID("classic")) } + @Test func classicIsAnAlternateWhenAnotherAssetIsPrimary() async { + let setter = FakeIconSetter(alternateIconName: nil) + let model = AppIconModel( + primaryAppIconName: "AppIconOcean", + options: options(), + setter: setter, + ) + + await model.apply(options()[0]) + + #expect(setter.alternateIconName == "AppIcon") + #expect(model.selectedID == AppIconID("classic")) + } + @Test func applyIsANoOpWhenAlreadySelected() async { let setter = FakeIconSetter(alternateIconName: nil) - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) await model.apply(options()[0]) @@ -83,7 +140,11 @@ struct AppIconModelTests { @Test func applySurfacesErrorsAndLeavesSelectionUnchanged() async { let setter = FakeIconSetter(alternateIconName: nil) setter.errorToThrow = FakeIconError.boom - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) await model.apply(options()[1]) @@ -97,7 +158,11 @@ struct AppIconModelTests { @Test func unsupportedDevicesDoNotAttemptAChange() async { let setter = FakeIconSetter(supportsAlternateIcons: false, alternateIconName: nil) - let model = AppIconModel(options: options(), setter: setter) + let model = AppIconModel( + primaryAppIconName: "AppIcon", + options: options(), + setter: setter, + ) await model.apply(options()[1]) @@ -108,12 +173,14 @@ struct AppIconModelTests { @Test func bundledManifestLoadsAndIsConsistent() throws { let options = try AppIconCatalog.load() - #expect(!options.isEmpty) + #expect(options.isEmpty == false) #expect(options.contains { $0.id == AppIconID("classic") }) - #expect(options.filter(\.isPrimary).count == 1) + #expect(options.contains { $0.assetName == "AppIcon" }) let ids = options.map(\.id) #expect(Set(ids).count == ids.count) + let assetNames = options.map(\.assetName) + #expect(Set(assetNames).count == assetNames.count) } /// Guards the core manifest-driven invariant: every option the picker lists diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md index 24797523..f0416247 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 8b82b4f1..20cd5411 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 33a13478..62c838e1 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 00000000..e2c957a4 --- /dev/null +++ b/Where/WhereWidgets/Sources/WhereWidgetBuildEnvironment.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Audience values resolved by the widget host and injected into WhereCore. +struct WhereWidgetBuildEnvironment { + let appGroupIdentifier: String + + static func current( + infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:], + ) -> WhereWidgetBuildEnvironment { + let stampedAudience = requireString("WhereAudience", in: infoDictionary) + precondition( + stampedAudience == compiledAudience, + "WhereAudience does not match the widget target's compiler condition", + ) + return WhereWidgetBuildEnvironment(appGroupIdentifier: requireString( + "WhereAppGroupIdentifier", + in: infoDictionary, + )) + } + + private static func requireString( + _ key: String, + in infoDictionary: [String: Any], + ) -> String { + guard let value = infoDictionary[key] as? String, !value.isEmpty else { + preconditionFailure("WhereWidgets' Info.plist has no non-empty \(key)") + } + return value + } + + #if WHERE_DEVELOPMENT && WHERE_BETA + #error("Exactly one Where audience compiler condition must be active") + #endif + #if WHERE_DEVELOPMENT && WHERE_APP_STORE + #error("Exactly one Where audience compiler condition must be active") + #endif + #if WHERE_BETA && WHERE_APP_STORE + #error("Exactly one Where audience compiler condition must be active") + #endif + + #if WHERE_DEVELOPMENT + private static let compiledAudience = "development" + #elseif WHERE_BETA + private static let compiledAudience = "beta" + #elseif WHERE_APP_STORE + private static let compiledAudience = "appStore" + #else + #error("A Where audience compiler condition must be active") + #endif +} diff --git a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift index 3cc6022f..b8df6cbe 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 4a33f6e7..7b5588c6 100644 --- a/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift +++ b/Where/WhereWidgets/Sources/WhereWidgetsBundle.swift @@ -6,15 +6,17 @@ import WidgetKit /// the app writes; see `WhereWidgetProvider` for the data path. @main struct WhereWidgetsBundle: WidgetBundle { + private let buildEnvironment = WhereWidgetBuildEnvironment.current() + var body: some Widget { - TodayWidget() - YearTotalsWidget() + TodayWidget(appGroupIdentifier: buildEnvironment.appGroupIdentifier) + YearTotalsWidget(appGroupIdentifier: buildEnvironment.appGroupIdentifier) } } #if DEBUG #Preview("Where widgets", as: .systemSmall) { - TodayWidget() + TodayWidget(appGroupIdentifier: "group.com.stuff.where.preview") } timeline: { WhereWidgetEntry.sample } diff --git a/Where/WhereWidgets/Sources/YearTotalsWidget.swift b/Where/WhereWidgets/Sources/YearTotalsWidget.swift index 98f18027..e964a852 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 67bd84e8..6b7b1451 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 451a8f57..f91d74fc 100755 --- a/icons +++ b/icons @@ -4,7 +4,7 @@ set -euo pipefail # icons — add or remove a selectable Where app icon, reproducibly. # # Edits only data + asset folders (never Swift): -# - the app's AppIcon.xcassets (the alternate appiconset iOS swaps to) +# - the app's AppIcon.xcassets (the appiconsets each audience can use) # - WhereUI's AppIconPreviews.xcassets (the imageset the picker renders, since # SwiftUI Image() can't load appiconsets) # - AppIcons.json (the manifest the picker reads) @@ -19,6 +19,7 @@ cd "$(dirname "$0")" APP_CATALOG="Where/Where/Resources/AppIcon.xcassets" PREVIEW_CATALOG="Where/WhereUI/Sources/Resources/AppIconPreviews.xcassets" MANIFEST="Where/WhereUI/Sources/Resources/AppIcons.json" +PROJECT_MANIFEST="Project.swift" usage() { cat <<'USAGE' @@ -39,8 +40,10 @@ WhereUI preview catalog, and AppIcons.json in sync. --dark 1024x1024 PNG for the dark appearance. --tinted 1024x1024 PNG for the tinted (monochrome) appearance. -The primary "Classic" icon (AppIcon / id "classic") is reserved: it can't be -added or removed. +The base "Classic" icon (AppIcon / id "classic") is reserved: it can't be +added or removed. Which appiconset is primary is selected per audience in +Project.swift; the script refuses to remove any configured primary and manages +the assets available to every audience. Examples: ./icons --add art/ocean.png --name Ocean --dark art/ocean-dark.png @@ -113,6 +116,7 @@ fi MODE="$MODE" \ APP_CATALOG="$APP_CATALOG" PREVIEW_CATALOG="$PREVIEW_CATALOG" MANIFEST="$MANIFEST" \ + PROJECT_MANIFEST="$PROJECT_MANIFEST" \ LIGHT="$LIGHT" NAME="$NAME" ID="$ID" DARK="$DARK" TINTED="$TINTED" TARGET="$TARGET" \ python3 - <<'PY' import json @@ -126,6 +130,7 @@ MODE = os.environ["MODE"] APP_CATALOG = os.environ["APP_CATALOG"] PREVIEW_CATALOG = os.environ["PREVIEW_CATALOG"] MANIFEST = os.environ["MANIFEST"] +PROJECT_MANIFEST = os.environ["PROJECT_MANIFEST"] PRIMARY_SET = "AppIcon" PRIMARY_ID = "classic" @@ -139,7 +144,7 @@ def die(msg): def require_repo_layout(): missing = [ path - for path in (MANIFEST, APP_CATALOG, PREVIEW_CATALOG) + for path in (MANIFEST, PROJECT_MANIFEST, APP_CATALOG, PREVIEW_CATALOG) if not os.path.exists(path) ] if missing: @@ -155,6 +160,16 @@ def load_manifest(): die(f"{MANIFEST} is not valid JSON: {error}") +def configured_primary_sets(): + require_repo_layout() + with open(PROJECT_MANIFEST) as f: + source = f.read() + names = set(re.findall(r'primaryAppIconName:\s*"([^"]+)"', source)) + if not names: + die(f"couldn't find any Where audience primary icons in {PROJECT_MANIFEST}") + return names + + def save_manifest(data): out = json.dumps(data, indent=2, ensure_ascii=False, separators=(",", " : ")) with open(MANIFEST, "w") as f: @@ -197,8 +212,10 @@ def do_list(): id_width = max(len(i["id"]) for i in icons) name_width = max(len(i["displayName"]) for i in icons) for icon in icons: - alt = icon.get("alternateIconName") or "(primary)" - print(f" {icon['id']:<{id_width}} {icon['displayName']:<{name_width}} {alt}") + print( + f" {icon['id']:<{id_width}} " + f"{icon['displayName']:<{name_width}} {icon['assetName']}" + ) def do_add(): @@ -215,7 +232,7 @@ def do_add(): set_name = PRIMARY_SET + pascal_case(name) if set_name == PRIMARY_SET or icon_id == PRIMARY_ID: - die(f'"{PRIMARY_ID}" / "{PRIMARY_SET}" is the reserved primary icon') + die(f'"{PRIMARY_ID}" / "{PRIMARY_SET}" is the reserved base icon') require_1024(light) if dark: @@ -228,7 +245,7 @@ def do_add(): for icon in icons: if icon["id"] == icon_id: die(f'an icon with id "{icon_id}" already exists (use --id to pick another)') - if icon.get("alternateIconName") == set_name: + if icon["assetName"] == set_name: die(f'an icon named "{set_name}" already exists') appiconset = os.path.join(APP_CATALOG, set_name + ".appiconset") @@ -274,7 +291,7 @@ def do_add(): icons.append({ "id": icon_id, "displayName": name, - "alternateIconName": set_name, + "assetName": set_name, "previewImageName": set_name, }) save_manifest(data) @@ -285,7 +302,7 @@ def do_add(): def do_remove(): target = os.environ["TARGET"] if target.lower() in (PRIMARY_ID, PRIMARY_SET.lower()): - die('the primary "Classic" icon can\'t be removed') + die('the base "Classic" icon can\'t be removed') data = load_manifest() icons = data.get("icons", []) @@ -297,17 +314,22 @@ def do_remove(): in { icon["id"].lower(), icon["displayName"].lower(), - (icon.get("alternateIconName") or "").lower(), + icon["assetName"].lower(), } ), None, ) if match is None: die(f'no icon matching "{target}" (try ./icons --list)') - if match.get("alternateIconName") is None: - die('the primary "Classic" icon can\'t be removed') - - set_name = match["alternateIconName"] + if match["assetName"] == PRIMARY_SET: + die('the base "Classic" icon can\'t be removed') + + set_name = match["assetName"] + if set_name in configured_primary_sets(): + die( + f'"{set_name}" is configured as a Where audience primary icon; ' + f'change {PROJECT_MANIFEST} before removing it' + ) for path in ( os.path.join(APP_CATALOG, set_name + ".appiconset"), os.path.join(PREVIEW_CATALOG, set_name + ".imageset"), @@ -318,7 +340,7 @@ def do_remove(): data["icons"] = [icon for icon in icons if icon is not match] save_manifest(data) print(f'Removed "{match["displayName"]}" (id: {match["id"]}).') - print("If it was the active icon, the app falls back to Classic on next launch.") + print("If it was active, the app falls back to its configured primary on next launch.") print("Run `./ide --no-open` to regenerate.")