Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
177 changes: 166 additions & 11 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -164,13 +308,17 @@ 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`
// defaults, because Settings > About shows them: the version a
// 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.",
),
Expand Down Expand Up @@ -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": "",
]),
Expand All @@ -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"),
]),
Expand All @@ -234,6 +385,7 @@ let project = Project(
.package(product: "WhereCore"),
.package(product: "WhereUI"),
],
settings: whereHostSettings(.widget),
),
.target(
name: "WhereShareExtension",
Expand All @@ -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(
Expand All @@ -269,6 +423,7 @@ let project = Project(
.package(product: "WhereCore"),
.package(product: "WhereUI"),
],
settings: whereHostSettings(.share),
),
.target(
name: "RegionViewer",
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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

Expand Down
10 changes: 7 additions & 3 deletions Where/Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 10 additions & 4 deletions Where/Where/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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)).
13 changes: 10 additions & 3 deletions Where/Where/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()
}
Expand Down
Loading
Loading