Fix fresh-install launch failure: canonical store, prompt promotion, honest splash#99
Conversation
On a fresh install, the launch's open-store step and the Spotlight indexer (via IntentServices -> WhereServices.forIntents()) each opened their own ModelContainer over the same not-yet-existing App Group store file, racing to create it; the launch's open could stall and throw SwiftData.SwiftDataError, flashing the 'Couldn't finish launching' failure UI (observed on device; double open confirmed in the unified log). Add SwiftDataStore.canonical() — an actor-serialized get-or-create of the process's single Storage.default store — and resolve it from both WhereBootstrap.makeServices() and forIntents(), so production wiring shares one container per process and intent writes ping the same changes() signal the UI refreshes from. make()/inMemory() stay ordinary factories for deliberately independent opens (debug tooling, tests). AppDelegate now indexes Spotlight regions after the launch drive finishes instead of concurrently with it. Plan step: Share the canonical on-disk SwiftDataStore.
A cancelled (superseded) drive's in-flight step isn't required to be cancellation-responsive: it can keep working after enterForeground()/ teardown() cancels its drive and then throw a real error. runStep's catch wrote phase = .failed regardless, letting a dying drive stomp the superseding drive's state (observed as a flash of the launch failure UI during the fresh-install store race). Report .cancelled instead when the drive task is already cancelled — the new drive re-runs the step and surfaces its own outcome. Plan step: LifecycleKit cancelled-drive phase clobber.
When a cold launch is misdetected as headless (scene-lifecycle applicationState reads .background in didFinishLaunching) and the scene is already .active by the time RootView appears, .onChange(of: scenePhase) never fires — so the only promotion path was the .task's check, which ran *after* awaiting launcher.run(), i.e. behind the entire background drive. The user stared at the container's empty background surface (a blank screen) until the drive finished; on the fresh-install store stall that was ~30 seconds with no splash (observed on device). Promote first, then await run(): enterForeground() is a no-op for a foreground launch and re-drives a misdetected background one, so the splash appears within the first frames. Plan step: RootView promotion order.
…ches The slow-launch caption promises a data update, but a launch that hasn't onboarded yet (fresh install, post-reset relaunch) has no data to update — and a fresh install's very first store creation routinely outlives the 500ms caption delay, so new users saw migration copy that read like an error state. LaunchSplashView now takes showsDataCaption and RootView passes model.hasOnboarded, read live so the post-reset relaunch is covered too; the radar pulse alone carries 'working' on those launches. Plan step: first-run caption suppression.
Docs-only groundwork: files the applicationState-is-.background-at- didFinishLaunching misclassification (every cold user launch drives headless first and gets promoted) with the evidence and constraints, so the LifecycleKit-level fix isn't lost. Plan step: record launch-reason misdetection.
…gating
Review follow-up (finding 1): the hasOnboarded gate only covered the
pre-onboarding stint — completeOnboarding() flips the flag before the
launch continues, so the post-onboarding splash re-armed the caption
and a slow first run could still read 'Updating your data…'. The copy
was always a guess about *why* a launch is slow (migration, first store
creation, or plain slowness), so replace it with neutral copy ('Getting
things ready…', keys launch.caption.*) and remove the showsDataCaption
gate entirely — the caption is now honest on every launch, including
first runs and post-reset relaunches.
Review follow-up (finding 2): with the runner deliberately discarding a superseded drive's error (no .failed clobber), a store-open failure in a cancelled open-store step left no trace anywhere — makeServices didn't log on throw and neither does SwiftDataStore.make. Catch-log-rethrow via WhereLog so the failure is always observable, superseded or not; the 'WhereServices assembled' info line also moves after assembly actually succeeds.
Review follow-up (finding 3): production wiring resolves canonical(); make(storage:) is the independent-open factory (sibling processes, debug tooling, tests). Docs-only.
Review follow-up (finding 4): forIntents() is now safely testable (the canonical store resolves in-memory under tests), so drop the stale 'opens the real App Group store' comment and add a serialized suite asserting forIntents() shares the canonical container — in both orders (launch first, intent first) — pinning the fresh-install creation-race regression this branch fixes.
| /// is a reuse default, not a lock-out: a deliberately independent open | ||
| /// (debug tooling, tests) keeps using `make(storage:)` / `inMemory()`, | ||
| /// which stay ordinary factories with no registration side effects. | ||
| public static func canonical() async throws -> SwiftDataStore { |
There was a problem hiding this comment.
I don't like making this a static, we should avoid singletons. Let's instead initialize the store once and inject it where it needs to go.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Addressed in 598ff3b: the singleton is gone. SwiftDataStore.canonical() and its process-global vendor are removed; the store is opened exactly once by the launch's open-store step, and everything else gets it by injection — WhereServices.forIntents(sharingStoreOf:) builds the GPS-free intents stack over the store an existing service layer holds, and AppDelegate installs that stack into IntentServices.shared.install(_:) after the launch drive finishes (Spotlight indexing rides it). The parameterless forIntents() remains only as the fallback for an intent racing app startup, back on its explicit .localOnly open. The serialized canonical test suites became hermetic injection tests.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Follow-up in fbff56b: the residual two-store window is gone too. The parameterless forIntents() (the self-open fallback) is deleted — forIntents(sharingStoreOf:) is the only way an intents stack is built and it never opens a store. IntentServices.current() now parks (cancellation-aware) until the launch installs the stack, and installation moved into the open-store step via an onServicesReady hook on WhereLaunch.makeLauncher, so it re-fires on retry-after-failure and reset relaunches. The launch's open is structurally the process's only store open.
PR feedback: avoid the process-global singleton. SwiftDataStore.canonical() and its vendor are gone; the store is opened exactly once by the launch's open-store step (back on a detached task), and everything else shares it by injection: - WhereServices.forIntents(sharingStoreOf:) builds the GPS-free intents stack over the store an existing service layer already holds. - After the launch drive finishes, AppDelegate derives that stack from the session's services and installs it via the new IntentServices.shared.install(_:); Spotlight indexing then rides it. WhereSession.services is public so the app target can reach the seam. - The parameterless forIntents() remains only as the fallback for an intent racing app startup, back on an explicit .localOnly open. Tests swap the serialized canonical suites for hermetic ones pinning that the shared stack rides the base services' container (and that writes through it are visible to the base). Docs updated to the injection model.
…open PR feedback follow-up: even the rare fallback double open is gone. The parameterless WhereServices.forIntents() (which opened its own .localOnly container) is deleted — forIntents(sharingStoreOf:) is now the only way an intents stack is built, and it never opens a store. IntentServices becomes a pure handoff: current() returns the installed stack or parks (cancellation-aware continuation) until the launch installs one; it can no longer assemble anything. Installation moves into the open-store step via a new onServicesReady hook on WhereLaunch.makeLauncher/sequence, so it re-fires on every session (re)start — first launch, retry after a failed launch, and the reset relaunch — and completes before the launch proceeds. AppDelegate wires the hook (derive stack, install); an intent racing a failed launch parks until Try Again succeeds, bounded by the system's intent time limit. New IntentServicesTests (hermetic instances: install/park/cancel/replace semantics, condition-based waits via a waiterCount probe) and WhereLaunchTests coverage that open-store hands the session's services to the hook on launch and again on the reset relaunch.
Review follow-ups: - forIntents(sharingStoreOf:) is now synchronous and non-throwing: it reuses the base layer's attributor, aggregator, and clock (newly retained on WhereServices) instead of re-reading tracked regions through make(). The failure mode where a hook error left every intent parked for the process lifetime is unrepresentable, AppDelegate's catch-and-log disappears, and the derived stack no longer duplicates a live RegionAttribution + changes() subscription — or diverges from an injected clock (new sharedStackInheritsTheBaseClock test). - WhereSession.services reverts to internal: the onServicesReady hook delivers the services, so the public widening from the intermediate design was dead API. - WhereIntents/AGENTS.md testing section now forbids calling an intent's perform() in tests: it resolves IntentServices.shared, which parks with nothing installed in the test host (or silently rides a stack another bundle's app-launch test left behind). - makeForIntents is re-documented as the test seam it now is.
Distills the architecture lesson from the fresh-install store-race fix: shared resources are created once at the composition root and injected (never re-resolved from globals); platform-instantiated consumers get a handoff seam that installs and awaits rather than a self-creating fallback; derived stacks reuse what the base computed so wiring can't fail; and composition hooks re-fire wherever the lifecycle re-creates the thing. Sits beside 'Modeling state' as its ownership/lifetime counterpart. Docs-only.
…leton PR feedback: IntentServices.shared is gone. AppDelegate — the composition root — owns the one IntentServices instance, registers it with the App Intents framework's dependency container (AppDependencyManager.shared.add, synchronously in didFinishLaunching, before the system can deliver an intent), and installs the store-sharing stack into it via the onServicesReady hook. Every intent and the region entity query resolve it with @dependency; RegionSpotlightIndexer (called by us, not the system) receives it by plain parameter injection. The handoff semantics are unchanged (install / park / cancel /replace); we just no longer own any global — the one static seam is the platform's purpose-built DI container. Root AGENTS.md's composition section and the WhereIntents docs now describe registering with the platform seam; the testing rule tightens to '@dependency traps when nothing registered'.
PR feedback: replace the local-binding workaround with the explicit provider closure — the capture list carries the Sendable actor reference ([intentServices = self.intentServices]), which the compiler accepts and SwiftFormat's redundantSelf rule leaves alone, so the registration is format-stable without an intermediate let.
Review follow-up: attempted the registration→resolution smoke test and it cannot exist — AppDependencyManager fatal-errors on any @dependency access outside 'the intent perform flow' (probe trapped with exactly that message: 'Dependency values can only be accessed inside of the intent perform flow and within types conforming to _SupportsAppDependencies'). The attempt did pay for itself: it proved a closure-literal registration lands on the async-throwing provider overload and still resolves inside the framework's flow is unverifiable in-process either way, so the registration keeps the provider-closure spelling and the constraint is now documented at the registration site, in the launch test, and in WhereIntents/AGENTS.md — along with the double-registration caveat for the app-hosted test bundle. Verifying the seam means invoking a Siri/Shortcuts intent on a device.
Conflict resolutions:
- AGENTS.md, Where/TODOs.md: clean union of both sides' additions
(main's synthesized-Codable + composition-root sections and new TODO
entries alongside our snapshot-testing docs and flakiness ledger).
- LaunchSplashView.swift: main's honest-splash copy (launch-neutral
"Getting things ready…" caption strings + doc) combined with our
snapshot determinism work (@MotionIsStatic, radar phase pinned to 0
under \.isCapturingSnapshot, previewShowsCaption seam).
- PreviewSupport.swift: main's FlightDayIssue sample fixture kept
alongside our pinned referenceNow dates, pre-onboarded loadedModel,
and Noop scheduler injection.
- SettingsView.swift: main's "Find issues now" scan section combined
with our SnapshotDatePickerStandIn capture substitution.
- ScreenHostingTests.swift (modify/delete): kept our deletion — the
hosting smoke tests were superseded by WhereUISnapshotTests. Main's
new flightDayDetailViewHosts coverage is ported as a snapshot case
instead: FlightDayDetailView gains a SnapshotProviding conformance
(day pinned to referenceNow) and a ScreenSnapshotTests.flightDayDetail
test with 10 new references.
Re-recorded references (all legitimate upstream UI changes):
- launchSplash.SlowLaunchCaption_iPhone{,_dark}: caption copy changed
from "Updating your data…" to "Getting things ready…" (#99).
- settings.Default_iPad{,_dark,_contrast,_accessibility}: new
"Find issues now" row in the Data resolution section (#90); the
iPhone variants are unchanged because that section sits below the
captured fold there.
- resolution.WithIssues_{iPhone,iPad}{,_dark,_contrast,_accessibility}
and resolution.WithIssues_iPad_ax5: new "Flights" section renders the
flight-day sample issue (#90); iPhone_ax5 unchanged (below the fold).
Stuff-iOS-Tests passes. Snapshot suite: failures triaged in a full
diagnostic run, re-recorded references verified green with a targeted
run; the final all-green full-suite pass was skipped at user request —
PR CI serves as the full-suite verification.
Co-authored-by: Cursor <cursoragent@cursor.com>
Problem
On a fresh install, the app sat on a blank white screen for ~30 seconds, flashed the full-screen "Couldn't finish launching — (SwiftData.SwiftDataError error 1.)" failure, then recovered into onboarding. Diagnosed from a device screen recording plus the simulator unified log.
Three separate defects lined up:
open-storestep andRegionSpotlightIndexer(via the intent layer's self-opened store) each opened their ownModelContainerover the same not-yet-existing App Group store file (two "Opened SwiftData store" log lines 1ms apart). On a fresh install the launch's open lost the race, stalled, and threwSwiftDataError→LifecycleFailureView.background(.other)on scene-based cold launches (applicationStatereads.backgroundindidFinishLaunching), so the container renderedEmptyView. With the scene already.activeat first render,.onChangenever fires — andRootView's fallback promotion ran only afterawait launcher.run(), i.e. behind the whole stuck drive. Hence the blank screen with no splash.phase. A cancelled drive's in-flight step that threw a real error still wrotephase = .failedover the promoted drive's state.Changes
open-storestep performs the process's only store open; everything else reaches it by injection.WhereServicesretains the store, attributor, aggregator, and clock it was built with, andWhereServices.forIntents(sharingStoreOf:)— synchronous, non-throwing, and the only way an intents stack is built — derives the GPS-free App Intents stack from them.AppDelegate(the composition root) owns the oneIntentServiceshandoff, registers it with the App Intents framework'sAppDependencyManagerindidFinishLaunching, and installs the stack into it via a newonServicesReadyhook onWhereLaunch.makeLauncherthat re-fires on every session (re)start (first launch, retry after failure, reset relaunch). Intents and the entity query resolve the handoff with@Dependency;current()parks (cancellation-aware) until installation instead of ever opening a store. Intent writes ping the samechanges()signal the UI refreshes from; Spotlight indexing moved after the launch drive and receives the handoff by parameter. (Two interim shapes — aSwiftDataStore.canonical()get-or-create and anIntentServices.sharedstatic — were reviewed away in favor of this; see the review thread.).cancelledinstead of writing.failed;WhereBootstrap.makeServices()catch-logs-rethrows so a discarded failure still leaves a trace.launcher.run(), so the splash appears within the first frames instead of after the whole background drive.launch.caption.*keys) — the caption fires on any slow launch (migration, first store creation, plain slowness), so it no longer claims a specific cause on a first run.AGENTS.mdgains a "Composition: create once, inject down" conventions section distilling the lesson (no singletons; use the platform's DI seam for platform-instantiated consumers, and keep it a handoff-not-factory; derive-don't-re-derive; re-fire hooks on lifecycle re-creation).Where/TODOs.mdrecords the launch-reason misdetection for a proper LifecycleKit-level fix; module docs updated to the injection model.Testing
forIntents(sharingStoreOf:)shares the base's container/clock and writes are visible across stacks (WhereServicesForIntentsSharingTests), theIntentServiceshandoff semantics — install / park / cancel / replace — on hermetic per-test instances (IntentServicesTests), theonServicesReadyhook firing on launch and again on the reset relaunch (WhereLaunchTests), and the superseded-drive phase-clobber (LifecycleRunnerForegroundPromotionTests).Stuff-iOS-Testsscheme green locally (iPhone 17, iOS 26.2 simulator);./swiftformat --lintclean.@Dependency→ the registered handoff).