diff --git a/AGENTS.md b/AGENTS.md index ea4dff43..e74d623c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,11 +138,11 @@ that nothing in this repo depends on a skill having been loaded. ### Never double-link a product a dynamic framework already carries A target that depends on **WhereUI** must not also list any of WhereUI's own -dependencies (WhereCore, Broadway, LifecycleKit, Periscope, SwiftDataInspector, -…) in `extraPackageProducts` — reach them transitively. A second copy splits the -module's type metadata across the WhereUI boundary and every type-keyed lookup -(SwiftUI `EnvironmentKey`s, Broadway's `BTraits`/`BThemes`/`BStylesheets`) -silently resolves against the wrong one. +dependencies (WhereCore, Broadway, LifecycleKit/LifecycleKitUI, Periscope, +SwiftDataInspector, …) in `extraPackageProducts` — reach them transitively. A +second copy splits the module's type metadata across the WhereUI boundary and +every type-keyed lookup (SwiftUI `EnvironmentKey`s, Broadway's +`BTraits`/`BThemes`/`BStylesheets`) silently resolves against the wrong one. Worth knowing rather than rediscovering: it reproduces only in the full multi-bundle scheme, never in an isolated `tuist test WhereUITests`. The @@ -267,8 +267,13 @@ scope and invariants on top rather than restating these. - Identifiers/keys are `Hashable` (ideally a typed enum) or `AnyHashable`, not raw `String`s — a typed token can't silently typo into a new, untracked id, and any `Hashable` converts to `AnyHashable` implicitly at the call site. - (e.g. `LifecycleStep.id` is `AnyHashable`; the Where app keys its launch steps - with the `LaunchStepID` enum, and `WherePreferences` keys with a `Keys` enum.) + Prefer carrying the *concrete* type where a generic can: `LifecycleStep` + declares `associatedtype ID: Hashable & Sendable` and `LaunchPlan` is generic + over it, so a plan's nodes must share one identity domain and `nodeIDs` hands + tests back typed cases instead of erased `AnyHashable`s. Reach for + `AnyHashable` only where a generic can't reach (a non-generic environment + value, a heterogeneous container). The Where app keys its launch steps with + the `LaunchStepID` enum, and `WherePreferences` keys with a `Keys` enum. - **Avoid parameter defaults on Core/store APIs.** Prefer explicit call-site arguments so new behavior isn't silently opted into. Reserve defaults for SwiftUI convenience inits and obvious zero values (`[]`, `.zero`) where diff --git a/Package.swift b/Package.swift index a2e69a97..e9915f0a 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,7 @@ let package = Package( products: [ .library(name: "StuffCore", targets: ["StuffCore"]), .library(name: "LifecycleKit", targets: ["LifecycleKit"]), + .library(name: "LifecycleKitUI", targets: ["LifecycleKitUI"]), .library(name: "JournalKit", targets: ["JournalKit"]), .library(name: "PeriscopeCore", targets: ["PeriscopeCore"]), .library(name: "PeriscopeUI", targets: ["PeriscopeUI"]), @@ -38,6 +39,13 @@ let package = Package( .target( name: "LifecycleKit", path: "Shared/LifecycleKit/Sources", + ), + .target( + name: "LifecycleKitUI", + dependencies: [ + .target(name: "LifecycleKit"), + ], + path: "Shared/LifecycleKitUI/Sources", resources: [ .process("Resources"), ], @@ -107,6 +115,7 @@ let package = Package( .target(name: "BroadwayCore"), .target(name: "BroadwayUI"), .target(name: "LifecycleKit"), + .target(name: "LifecycleKitUI"), .target(name: "PeriscopeCore"), .target(name: "PeriscopeTools"), .target(name: "PeriscopeUI"), diff --git a/Project.swift b/Project.swift index 756eb120..01b5beb1 100644 --- a/Project.swift +++ b/Project.swift @@ -20,8 +20,9 @@ private let developmentTeam = Environment.developmentTeam.getString(default: "") /// `STRING_CATALOG_GENERATE_SYMBOLS` turns on Xcode's type-safe String Catalog /// symbol generation for the app and app-extension targets (Where, WhereWidgets, /// WhereShareExtension, …). The SwiftPM package targets declared in `Package.swift` -/// (WhereUI, WhereCore, RegionKit, LifecycleKit) get symbol generation automatically -/// from the toolchain, so this only needs to reach the Tuist-native targets. +/// (WhereUI, WhereCore, RegionKit, LifecycleKitUI) get symbol generation +/// automatically from the toolchain, so this only needs to reach the +/// Tuist-native targets. /// /// `DEVELOPMENT_TEAM` is threaded in from the environment when present (see above). private let projectSettings: Settings = .settings( @@ -281,6 +282,13 @@ let project = Project( productDependency: "LifecycleKit", sources: ["Shared/LifecycleKit/Tests/**"], ), + unitTests( + name: "LifecycleKitUITests", + bundleIdSuffix: "lifecyclekitui", + productDependency: "LifecycleKitUI", + sources: ["Shared/LifecycleKitUI/Tests/**"], + extraPackageProducts: ["LifecycleKit"], + ), unitTests( name: "JournalKitTests", bundleIdSuffix: "journalkit", @@ -340,7 +348,7 @@ let project = Project( // BTraits/BThemes/BStylesheets containers) then silently resolves against // the wrong copy — the writer stores under one copy's key type, the // reader looks it up under another's. Everything the tests need - // (BroadwayCore/BroadwayUI, LifecycleKit, PeriscopeCore/UI/Tools, + // (BroadwayCore/BroadwayUI, LifecycleKit/LifecycleKitUI, PeriscopeCore/UI/Tools, // SwiftDataInspector, RegionKit + its GeoJSON bundle) is reached // transitively through WhereUI. // See "Never double-link a product a dynamic framework already @@ -435,6 +443,7 @@ let project = Project( "StuffTestHost", "StuffCoreTests", "LifecycleKitTests", + "LifecycleKitUITests", "JournalKitTests", "PeriscopeCoreTests", "PeriscopeUITests", @@ -453,6 +462,7 @@ let project = Project( testAction: .targets([ "StuffCoreTests", "LifecycleKitTests", + "LifecycleKitUITests", "JournalKitTests", "PeriscopeCoreTests", "PeriscopeUITests", @@ -470,6 +480,7 @@ let project = Project( ), testScheme(name: "StuffCoreTests"), testScheme(name: "LifecycleKitTests"), + testScheme(name: "LifecycleKitUITests"), testScheme(name: "JournalKitTests"), testScheme(name: "PeriscopeCoreTests"), testScheme(name: "PeriscopeUITests"), diff --git a/Shared/LifecycleKit/AGENTS.md b/Shared/LifecycleKit/AGENTS.md index 35e353e6..8aa67648 100644 --- a/Shared/LifecycleKit/AGENTS.md +++ b/Shared/LifecycleKit/AGENTS.md @@ -1,38 +1,73 @@ # LifecycleKit – Module Shape -LifecycleKit is an app-agnostic SwiftUI microframework that models app startup -(and its reverse, teardown) as an ordered, conditional, launch-reason-aware -sequence of async steps: a `@MainActor @Observable` `LifecycleRunner` walks a -`LifecycleSteps` sequence and publishes one `phase`; `LifecycleContainer` -renders it. See [`README.md`](README.md) for the full narrative and API. +LifecycleKit is an app-agnostic engine that models app startup (and its +reverse, teardown) as a **typed plan**: steps are types with concrete +`Input`/`Output`, a `LaunchPlan` composes them into a sequential trunk plus +concurrent detached fan-outs with the data flow checked at compile time, and +a `@MainActor @Observable` `LifecycleRunner` walks the plan and +publishes one value-carrying `phase`. Rendering lives in +[LifecycleKitUI](../LifecycleKitUI/AGENTS.md). See [`README.md`](README.md) +for the full narrative and API. This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns build system, formatting, and global conventions. Read that first. ## Scope & dependencies -- Pure **SwiftUI + Foundation + Observation**. It must **not** import - WhereCore, UIKit, or any app code — app-specific launch logic lives in the - consumer (e.g. `WhereUI/Sources/Launch/`). -- Steps and the engine are `@MainActor`; heavy work hops to an actor *inside* - a step's `perform`, never by loosening isolation on the step. +- Pure **Foundation + Observation**. It must **not** import SwiftUI, UIKit, + WhereCore, or any app code — views belong in LifecycleKitUI; app-specific + launch logic lives in the consumer (e.g. `WhereUI/Sources/Launch/`). +- Steps, gates, and the engine are `@MainActor`; heavy work hops to an actor + *inside* a step's `run`, never by loosening isolation on the step. ## Invariants +- **The type erasure has exactly one home.** `LaunchPlan`'s combinators erase + steps into `LaunchPlanNode` (package-visible for the runner and the UI + proxy seam); their generic constraints guarantee every internal cast. Never + add a second erasure site or a public API that traffics in `Any`. +- **One identity domain per plan.** `LaunchPlan` is generic over + `ID: Hashable & Sendable` and every combinator requires the node's + `ID` to match, so a plan can't mix domains and a node keyed for another + plan can't be composed in; `nodeIDs` gives back `[ID]`, not erased keys. + IDs erase to `AnyHashable` *inside* `LaunchPlanNode` and stay erased from + there on — the runner's memo, `LifecycleFailure.stepID`, + `LifecycleStepContext.stepID`, and `LifecycleGateHandle.id` are all + `AnyHashable`, deliberately: `LifecycleDriving` (the seam behind the + non-generic `\.lifecycle` environment value) traffics in `[LaunchPlanNode]`, + so pushing `ID` past the plan would force it onto the runner, the container's + generic list, and every splash/failure/gate closure. If you want typed + `failed(at:)` / `isRunning(_:)` assertions, that's the (deliberate) cost to + price in — it isn't an oversight. +- **Only pass-through positions may skip.** Value-producing (`init`/`then`) + steps must keep `modes == .all` (plan-construction `precondition`) — a + skipped producer would leave a hole in the data flow. Don't add a skip path + for them. +- **Failure is terminal.** A thrown node parks `.failed` with no retry — the + recovery is relaunching the app. A failed teardown likewise parks and does + not relaunch (a thrown erase leaves state intact). Don't reintroduce a + resume/retry path; if a node is genuinely flaky, retry inside it at the + layer that understands the failure. - **All drives funnel through a single in-flight task** (cancel-and-drain): - two drives never overlap, and `teardown()`/`enterForeground()` can interrupt - a launch parked on an interactive step. A cancelled drive is distinct from a - thrown step (`.failed`). Don't add a drive path that bypasses that - serialization. -- **Launches with no window build no view tree.** `LifecycleContainer` returns - `EmptyView()` whenever `runner.reason.buildsNoViewTree` (a `.background` - relaunch, or an `.undetermined` one not yet promoted) — even at `.ready` — so - `content()` is never constructed for a launch nobody sees. + two drives never overlap, and `teardown()`/`enterForeground()` can + interrupt a launch parked on a gate. A cancelled drive is distinct from a + thrown node (`.failed`), a superseded drive never writes the phase the new + drive owns, and a superseded drive's gate handle resolves to a no-op. + Don't add a drive path that bypasses that serialization. +- **Memoized run-once, for promotion.** Completed nodes' outputs are + memoized so an `enterForeground()` promotion's re-walk skips completed + work; skipped gates are deliberately *not* memoized so they re-evaluate on + promotion. Fresh attempts (first `run()`, the start of a teardown, the + post-teardown relaunch) clear the memo — so teardown plans may freely reuse + launch node IDs (no live shared memo, since there is no retry re-walk). +- **Detached children are off the critical path by construction:** they never + block `.ready`, never fail the drive, and surface failures only on + `detachedFailures`. - **`.undetermined` is the honest UIScene launch reason.** Under the UIScene lifecycle `UIApplication.applicationState` reads `.background` at `didFinishLaunching` even for a user tap, so a consumer that can't yet tell a headless wake from a user launch should launch `.undetermined` rather than - fabricate a `.background(cause)`. It gates to the background-safe steps and + fabricate a `.background(cause)`. It gates to the background-safe nodes and builds no view tree until `enterForeground()` promotes it; if no scene ever connects it honestly stays `.undetermined`, never claiming a cause it didn't observe. @@ -41,20 +76,11 @@ system, formatting, and global conventions. Read that first. repeat call costs nothing while `.background` and `.undetermined` both promote; consumers must only call it once the scene is genuinely `.active` (see `RootView` in WhereUI for the `scenePhase` gating pattern). -- **Each step runs at most once per launch attempt.** A completed step is - recorded in `completedStepIDs`, and a re-drive (`enterForeground()` promotion) - skips it — so a work step that already serviced the windowless drive isn't - repeated when foreground-only steps get their turn. Only steps that actually - ran to completion are recorded (a mode/condition-skipped or cancelled step - isn't), so promotion still runs the newly-applicable steps and re-evaluates - conditions. The set resets on a *fresh* attempt (first `run()`, and the - relaunch after `teardown()`), so a reset re-runs everything; it's preserved - across `retry()`, which resumes from the failed step. ## Testing Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost`. Engine tests -build a `LifecycleSteps` and assert on `phase`; view tests host -`LifecycleContainer` and assert which branch renders; seeded fuzz tests -(`LifecycleRunnerFuzzTests`) replay failures exactly. Keep tests -deterministic — gate async steps on test-controlled continuations, not timing. +build a `LaunchPlan` from the shared `FixtureStep`/`FixtureGate` fixtures and +assert on `phase`; seeded fuzz tests (`LifecycleRunnerFuzzTests`) replay +failures exactly against an independent model. Keep tests deterministic — +park async steps on test-controlled streams/handles, not timing. diff --git a/Shared/LifecycleKit/README.md b/Shared/LifecycleKit/README.md index 824f363c..8dff3f59 100644 --- a/Shared/LifecycleKit/README.md +++ b/Shared/LifecycleKit/README.md @@ -1,320 +1,258 @@ # LifecycleKit -A small, app-agnostic SwiftUI microframework that models **app startup — and -its reverse, reset/teardown — as an ordered, conditional, launch-reason-aware -sequence of async steps**, driven by a `@MainActor @Observable` engine whose -single published `phase` the root view renders. - -It replaces the usual scattering of launch logic (a synchronous `bootstrap()` -in the app delegate, an async `start()` on a model, a second `start()` from a -view's `.task`) with one linear, inspectable flow. A thrown error bubbles up to -a failure phase with retry; logout/erase is the same machinery run in reverse. - -LifecycleKit depends only on SwiftUI + Foundation + Observation — no app code. +A small, app-agnostic engine that models **app startup — and its reverse, +reset/teardown — as a typed plan**: a sequential trunk of required steps plus +concurrent detached fan-outs, driven by a `@MainActor @Observable` runner +whose single published `phase` the UI layer renders. + +Each step is its own type with concrete `Input`/`Output`. The plan's +combinators check the data flow at compile time, so the classic launch bugs — +a step running before the thing it needs exists, a skipped step leaving a +hole downstream, the app UI rendering off an optional that "should" have been +set — are unrepresentable rather than merely avoided. A thrown trunk step +parks the runner in a terminal failure phase (no retry — the recovery is +relaunching the app); logout/erase is the same machinery run over a teardown +plan. + +LifecycleKit depends only on Foundation + Observation — **no SwiftUI, no app +code**. Everything rendered lives in [LifecycleKitUI](../LifecycleKitUI). ## Mental model -Launch is a pipeline. The engine awaits each step in order; advancing to the -next step just moves the cursor, and a thrown error short-circuits to `.failed`. +Launch is a typed pipeline. The trunk value at any point is the launch's +*dependency scope so far* — the proof of everything promoted to that point — +and it only grows, by each step embedding what came before: ``` -launching ──▶ running ──▶ running ──▶ … ──▶ ready - │ ▲ - └──▶ failed ──(retry)────────┘ - -ready ──(teardown)──▶ launching ──▶ … ──▶ ready + trunk (required, ordered, typed) detached fan +┌──────────┐ ┌───────────────┐ ┌──────────┐ ┌───────────┐ ┌──────────┐ +│ OpenStore ├──▶│ StartSession ├──▶│ gate: ├──▶│ SyncAuth ├─┬▶ Reminders │ +│ Void→Svcs │ │ Svcs→Session │ │Onboarding│ │ (keeping) │ ├▶ Widgets │ … +└──────────┘ └───────────────┘ └──────────┘ └───────────┘ └▶ … + │ + .ready(Session) — before the fan drains ``` -The key insight that unifies silent and interactive steps: **an interactive -step is just an async step that awaits a continuation the presented UI -resumes.** Onboarding and migration aren't special engine cases — they're steps -whose `perform` suspends on `bridge.waitForResolution()` while their -`presentation` view is shown, and the view calls `bridge.complete()`. +- **`then` steps** produce the next scope; their `Input` must equal the + current trunk `Output`, so misordering is a compile error. They can never + be skipped (the plan `precondition`s `modes == .all` for them) — a skipped + producer would leave a hole in the data flow. +- **`thenKeeping` steps** are required `Void`-output work; the trunk value + flows past them, so they *may* gate on the launch reason. +- **Gates** park the trunk awaiting external (user) resolution — onboarding + is the canonical one. Pass-through by construction, foreground-only by + default, re-evaluated when a headless launch is promoted. +- **Detached children** take the trunk value and return `Void`, so nothing + can depend on a fire-and-forget step. They run concurrently, never block + `.ready`, and a failure lands on the runner's `detachedFailures` + diagnostics — observable, never fatal. ## Installation -`LifecycleKit` is a local SPM library in this repo (`Shared/LifecycleKit`). Add -it to a target's dependencies in [`Package.swift`](../../Package.swift): +`LifecycleKit` is a local SPM library in this repo (`Shared/LifecycleKit`). +Add it to a target's dependencies in [`Package.swift`](../../Package.swift): ```swift -.target(name: "YourUI", dependencies: [.target(name: "LifecycleKit")]) +.target(name: "YourCore", dependencies: [.target(name: "LifecycleKit")]) ``` ## Core API ```swift -// Why we're launching — gates UI-bearing steps. `.undetermined` is the honest -// state under the UIScene lifecycle, where `applicationState` can't tell a user -// launch from a headless wake at launch: it behaves like a background launch -// until `enterForeground()` promotes it once a scene actually activates. -public enum LifecycleReason { case userForeground, background(LifecycleBackgroundCause), undetermined } -public struct LifecycleModeSet: OptionSet { /* .foreground, .background, .all */ } - -// One unit of launch work. `condition`/`modes` are set at construction (init / -// .work / .interactive parameters); attach UI with the .presenting modifiers. -public struct LifecycleStep: Identifiable { - public init(id: AnyHashable, modes: LifecycleModeSet = .all, - condition: @escaping @MainActor () async -> Bool = { true }, - perform: ...) - public func presenting(minVisible: Duration = .zero, _ view: ...) -> Self // always while running - public func presenting(when: ..., minVisible: Duration = .zero, _ view: ...) -> Self // only if predicate holds at start - public func presenting(after: Duration, minVisible: Duration = .zero, _ view: ...) -> Self // only if still running after delay - // minVisible (any trigger): once shown, keep the view up at least this long - - public static func work(_ id: AnyHashable, modes: ... = .all, condition: ... = …, _ perform: ...) -> LifecycleStep - public static func interactive(_ id: AnyHashable, modes: ... = .foreground, condition: ... = …, perform: ... = …, presenting: ...) -> LifecycleStep +// One unit of launch/teardown work. Input is what it needs; Output is what +// finishing proves. +@MainActor public protocol LifecycleStep { + associatedtype Input: Sendable + associatedtype Output: Sendable + associatedtype ID: Hashable & Sendable // the plan's identity domain + var id: ID { get } // a typed enum case + var modes: LifecycleModeSet { get } // defaults to .all + func run(_ input: Input, _ context: LifecycleStepContext) async throws -> Output } -@resultBuilder public enum LifecycleStepsBuilder {} // if / if-else / for -public struct LifecycleSteps { public init(@LifecycleStepsBuilder _ steps: () -> [LifecycleStep]) } - -// Bridge between a running step and its presented view. -@MainActor @Observable public final class LifecycleStepUIBridge { - public let reason: LifecycleReason - public var progress: Double? // determinate progress for the view - public var message: String? - public func complete() // UI resumes the step - public func fail(_ error: Error) // UI fails the step - public func waitForResolution() async throws +// A trunk node that parks the drive awaiting external resolution. +// Pass-through by construction; foreground-only by default. +@MainActor public protocol LifecycleGate { + associatedtype Value: Sendable + associatedtype ID: Hashable & Sendable + var id: ID { get } + var modes: LifecycleModeSet { get } // defaults to .foreground + func isNeeded(_ value: Value) async -> Bool } -public enum LifecyclePhase { - case launching, running(LifecycleStep, LifecycleStepUIBridge), failed(LifecycleFailure), ready +// The typed tree. ID is the plan's identity domain (inferred from the root +// step), Input the root step's input (Void for a launch; a real value for a +// teardown), Output the trunk's final value. +@MainActor public struct LaunchPlan { + public init(_ step: S) + where S.Input == Input, S.Output == Output, S.ID == ID + public func then(_ step: S) -> LaunchPlan + where S.Input == Output, S.ID == ID + public func thenKeeping(_ step: S) -> Self + where S.Input == Output, S.Output == Void, S.ID == ID + public func gate(_ gate: G) -> Self where G.Value == Output, G.ID == ID + public func detached(@DetachedChildrenBuilder _ children: ...) -> Self + public var nodeIDs: [ID] // introspection for tests/tools } -@MainActor @Observable public final class LifecycleRunner { - public private(set) var phase: LifecyclePhase +// The engine, generic over the launch's output. +@MainActor @Observable public final class LifecycleRunner { + public enum Phase { + case launching // splash + case running(LifecycleStepContext) // splash + caption/progress + case awaitingGate(LifecycleGateHandle) // the gate's registered view + case failed(LifecycleFailure) // terminal failure UI (no retry) + case ready(Launch) // the app, handed the launch's output + } + public private(set) var phase: Phase + public private(set) var detachedFailures: [LifecycleFailure] // off-phase diagnostics public init(reason: LifecycleReason, initializePrerequisites: @MainActor () -> Void = {}, - sequence: LifecycleSteps) - public func run() async // walk the steps; idempotent - public func retry() // re-run from the failed step - public func enterForeground() async // promote a background/undetermined launch - public func teardown(_ sequence: LifecycleSteps) async // reverse flow → relaunch + plan: LaunchPlan) + public func run() async // walk the plan; idempotent + public func enterForeground() async // promote a background/undetermined launch + public func teardown(_ plan: LaunchPlan, + input: In) async } -``` - -Steps are built with the `LifecycleStep.work` / `LifecycleStep.interactive` -factories so sequences read declaratively: - -```swift -LifecycleStep.work(_ id: AnyHashable, - modes: LifecycleModeSet = .all, - condition: @escaping @MainActor () async -> Bool = { true }, - _ perform: @escaping @MainActor (LifecycleStepUIBridge) async throws -> Void) -LifecycleStep.interactive(_ id: AnyHashable, - modes: LifecycleModeSet = .foreground, - condition: @escaping @MainActor () async -> Bool = { true }, - perform: @escaping @MainActor (LifecycleStepUIBridge) async throws -> Void = { try await $0.waitForResolution() }, - @ViewBuilder presenting: @escaping @MainActor (LifecycleStepUIBridge) -> some View) -``` - -`LifecycleStep.interactive` defaults to `modes: .foreground`: a step whose -whole job is to wait for the user would deadlock during a headless background -launch (there's no UI to resolve it), so it's skipped there. - -## Where the *final* app UI comes from -The launch sequence is only the **prerequisites**. The destination — the real, -"logged-in" / default app UI — is **not a step**; it's the `content` closure -handed to `LifecycleContainer`, rendered when (and only when) the runner reaches -`.ready`. (It can't be a step: steps complete and the cursor advances, whereas -the app UI is terminal and persists for the rest of the process lifetime.) - -```swift -LifecycleContainer(runner) { // `content` == the real app, the destination - MainTabView() // appears once the runner hits .ready +// The engine-minted token for one parked gate — the only way to resume it. +@MainActor public final class LifecycleGateHandle { + public let id: AnyHashable + public func complete() + public func fail(_ error: Error) } ``` -`LifecycleContainer` renders from `runner.phase`: - -| phase | renders | -|-------|---------| -| `.launching` / a silent step | `splash()` (defaults to `LifecycleSplash`) | -| `.running` with a presentation | that step's view (onboarding, migration) | -| `.failed` | `failure(_:retry:)` (defaults to `LifecycleFailureView`) | -| `.ready` | `content()` — the destination UI | +`LifecycleReason` (`.userForeground` / `.background(cause)` / `.undetermined`) +and `LifecycleModeSet` gate which nodes run: `.undetermined` is the honest +state under the UIScene lifecycle, where `applicationState` can't tell a user +launch from a headless wake — it behaves like a background launch until +`enterForeground()` promotes it once a scene actually activates. -Surfaces crossfade by default (see `transition`/`animation` below). - -The `splash` and `failure` views are caller-injectable; the convenience -initializers above default them to the built-ins. +## Usage -Surface changes (splash → failure → app `content`) are animated. The designated -initializer takes `transition`/`animation` (a crossfade by default; pass -`animation: nil` to swap instantly): +Model each step as a type; assemble the plan; build the runner early (e.g. in +the app delegate, so a headless background launch works before any window +exists) and drive it: ```swift -LifecycleContainer(runner, transition: .opacity, animation: .easeInOut) { - MainTabView() +struct OpenStoreStep: LifecycleStep { + let deps: Dependencies + let id = StepID.openStore + func run(_: Void, _: LifecycleStepContext) async throws -> Services { + try await deps.openStore() // the process's ONE store open + } } -``` - -The transition is keyed on `LifecyclePhase.surfaceIdentity`, which collapses -`.launching` and `.running` into one "splash" surface so a step *advancing* — -still showing the splash — doesn't retrigger the transition and flash it; only -reaching `.failed`/`.ready` animates. - -The launch surfaces (splash / step presentation / failure) are layered **above** -`content`. When the runner reaches `.ready` the leaving splash plays its -*removal* transition over the entering destination — so a reveal that scales the -splash up and fades it out uncovers the app UI beneath, rather than being -clipped to a pop behind freshly-inserted content: - -```swift -LifecycleContainer( - runner, - transition: .asymmetric(insertion: .identity, - removal: .scale(scale: 16).combined(with: .opacity)), - animation: .easeIn(duration: 0.55), - splash: { LaunchSplashView() }, -) { MainTabView() } -``` - -A fast launch can finish before the splash is ever seen (an optimized build may -reach `.ready` in a few frames), so its reveal flashes past. Pass -`minimumSplashDuration` to hold the splash up for at least that long once it -first appears, then play the reveal — it defaults to `.zero` (reveal as soon as -the runner is ready). The hold is per-appearance, so a reset relaunch (or the -return from an onboarding step) gets its own minimum: - -```swift -LifecycleContainer(runner, minimumSplashDuration: .seconds(1)) { MainTabView() } -``` -The container also publishes the runner into the environment as -`\.lifecycleRunner`, a `LifecycleRunnerProxy` (not a bare optional), letting -nested views reach `retry()`/`teardown()` without prop-drilling. When no -container is above (previews, isolated tests) the proxy is *disconnected* and -each call asserts in debug / no-ops in release, so call sites never `guard`: - -```swift -struct ResetButton: View { - @Environment(\.lifecycleRunner) private var runner - var body: some View { - Button("Erase & reset", role: .destructive) { - Task { await runner.teardown(teardownSteps) } - } +struct StartSessionStep: LifecycleStep { + let id = StepID.startSession + func run(_ services: Services, _: LifecycleStepContext) async throws -> Session { + Session(services: services) // scope grows by embedding } } -``` -For a launch that shows no window (`reason.buildsNoViewTree` — a **background** -relaunch, or an **undetermined** one not yet promoted) it renders `EmptyView()` -always — even at `.ready` — so `content()` (the heavy view tree) is never built. - -## Usage +struct OnboardingGate: LifecycleGate { + let deps: Dependencies + let id = StepID.onboarding + func isNeeded(_: Session) async -> Bool { !deps.hasOnboarded } +} -Build the runner early (e.g. in the app delegate, so a headless background -launch works before any window exists) and drive it: +let plan = LaunchPlan(OpenStoreStep(deps: deps)) + .then(StartSessionStep()) + .gate(OnboardingGate(deps: deps)) + .thenKeeping(SyncAuthStep()) // required, ordered, Void-output + .detached { // concurrent; never blocks .ready + RemindersStep() + WidgetSnapshotStep() + } -```swift -// App delegate / launch site: let runner = LifecycleRunner( - // Under the UIScene lifecycle `applicationState` reads `.background` even - // for a user tap, so don't guess a cause here: launch `.undetermined` and - // let `enterForeground()` promote it when a scene actually activates. reason: .undetermined, - initializePrerequisites: { deps.installLocationManager() }, // synchronous, must-exist-now wiring - sequence: LifecycleSteps { - LifecycleStep.work("open-store") { _ in try await deps.openStore() } - // Migration UI keyed off slowness: shown only if the open is still - // running after a beat, then held for a readable minimum. - .presenting(after: .milliseconds(500), minVisible: .seconds(1)) { - MigrationProgressView(bridge: $0) - } - - LifecycleStep.interactive("onboarding", condition: { !deps.hasOnboarded }) { - OnboardingView(bridge: $0) - } - - LifecycleStep.work("sync-auth") { _ in await deps.syncAuthorization() } - LifecycleStep.work("reconcile-tracking") { _ in await deps.reconcileTracking() } - LifecycleStep.work("load") { _ in await deps.refresh() } - }, + initializePrerequisites: { deps.installLocationManager() }, // sync, must-exist-now + plan: plan, ) Task { await runner.run() } ``` +What the compiler now refuses: + ```swift -// Root view: gate the real app behind the runner. -struct RootView: View { - @Environment(\.scenePhase) private var scenePhase - let runner: LifecycleRunner - - var body: some View { - LifecycleContainer(runner) { MainTabView() } - // run() is idempotent; promote a background launch only once the - // scene is genuinely active, so a background-connected scene stays - // headless. - .task { - await runner.run() - if scenePhase == .active { await runner.enterForeground() } - } - .onChange(of: scenePhase) { _, phase in - guard phase == .active else { return } - Task { await runner.enterForeground() } - } - } -} +LaunchPlan(StartSessionStep()) // ✗ needs Services; nothing produced it yet +plan.detached { StartSessionStep() } // ✗ detached children must be Void-output +LaunchPlan(OpenStoreStep(deps: deps)) + .gate(OnboardingGate(deps: deps)) // ✗ the gate's Value is Session, not Services ``` +Rendering — the phase-to-surface mapping, gate-view registration, and the +`\.lifecycle` environment proxy — lives in +[LifecycleKitUI](../LifecycleKitUI/README.md). + ### Reset / teardown -Run a reverse sequence and relaunch from the top — e.g. a logout/erase that -returns the app to first-run onboarding once teardown clears the "has onboarded" -flag: +A teardown plan roots at a real value (the thing being torn down), runs its +nodes, then relaunches from the top as a fresh attempt — e.g. a logout/erase +that returns the app to first-run onboarding once teardown clears the "has +onboarded" flag: ```swift -Button("Erase all data & reset", role: .destructive) { - Task { - await runner.teardown(LifecycleSteps { - LifecycleStep.work("erase") { _ in try await deps.eraseAll() } - LifecycleStep.work("forget") { _ in deps.resetPreferences() } - }) - } -} +await runner.teardown( + LaunchPlan(EraseDataStep(deps: deps)) // Session → Void + .then(ResetPreferencesStep(deps: deps)), + input: session, +) ``` -If a teardown step throws, the runner parks in `.failed` and does **not** -relaunch; `retry()` resumes the teardown from the failed step, then relaunches. - -## Two correctness points designed in deliberately - -- **Synchronous `initializePrerequisites` vs. async steps.** - `initializePrerequisites` runs synchronously at `init` for cheap, - must-exist-now wiring (e.g. installing a `CLLocationManager` delegate a queued - background event can't wait for). Everything expensive — including opening a - store that may run a slow migration — belongs in an async step, so it never - blocks `didFinishLaunching` (and the system watchdog). - -- **Windowless launches build no UI.** iOS connects a background scene without - displaying it and reclaims such apps first under memory pressure, so a launch - with no window should build *no* view tree. `LifecycleContainer` enforces this - (`EmptyView()` whenever `reason.buildsNoViewTree` — a `.background` relaunch or - an `.undetermined` one not yet promoted); the work still runs because it's - driven from the launch site (`Task { await runner.run() }`), independent of - whether SwiftUI ever builds the hierarchy. When a window genuinely appears, - `enterForeground()` promotes the runner and re-drives so foreground-only - steps (onboarding) now run — skipping any step that already completed during - the windowless drive, so a work step never runs twice. - -All drives (`run` / `enterForeground` / `retry` / `teardown`) are serialized -through a single internal task, so two never overlap (which would let, e.g., a -store-open step run twice concurrently). A new drive **cancels** the in-flight -one and awaits it draining before starting: a parked interactive step's -`waitForResolution()` throws `CancellationError`, which the engine treats as -"drive cancelled" (stop quietly), distinct from a step throwing (→ `.failed`). -That's what lets `teardown()` / `enterForeground()` interrupt a launch parked on -onboarding instead of hanging forever behind it. +If a teardown node throws, the runner parks in the terminal `.failed` and +does **not** relaunch — a thrown erase never reaches the session drop, so +state stays intact and relaunching the app returns to the working app rather +than a half-erased one. A teardown's detached children drain *before* the +relaunch, so no torn-down-world work overlaps the fresh launch. + +Teardown starts from an empty run-once set (the launch attempt is over), so a +teardown plan may freely reuse launch node IDs — there is no retry re-walk +that would consult a live launch memo, so no cross-plan disjointness +precondition is needed. + +## Correctness points designed in deliberately + +- **Failure is terminal.** A thrown node parks `.failed` with no retry — the + recovery is relaunching the app. (Retry's original customer, a fresh + install's transient store-open race, was fixed structurally by injection; + genuinely retryable work belongs to the layer that understands it.) +- **Promotion re-walks with the memo** skipping completed nodes, so completed + work never runs twice within an attempt (the memo exists only for + promotion — a fresh launch never re-walks a node). A fresh attempt (first + `run()`, the start of a teardown, the relaunch after it) clears the memo. +- **Skipping can't corrupt the data flow.** Only pass-through positions + (`thenKeeping`, gates, detached children) may be mode-gated or + conditional; a skipped gate is *not* memoized, so `isNeeded` re-evaluates + when the launch is promoted (a cold `.undetermined` start still onboards + once it becomes user-visible). +- **`.ready` never waits for the fan, and the fan can't regress it.** + `.ready(Launch)` publishes the moment the trunk finishes; detached children + drain behind it and report failures only on `detachedFailures`. +- **Drives never overlap.** All drives (`run` / `enterForeground` / + `teardown`) serialize through a single internal task; a new drive cancels + the in-flight one and awaits it draining first. A parked gate's wait throws + `CancellationError` on cancellation — "drive cancelled" (stop quietly) is + distinct from a node throwing (→ `.failed`) — which is what lets + `teardown()` / `enterForeground()` interrupt a launch parked on onboarding + instead of hanging forever behind it. A superseded drive that throws a + *real* error reports cancelled rather than clobbering the phase the new + drive owns, and a superseded drive's gate handle resolves to a no-op. +- **Synchronous `initializePrerequisites` vs. async steps.** It runs + synchronously at `init` for cheap, must-exist-now wiring (e.g. installing a + `CLLocationManager` delegate a queued background event can't wait for). + Everything expensive — including opening a store that may run a slow + migration — belongs in an async step, so it never blocks + `didFinishLaunching` (and the system watchdog). ## Testing -The engine and views are exercised with Swift Testing + a hosted UI test host. -What's worth covering when adopting it: step ordering, `condition` gating, mode -filtering (background skips foreground-only), thrown error → `.failed` + -`retry()` resuming from the failed step, interactive suspension until -`bridge.complete()`, progress propagation, and `teardown()` returning to -`.launching`. Because a real interactive step suspends, drive it from a `Task` -and poll `runner.phase` until it parks, then resolve the bridge. +The engine is exercised with Swift Testing: targeted suites for ordering, +value threading, mode gating, gates, detached isolation, promotion (memo +run-once), terminal failure, and teardown (including reusing launch node +IDs) — plus a seeded fuzz suite that drives randomized plans against an +independent model (200 seeds). Because a real gate suspends, drive +the runner from a `Task` and poll `runner.phase` until it parks, then resolve +the handle. diff --git a/Shared/LifecycleKit/Sources/LaunchPlan.swift b/Shared/LifecycleKit/Sources/LaunchPlan.swift new file mode 100644 index 00000000..ddee4b72 --- /dev/null +++ b/Shared/LifecycleKit/Sources/LaunchPlan.swift @@ -0,0 +1,292 @@ +/// The typed launch (or teardown) tree: a sequential *trunk* of required +/// steps plus concurrent *detached* fan-outs. +/// +/// `Input` is what the plan's root step needs (`Void` for a launch; a +/// teardown plan roots at a real value, e.g. the session being torn down). +/// `Output` is the trunk value after the last node — the proof the whole +/// launch is done, carried into `LifecycleRunner.Phase.ready`. `ID` is the +/// plan's identity domain, inferred from its root step — a typed enum per +/// plan (Where uses `LaunchStepID`) rather than a bare `String`. +/// +/// The combinators make invalid plans unrepresentable: +/// - `then` requires the next step's `Input` to equal the current trunk +/// `Output` — a step cannot be placed before its input exists. +/// - every combinator requires the node's `ID` to equal the plan's, so one +/// plan can't mix identity domains and a node keyed for another plan can't +/// be composed in. +/// - `thenKeeping` and `gate` are pass-through (`Void`-output step / no +/// transformation), so they are the only trunk positions that may be +/// mode-gated or conditional: skipping them cannot leave a hole in the +/// data flow. +/// - `detached` children take the trunk value and return `Void`, so nothing +/// downstream can depend on a fire-and-forget step. +/// +/// Types are erased *inside* the plan (one place, `LaunchPlanNode`), so the +/// runner can memoize heterogeneous outputs and re-walk the trunk on a +/// promotion; the combinators' constraints guarantee every internal cast. +@MainActor +public struct LaunchPlan { + /// The erased node list the runner walks. Order is trunk order; detached + /// groups occupy one position and fan out from there. + package private(set) var nodes: [LaunchPlanNode] + + /// The plan's node IDs in declaration order (detached children at their + /// group's position) — introspection for tests and tooling. + /// + /// Recovers `ID` from the erased nodes; every node was appended by a + /// combinator constrained to `S.ID == ID`, so the cast can't fail (the + /// same guarantee the trunk-value casts rely on). + public var nodeIDs: [ID] { + nodes.flatMap(\.ids).map { $0.base as! ID } + } + + private init(nodes: [LaunchPlanNode]) { + self.nodes = nodes + } + + /// Root the plan at `step`. The plan's `ID`/`Input`/`Output` are inferred + /// from the step, so a launch plan starts `LaunchPlan(OpenStoreStep(...))` + /// and a teardown plan roots at the value it consumes. + public init(_ step: S) + where S.Input == Input, S.Output == Output, S.ID == ID + { + self.init(nodes: []) + append(.step(StepNode(producing: step))) + } + + /// Run `step` after the trunk so far, its `Input` fed by the current + /// trunk `Output`; the trunk value becomes the step's output. Required + /// semantics: a throw fails the drive terminally — the recovery for a + /// failed launch is relaunching the app, not resuming here. + public func then(_ step: S) -> LaunchPlan + where S.Input == Output, S.ID == ID + { + LaunchPlan(nodes: nodes) + .appending(.step(StepNode(producing: step))) + } + + /// Run a required `Void`-output step and keep the trunk value flowing + /// past it. Because the value is untouched, this is the one trunk *step* + /// position that may be mode-gated (`modes`) — a skipped step here can't + /// break the data flow. + public func thenKeeping(_ step: S) -> LaunchPlan + where S.Input == Output, S.Output == Void, S.ID == ID + { + appending(.step(StepNode(keeping: step))) + } + + /// Park the trunk at `gate` when it applies (`modes`) and is needed + /// (`isNeeded`), awaiting external resolution through a + /// `LifecycleGateHandle`. Pass-through by construction — see + /// `LifecycleGate`. + public func gate(_ gate: G) -> LaunchPlan + where G.Value == Output, G.ID == ID + { + appending(.gate(GateNode(erasing: gate))) + } + + /// Fan `children` out concurrently from this trunk position. Children + /// take the trunk value, return nothing, and cannot present or fail the + /// drive: a failure surfaces on `LifecycleRunner.detachedFailures` (and + /// never blocks `.ready`). The trunk continues immediately with its value + /// unchanged. + public func detached( + @DetachedChildrenBuilder _ children: @MainActor () + -> [DetachedChild], + ) -> LaunchPlan { + appending(.detached(children().map(\.node))) + } + + private func appending(_ node: LaunchPlanNode) -> Self { + var copy = self + copy.append(node) + return copy + } + + /// Node IDs must be unique within a plan: run-once memoization and + /// gate-view registration both key on them, so a duplicate would make + /// those ambiguous. A duplicate is a programmer + /// error — fail fast at plan construction. + private mutating func append(_ node: LaunchPlanNode) { + var seen = Set(nodes.flatMap(\.ids)) + for id in node.ids { + precondition( + seen.insert(id).inserted, + "LaunchPlan contains duplicate node ID: \(id)", + ) + } + nodes.append(node) + } +} + +/// A fire-and-forget child of a `LaunchPlan.detached` group, erased over the +/// step that backs it. Constructed only from steps whose `Input` is the trunk +/// value and whose `Output` is `Void`, so a detached step that produces a +/// value anyone could depend on is unspellable. +@MainActor +public struct DetachedChild { + let node: DetachedNode + + public init(_ step: S) + where S.Input == Value, S.Output == Void, S.ID == ID + { + node = DetachedNode( + id: step.id, + modes: step.modes, + run: { @Sendable value, context in + try await step.run(value as! S.Input, context) + }, + ) + } +} + +/// Result builder for `LaunchPlan.detached`, with `if`/`if-else`/`for` +/// support so children can be included conditionally. Bare steps are lifted +/// into `DetachedChild`, which is where the `Input == Value, Output == Void` +/// constraints live. +@resultBuilder +@MainActor +public enum DetachedChildrenBuilder { + public static func buildExpression(_ step: S) -> [DetachedChild] + where S.Input == Value, S.Output == Void, S.ID == ID + { + [DetachedChild(step)] + } + + public static func buildExpression(_ child: DetachedChild) + -> [DetachedChild] + { + [child] + } + + public static func buildBlock(_ children: [DetachedChild]...) + -> [DetachedChild] + { + children.flatMap(\.self) + } + + public static func buildOptional(_ children: [DetachedChild]?) + -> [DetachedChild] + { + children ?? [] + } + + public static func buildEither(first children: [DetachedChild]) + -> [DetachedChild] + { + children + } + + public static func buildEither(second children: [DetachedChild]) + -> [DetachedChild] + { + children + } + + public static func buildArray(_ children: [[DetachedChild]]) + -> [DetachedChild] + { + children.flatMap(\.self) + } + + public static func buildLimitedAvailability( + _ children: [DetachedChild], + ) -> [DetachedChild] { + children + } +} + +// MARK: - Erased nodes + +/// One erased position on a plan's trunk. This is the single place the typed +/// step/gate generics are erased; the `LaunchPlan` combinators' constraints +/// guarantee every `as!` inside the erased closures succeeds, so the runner +/// never sees a cast fail and the public API never shows `Any`. +package enum LaunchPlanNode { + case step(StepNode) + case gate(GateNode) + case detached([DetachedNode]) + + /// Every ID this node contributes to the plan's uniqueness domain. + var ids: [AnyHashable] { + switch self { + case let .step(node): [node.id] + case let .gate(node): [node.id] + case let .detached(children): children.map(\.id) + } + } +} + +/// A required trunk step, erased. `run` returns the trunk value after the +/// node: the step's output for a value-producing step, the untouched input +/// for a `thenKeeping` step. +package struct StepNode { + package let id: AnyHashable + package let modes: LifecycleModeSet + package let run: @Sendable @MainActor ( + any Sendable, + LifecycleStepContext, + ) async throws -> any Sendable + + /// Erase a value-producing (`init`/`then`) step. Such a step can never be + /// skipped — a skip would leave a hole in the data flow — so it must not + /// narrow `modes`; a narrowed one is a misconfiguration, caught here at + /// plan construction. + @MainActor + init(producing step: S) { + precondition( + step.modes == .all, + """ + Step '\(step.id)' produces a value, so it must run under all modes \ + (modes: .all); only pass-through positions (thenKeeping, gates, \ + detached children) may gate on the launch reason. + """, + ) + id = step.id + modes = step.modes + run = { @Sendable value, context in + try await step.run(value as! S.Input, context) + } + } + + /// Erase a pass-through (`thenKeeping`) step: the step's `Void` output is + /// discarded and the input keeps flowing. + @MainActor + init(keeping step: S) where S.Output == Void { + id = step.id + modes = step.modes + run = { @Sendable value, context in + try await step.run(value as! S.Input, context) + return value + } + } +} + +/// A gate on the trunk, erased. The runner evaluates `isNeeded` with the +/// current trunk value and, when true, parks awaiting a `LifecycleGateHandle` +/// resolution; `gateType` lets the UI layer's registry recover the gate's +/// concrete type (and with it `Value`) statically. +package struct GateNode { + package let id: AnyHashable + package let modes: LifecycleModeSet + package let gateType: ObjectIdentifier + package let isNeeded: @Sendable @MainActor (any Sendable) async -> Bool + + @MainActor + init(erasing gate: G) { + id = gate.id + modes = gate.modes + gateType = ObjectIdentifier(G.self) + isNeeded = { @Sendable value in + await gate.isNeeded(value as! G.Value) + } + } +} + +/// A detached child, erased. Runs concurrently with the trunk; its failure is +/// recorded, never fatal. +package struct DetachedNode { + package let id: AnyHashable + package let modes: LifecycleModeSet + package let run: @Sendable @MainActor (any Sendable, LifecycleStepContext) async throws -> Void +} diff --git a/Shared/LifecycleKit/Sources/LifecycleContainer.swift b/Shared/LifecycleKit/Sources/LifecycleContainer.swift deleted file mode 100644 index 4c2c7d4b..00000000 --- a/Shared/LifecycleKit/Sources/LifecycleContainer.swift +++ /dev/null @@ -1,309 +0,0 @@ -import SwiftUI - -extension EnvironmentValues { - /// A handle to the running `LifecycleRunner`, published by - /// `LifecycleContainer` so nested views (a custom failure view, a Settings - /// "reset" button) can reach `retry()`/`teardown()` without prop-drilling. - /// - /// It's a `LifecycleRunnerProxy` rather than a bare `LifecycleRunner?` so a - /// view that reads it can just *call* the runner: when no container is above - /// (previews, isolated tests) the proxy is disconnected and every call - /// asserts in debug — surfacing the missing container — and no-ops in - /// release, instead of each call site silently `guard`ing the optional away. - @Entry public var lifecycleRunner = LifecycleRunnerProxy() -} - -/// A debug-loud, release-quiet handle to the environment's `LifecycleRunner`. -/// -/// `LifecycleContainer` publishes a *connected* proxy; the environment default -/// is *disconnected* (no runner). Calling through a disconnected proxy -/// `assertionFailure`s in debug (so a view used outside a container is caught in -/// development) and no-ops in release (so a stray reset/retry tap can't crash a -/// shipping build). Views therefore drive the runner without `guard`ing — the -/// "is there a runner?" decision lives here, once. -/// -/// The wrapped `LifecycleRunner` is `@MainActor`; every forwarding entry point -/// is annotated `@MainActor` accordingly. The struct itself stays `Sendable` so -/// it can be the `@Entry` default — callers must invoke it from the main actor -/// (SwiftUI views already do). -public struct LifecycleRunnerProxy: Sendable { - let base: LifecycleRunner? - - /// A disconnected proxy (no runner): the environment default, and what - /// previews get so reset/retry quietly do nothing. - public init() { - base = nil - } - - init(_ runner: LifecycleRunner) { - base = runner - } - - /// Resume a failed launch from the step that failed. - /// See `LifecycleRunner.retry()`. - @MainActor public func retry(file: StaticString = #fileID, line: UInt = #line) { - connected(file: file, line: line)?.retry() - } - - /// Run a teardown `sequence`, then relaunch from the top. - /// See `LifecycleRunner.teardown(_:)`. - @MainActor public func teardown( - _ sequence: LifecycleSteps, - file: StaticString = #fileID, - line: UInt = #line, - ) async { - await connected(file: file, line: line)?.teardown(sequence) - } - - /// Promote a headless background launch to the foreground. - /// See `LifecycleRunner.enterForeground()`. - @MainActor public func enterForeground(file: StaticString = #fileID, line: UInt = #line) async { - await connected(file: file, line: line)?.enterForeground() - } - - /// The wrapped runner, or nil with a debug assertion pointing at the caller — - /// so a disconnected proxy is loud in development and a silent no-op in - /// production. - private func connected(file: StaticString, line: UInt) -> LifecycleRunner? { - if base == nil { - assertionFailure( - "No LifecycleRunner in the environment — is this view inside a LifecycleContainer?", - file: file, - line: line, - ) - } - return base - } -} - -/// The root view that renders a `LifecycleRunner`'s `phase`. -/// -/// The launch sequence is only the *prerequisites*; the destination — the -/// app's real, "logged-in" UI — is the `content` closure, shown when the -/// runner reaches `.ready`. Pass an already-built runner (created early, -/// e.g. in the app delegate, so a headless background launch works without a -/// window). -/// -/// The `splash` and `failure` views are caller-injectable; convenience -/// initializers default them to the built-in `LifecycleSplash` / -/// `LifecycleFailureView`. The runner is published into the environment -/// (`\.lifecycleRunner`) for nested views to reach. -/// -/// Surface changes (splash → failure → app `content`) are animated with the -/// caller-supplied `transition`/`animation` (a crossfade by default), keyed on -/// `LifecyclePhase.surfaceIdentity` so a step *advancing* — which keeps showing -/// the splash — doesn't retrigger the transition and flash it. The launch -/// surfaces are layered above `content`, so a *leaving* splash plays its -/// removal transition over the *entering* destination (a scale-up-and-fade -/// reveal, say) instead of being clipped to a pop behind it. -/// -/// For a launch that builds no view tree (`.background`, or an `.undetermined` -/// one not yet promoted), the container renders nothing at all (iOS never shows -/// UI for a headless relaunch and reclaims memory aggressively), so `content` -/// is never constructed even once the runner reaches `.ready`. -public struct LifecycleContainer: View { - private let runner: LifecycleRunner - private let transition: AnyTransition - private let animation: Animation? - private let minimumSplashDuration: Duration - private let splash: () -> Splash - private let failureView: (LifecycleFailure, @escaping () -> Void) -> Failure - private let content: () -> Content - - /// When the splash surface first became visible this launch, and whether - /// `minimumSplashDuration` has since elapsed. Together they gate holding the - /// `.ready` reveal until the splash has shown for its minimum. - @State private var splashAppearedAt: ContinuousClock.Instant? - @State private var minimumSplashElapsed = false - - /// - Parameters: - /// - transition: how each surface enters/leaves. Defaults to a crossfade. - /// - animation: the animation driving `transition`. Pass `nil` to swap - /// surfaces instantly (no animation). - /// - minimumSplashDuration: the least time the splash stays up before the - /// `.ready` reveal, so a very fast launch still shows the splash (and - /// its reveal) rather than flashing past. `.zero` (the default) reveals - /// as soon as the runner is ready. - public init( - _ runner: LifecycleRunner, - transition: AnyTransition = .opacity, - animation: Animation? = .default, - minimumSplashDuration: Duration = .zero, - @ViewBuilder splash: @escaping () -> Splash, - @ViewBuilder failure: @escaping (LifecycleFailure, @escaping () -> Void) -> Failure, - @ViewBuilder content: @escaping () -> Content, - ) { - self.runner = runner - self.transition = transition - self.animation = animation - self.minimumSplashDuration = minimumSplashDuration - self.splash = splash - failureView = failure - self.content = content - } - - public var body: some View { - Group { - if runner.reason.buildsNoViewTree { - EmptyView() - } else { - phaseContent - } - } - .environment(\.lifecycleRunner, LifecycleRunnerProxy(runner)) - .animation(animation, value: displayedSurfaceIdentity) - // Record when the splash first shows so the reveal can be held for at - // least `minimumSplashDuration`. Resets each time the splash reappears - // (a reset relaunch, or the return from onboarding) so every episode - // gets its own minimum. - .onChange(of: isShowingSplash, initial: true) { _, showing in - guard showing else { return } - splashAppearedAt = ContinuousClock.now - minimumSplashElapsed = false - } - // Once the runner is ready, hold the splash for the remainder of its - // minimum (if any), then release the reveal. - .task(id: isReadyPhase) { - guard isReadyPhase, - minimumSplashDuration > .zero, - !minimumSplashElapsed, - let appearedAt = splashAppearedAt - else { return } - let remaining = minimumSplashDuration - appearedAt.duration(to: ContinuousClock.now) - if remaining > .zero { - try? await Task.sleep(for: remaining) - guard !Task.isCancelled else { return } - } - // Drive the reveal in an explicit transaction: `.animation(_:value:)` - // doesn't reliably animate this async, `.task`-driven flip, so the - // splash would be removed without its reveal transition. - withAnimation(animation) { minimumSplashElapsed = true } - } - } - - /// Whether the splash is actually on screen right now: a launch that builds a - /// view tree, parked on `.launching` or a `.running` step not showing its own - /// presentation. - private var isShowingSplash: Bool { - guard !runner.reason.buildsNoViewTree else { return false } - switch runner.phase { - case .launching: return true - case let .running(_, bridge): return bridge.presentation == nil - case .failed, .ready: return false - } - } - - private var isReadyPhase: Bool { - if case .ready = runner.phase { return true } - return false - } - - /// Whether the app content may be revealed: no minimum was requested, no - /// splash was ever shown, or the minimum has now elapsed. - private var canRevealReady: Bool { - minimumSplashDuration <= .zero || splashAppearedAt == nil || minimumSplashElapsed - } - - /// The surface actually on screen, for `.animation(_:value:)`. While the - /// `.ready` reveal is held behind `minimumSplashDuration` this stays - /// `.splash`, so the reveal transition fires when the hold releases — not the - /// instant the runner reports `.ready`. - private var displayedSurfaceIdentity: LifecyclePhase.SurfaceIdentity { - if isReadyPhase, !canRevealReady { - return .splash - } - return runner.phase.surfaceIdentity - } - - /// Launch surfaces (splash / step presentation / failure) sit above the - /// app `content` so that when the runner reaches `.ready`, a *leaving* splash - /// animates on top of the *entering* content — letting a removal transition - /// (e.g. the Where launch splash scaling up and fading to reveal the UI) play - /// over the destination instead of being hidden behind freshly-inserted - /// content. With equal z-indices SwiftUI draws the inserted view last, which - /// would clip the reveal to a plain pop. - private static var launchSurfaceZIndex: Double { - 1 - } - - private static var contentZIndex: Double { - 0 - } - - @ViewBuilder private var phaseContent: some View { - switch runner.phase { - case .launching: - splashSurface - case let .running(_, bridge): - // Show the step's active presentation if it has one, otherwise - // fall back to the splash. Reading `bridge.presentation` makes - // a deferred (`presenting(after:)`) presentation appear without - // a phase change. - if let presentation = bridge.presentation { - presentation.transition(transition).zIndex(Self.launchSurfaceZIndex) - } else { - splashSurface - } - case let .failed(failure): - failureView(failure) { runner.retry() } - .transition(transition) - .zIndex(Self.launchSurfaceZIndex) - case .ready: - // Keep the splash up until its minimum has elapsed (see - // `minimumSplashDuration`), then reveal the app content. - if canRevealReady { - content().transition(transition).zIndex(Self.contentZIndex) - } else { - splashSurface - } - } - } - - private var splashSurface: some View { - splash().transition(transition).zIndex(Self.launchSurfaceZIndex) - } -} - -extension LifecycleContainer where Splash == LifecycleSplash, Failure == LifecycleFailureView { - /// Convenience initializer using the built-in splash and failure views. - public init( - _ runner: LifecycleRunner, - @ViewBuilder content: @escaping () -> Content, - ) { - self.init( - runner, - splash: { LifecycleSplash() }, - failure: { LifecycleFailureView(failure: $0, retry: $1) }, - content: content, - ) - } -} - -extension LifecycleContainer where Failure == LifecycleFailureView { - /// Convenience initializer with a custom splash but the built-in failure - /// view. - public init( - _ runner: LifecycleRunner, - @ViewBuilder splash: @escaping () -> Splash, - @ViewBuilder content: @escaping () -> Content, - ) { - self.init( - runner, - splash: splash, - failure: { LifecycleFailureView(failure: $0, retry: $1) }, - content: content, - ) - } -} - -#if DEBUG - #Preview("Launching") { - LifecycleContainer( - LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("open") { _ in } - }), - ) { - Text("App content") - } - } -#endif diff --git a/Shared/LifecycleKit/Sources/LifecycleDriving.swift b/Shared/LifecycleKit/Sources/LifecycleDriving.swift new file mode 100644 index 00000000..1ec6c087 --- /dev/null +++ b/Shared/LifecycleKit/Sources/LifecycleDriving.swift @@ -0,0 +1,18 @@ +/// The type-erased drive surface `LifecycleKitUI`'s environment proxy talks +/// to: everything a nested view may do to a runner without knowing its +/// `Launch` type (an environment value must be non-generic). +/// +/// The erasure stays inside the package: `LifecycleProxy` erases a typed +/// `LaunchPlan` + input at the call site (where the compiler has checked +/// them) and forwards through this seam. +@MainActor +package protocol LifecycleDriving: AnyObject, Sendable { + /// See `LifecycleRunner.enterForeground()`. + func enterForeground() async + + /// `LifecycleRunner.teardown(_:input:)` with the plan pre-erased by the + /// typed caller. + func teardownErased(nodes: [LaunchPlanNode], input: any Sendable) async +} + +extension LifecycleRunner: LifecycleDriving {} diff --git a/Shared/LifecycleKit/Sources/LifecycleFailure.swift b/Shared/LifecycleKit/Sources/LifecycleFailure.swift new file mode 100644 index 00000000..c8874dda --- /dev/null +++ b/Shared/LifecycleKit/Sources/LifecycleFailure.swift @@ -0,0 +1,11 @@ +/// Carries which node failed and why, so the (terminal) failure UI can name +/// and describe it. +public struct LifecycleFailure { + public let stepID: AnyHashable + public let error: any Error + + public init(stepID: AnyHashable, error: any Error) { + self.stepID = stepID + self.error = error + } +} diff --git a/Shared/LifecycleKit/Sources/LifecycleGateHandle.swift b/Shared/LifecycleKit/Sources/LifecycleGateHandle.swift new file mode 100644 index 00000000..3dc72c0a --- /dev/null +++ b/Shared/LifecycleKit/Sources/LifecycleGateHandle.swift @@ -0,0 +1,105 @@ +/// The engine-minted token for one parked gate: the *only* way to resume (or +/// fail) a trunk waiting in `LifecycleRunner.Phase.awaitingGate`. +/// +/// The engine creates a fresh handle each time a gate parks and publishes it +/// in the phase; the gate's view calls `complete()` (or `fail(_:)`) to +/// resolve it. Because the handle is scoped to the drive that minted it, a +/// stale view resolving a superseded drive's handle is a no-op — it cannot +/// clobber the phase the superseding drive now owns. +@MainActor +public final class LifecycleGateHandle { + /// The parked gate's identity (`LifecycleGate.id`). + public let id: AnyHashable + + /// Why the app is launching, in case the gate's view wants to adapt. + public let reason: LifecycleReason + + /// The parked gate's concrete type, for the UI layer's gate-view registry. + /// Nil only for standalone preview/test handles built with the public + /// initializer. + package let gateType: ObjectIdentifier? + + /// The trunk value at the gate, erased; the registry recovers its static + /// type from `gateType`. Nil only for standalone preview/test handles. + package let value: (any Sendable)? + + /// Whether the gate has been resolved yet, and with what result. One value + /// so a resolved gate always carries its result, and a resolution before + /// anyone waits is simply buffered until `waitForResolution()` delivers it. + private enum Resolution { + case pending + case resolved(Result) + } + + private var continuation: CheckedContinuation? + private var resolution: Resolution = .pending + + /// Create a standalone handle. The engine creates one per parked gate, but + /// this is public so consumers can build and drive their gate views in + /// previews and tests without an engine. + public init(id: AnyHashable, reason: LifecycleReason) { + self.id = id + self.reason = reason + gateType = nil + value = nil + } + + package init(node: GateNode, reason: LifecycleReason, value: any Sendable) { + id = node.id + self.reason = reason + gateType = node.gateType + self.value = value + } + + /// Resume the parked trunk: the gate completed and the launch continues. + public func complete() { + resolve(.success(())) + } + + /// Fail the parked trunk by throwing `error` out of the engine's wait, + /// which parks the runner in `.failed` at this gate. + public func fail(_ error: Error) { + resolve(.failure(error)) + } + + /// Suspends until `complete()` or `fail(_:)` is called. Resolving before a + /// caller starts waiting is supported (the buffered result is delivered + /// immediately), so there is no lost-wakeup race. + /// + /// Cancelling the surrounding task resumes the wait by throwing + /// `CancellationError`, which lets the engine drain a parked gate when a + /// new drive supersedes it (rather than hanging on a tap that will never + /// come). + func waitForResolution() async throws { + try await withTaskCancellationHandler { + switch resolution { + case let .resolved(result): + try result.get() + case .pending: + try await withCheckedThrowingContinuation { continuation in + // A cancellation could land before we store the + // continuation; if so, resolution is already set, so + // deliver it here instead of parking forever. + if case let .resolved(result) = resolution { + continuation.resume(with: result) + } else { + self.continuation = continuation + } + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.resolve(.failure(CancellationError())) + } + } + } + + private func resolve(_ result: Result) { + guard case .pending = resolution else { return } + resolution = .resolved(result) + if let continuation { + self.continuation = nil + continuation.resume(with: result) + } + } +} diff --git a/Shared/LifecycleKit/Sources/LifecyclePhase.swift b/Shared/LifecycleKit/Sources/LifecyclePhase.swift deleted file mode 100644 index e52a0366..00000000 --- a/Shared/LifecycleKit/Sources/LifecyclePhase.swift +++ /dev/null @@ -1,88 +0,0 @@ -/// The single observable value the host renders. The runner publishes a -/// `LifecyclePhase`; `LifecycleContainer` maps each case to UI. -public enum LifecyclePhase { - /// Before any presenting step — show the splash. - case launching - /// A step is running. The host shows the step's presentation if one is - /// active (`LifecycleStepUIBridge.presentation`), otherwise the splash. - case running(LifecycleStep, LifecycleStepUIBridge) - /// A step threw. The host shows an error UI offering retry. - case failed(LifecycleFailure) - /// All applicable steps finished — hand off to the app's main UI. - case ready -} - -/// Carries which step failed and why, so the failure UI can describe it and -/// the runner can retry from that step. -public struct LifecycleFailure { - public let stepID: AnyHashable - public let error: any Error - - public init(stepID: AnyHashable, error: any Error) { - self.stepID = stepID - self.error = error - } -} - -extension LifecyclePhase { - public var isLaunching: Bool { - if case .launching = self { true } else { false } - } - - public var isReady: Bool { - if case .ready = self { true } else { false } - } - - /// The id of the currently running step, if any. - public var runningStepID: AnyHashable? { - if case let .running(step, _) = self { step.id } else { nil } - } - - /// The bridge of the currently running step, if any. - public var runningBridge: LifecycleStepUIBridge? { - if case let .running(_, bridge) = self { bridge } else { nil } - } - - /// The failure, if the launch failed. - public var failure: LifecycleFailure? { - if case let .failed(failure) = self { failure } else { nil } - } - - /// Whether the step with `id` is the one currently running. Handy in tests - /// that drive the runner until a particular step is active, and reads better - /// than comparing the `AnyHashable` `runningStepID` to a raw token. - public func isRunning(_ id: AnyHashable) -> Bool { - runningStepID == id - } - - /// Whether the launch failed in the step with `id`. - public func failed(at id: AnyHashable) -> Bool { - failure?.stepID == id - } -} - -extension LifecyclePhase { - /// A value identity for the *surface* `LifecycleContainer` renders, so it can - /// animate transitions between surfaces with `.animation(_:value:)` (the - /// phase itself isn't `Equatable`). - /// - /// `launching` and `running` collapse to `.splash`: a running step shows - /// either the splash or its own (possibly deferred) presentation, and that - /// swap is driven by `LifecycleStepUIBridge.presentation` within the running - /// surface — not a phase change — so steps advancing must not retrigger a - /// top-level transition and flash the splash. Reaching `.failed`/`.ready` is - /// a real surface change and animates. - enum SurfaceIdentity: Hashable { - case splash - case failed(AnyHashable) - case ready - } - - var surfaceIdentity: SurfaceIdentity { - switch self { - case .launching, .running: .splash - case let .failed(failure): .failed(failure.stepID) - case .ready: .ready - } - } -} diff --git a/Shared/LifecycleKit/Sources/LifecycleRunner.swift b/Shared/LifecycleKit/Sources/LifecycleRunner.swift index ce447bbc..a16df78d 100644 --- a/Shared/LifecycleKit/Sources/LifecycleRunner.swift +++ b/Shared/LifecycleKit/Sources/LifecycleRunner.swift @@ -1,7 +1,6 @@ import Observation -import SwiftUI -/// Drives a `LifecycleSteps` to completion, publishing a single observable +/// Drives a `LaunchPlan` to completion, publishing a single observable /// `phase` the host renders. The engine and every step run on the main actor; /// heavy work is expected to be delegated to actors from inside a step's body. /// @@ -9,28 +8,56 @@ import SwiftUI /// - The synchronous `initializePrerequisites` runs at `init`, before any /// async work — use it for the cheap, must-exist-now wiring a background /// relaunch can't wait for (e.g. installing a `CLLocationManager` delegate). -/// - `run()` walks the steps in order, skipping those whose `modes` don't -/// include the launch reason or whose async `condition` is false, awaiting -/// each remaining step's body. A thrown error parks the runner in -/// `.failed`; `retry()` resumes from the step that failed. +/// - `run()` walks the plan's trunk in order, threading each node's typed +/// output into the next node's input and skipping nodes whose `modes` +/// don't include the launch reason (only pass-through nodes may gate — see +/// `LaunchPlan`). Detached groups fan out concurrently and never block the +/// trunk. A thrown trunk step parks the runner in `.failed` — a **terminal** +/// state: there is no retry, so the recovery for a failed launch is +/// relaunching the app. /// - `enterForeground()` promotes a runner that started headless (its -/// `reason` was `.background`) once a window actually appears, re-driving the -/// sequence so the now-applicable foreground-only steps (onboarding, etc.) -/// run. +/// `reason` was `.background` or `.undetermined`) once a window actually +/// appears, re-driving the plan so the now-applicable foreground-only +/// nodes (gates, foreground work) run. (Promotion is the *only* reason the +/// memo exists — a fresh launch never re-walks a node.) /// /// Drives never overlap. The internal `State` folds the launch reason, the /// "has run" flag, and the in-flight drive task into one value so invalid /// combinations are unrepresentable; `reason` and `phase` are its public -/// projections. A new drive (`run`/`retry`/`enterForeground`/`teardown`) cancels -/// the in-flight one and awaits it draining before starting — cooperative -/// cancellation (a parked `waitForResolution()` throws `CancellationError`) -/// keeps that drain from hanging behind an interactive step waiting on a tap -/// that will never come. +/// projections. A new drive (`run`/`enterForeground`/`teardown`) cancels the +/// in-flight one and awaits it draining before starting — cooperative +/// cancellation (a parked gate's `waitForResolution()` throws +/// `CancellationError`) keeps that drain from hanging behind a gate waiting +/// on a tap that will never come. @MainActor @Observable -public final class LifecycleRunner { +public final class LifecycleRunner { + /// The single value the host renders. Each case carries exactly what its + /// surface needs — and `.ready` carries the trunk's final value, so the + /// app surface cannot be rendered without the proof the launch produced. + public enum Phase { + /// Before (or between) trunk nodes — show the splash. + case launching + /// A trunk step is running; its context feeds splash caption/progress. + case running(LifecycleStepContext) + /// A gate parked the trunk; the UI resolves the handle to continue. + case awaitingGate(LifecycleGateHandle) + /// A trunk node threw. Terminal — the host shows an error surface (no + /// retry); the recovery is relaunching the app. + case failed(LifecycleFailure) + /// The trunk finished — hand its output to the app's main UI. + /// Detached children may still be draining; they can't regress this. + case ready(Launch) + } + /// The single value the host renders. - public private(set) var phase: LifecyclePhase = .launching + public private(set) var phase: Phase = .launching + + /// Failures from detached (fire-and-forget) children. Deliberately *off* + /// the phase: a detached failure is observable here (and worth logging by + /// the consumer) but never fails the drive or blocks `.ready`. Reset when + /// a fresh attempt begins, like the memoized outputs. + public private(set) var detachedFailures: [LifecycleFailure] = [] /// The runner's drive lifecycle. One value so e.g. "not started yet" can't /// also hold a drive task, and the launch reason always travels with it. @@ -54,43 +81,36 @@ public final class LifecycleRunner { private var state: State - /// Why the app launched this time. A not-yet-foreground launch (`.background` - /// or `.undetermined`) can be promoted to a foreground one via - /// `enterForeground()`; the container observes this to stop rendering - /// `EmptyView()` and start building real UI. + /// Why the app launched this time. A not-yet-foreground launch + /// (`.background` or `.undetermined`) can be promoted to a foreground one + /// via `enterForeground()`; the container observes this to stop rendering + /// nothing and start building real UI. public var reason: LifecycleReason { state.reason } - @ObservationIgnored private let steps: [LifecycleStep] - /// The most recent teardown sequence handed to `teardown(_:)`, retained so a - /// `retry()` after a thrown teardown step resumes the teardown (and the - /// relaunch that follows) rather than re-driving the launch sequence over - /// un-torn-down state. Empty until the first `teardown(_:)`. - @ObservationIgnored private var teardownSteps: [LifecycleStep] = [] - - /// Steps that have already finished during the current launch attempt, so a - /// re-drive doesn't repeat completed work. It's what lets `enterForeground()` - /// promote a headless/undetermined launch — which re-drives from the top so - /// the now-applicable foreground-only steps run — without running a work - /// step that already serviced the launch a second time. + @ObservationIgnored private let launchNodes: [LaunchPlanNode] + + /// The output of every node that ran to completion during the current + /// attempt, keyed by node ID — the run-once set that lets an + /// `enterForeground()` promotion's re-walk skip completed work and thread + /// the recorded value into the next node. /// - /// Only steps that actually *ran to completion* are recorded; a step skipped - /// by mode/condition gating (e.g. a foreground-only step during a background - /// drive) or one that was cancelled mid-flight is not, so it still runs (and - /// its condition still re-evaluates) once the launch is promoted. Reset when - /// a *fresh* attempt begins (first `run()`, and the relaunch after a - /// teardown) so a reset genuinely re-runs everything; preserved across - /// `enterForeground()` and `retry()`, which continue the same attempt. - @ObservationIgnored private var completedStepIDs: Set = [] + /// Only nodes that actually *ran to completion* are recorded; a node + /// skipped by mode gating or a gate whose `isNeeded` was false is not, so + /// it re-evaluates once the launch is promoted. Reset when a *fresh* + /// attempt begins (first `run()`, the start of a teardown, and the + /// relaunch after it), so each fresh walk starts from an empty set — + /// which is why a teardown plan may freely reuse launch node IDs. + @ObservationIgnored private var memo: [AnyHashable: any Sendable] = [:] public init( reason: LifecycleReason, initializePrerequisites: @MainActor () -> Void = {}, - sequence: LifecycleSteps, + plan: LaunchPlan, ) { state = .notStarted(reason) - steps = sequence.steps + launchNodes = plan.nodes initializePrerequisites() } @@ -100,70 +120,61 @@ public final class LifecycleRunner { if case let .running(_, task) = state { task } else { nil } } - /// Walk the sequence once. Safe to call repeatedly; only the first call - /// drives the steps, and later callers await that drive instead of - /// starting a second one. + /// Walk the plan once. Safe to call repeatedly; only the first call + /// drives the nodes, and later callers await that drive instead of + /// starting a second one. Returns once the trunk *and* its detached + /// children have drained (`.ready` is published as soon as the trunk + /// finishes, before the children do). public func run() async { switch state { case let .notStarted(reason): // A fresh attempt starts with a clean slate; nothing has run yet. - completedStepIDs.removeAll() - await drive(reason: reason, from: 0) + memo.removeAll() + detachedFailures.removeAll() + await drive(reason: reason) case let .running(_, task): await task.value } } - /// Promote a not-yet-foreground launch (`.background` or `.undetermined`) to - /// a foreground one and re-drive the sequence so foreground-only steps (e.g. - /// onboarding) now run. No-op for a runner that already launched in the - /// foreground. + /// Promote a not-yet-foreground launch (`.background` or `.undetermined`) + /// to a foreground one and re-drive the plan so foreground-only nodes + /// (gates, foreground work) now run. No-op for a runner that already + /// launched in the foreground. /// - /// Call this from the root view's `.task`: it fires only once a window - /// exists, which is exactly when a background/undetermined launch has become - /// a user-visible one. The re-drive skips steps that already completed (see - /// `completedStepIDs`), so a work step serviced during the headless drive + /// Call this only once the scene is genuinely active: it fires when a + /// window exists, which is exactly when a background/undetermined launch + /// has become a user-visible one. The re-drive skips nodes that already + /// completed (see `memo`), so work serviced during the headless drive /// isn't run a second time. public func enterForeground() async { guard reason != .userForeground else { return } - await drive(reason: .userForeground, from: 0) - } - - /// Resume from the step that failed. No-op unless the runner is currently in - /// `.failed`. - /// - /// If the failure was in a teardown step (from `teardown(_:)`), the teardown - /// is resumed from that step and the relaunch still follows — so a failed - /// erase re-erases rather than dropping the user back into the app over - /// un-torn-down state. Otherwise the launch sequence is resumed from the - /// failed step. - public func retry() { - guard case let .failed(failure) = phase else { return } - if let teardownIndex = teardownSteps.firstIndex(where: { $0.id == failure.stepID }) { - Task { await driveTeardown(fromTeardownIndex: teardownIndex) } - } else if let startIndex = steps.firstIndex(where: { $0.id == failure.stepID }) { - let reason = reason - Task { await drive(reason: reason, from: startIndex) } - } + await drive(reason: .userForeground) } - /// Run a teardown `sequence` (logout / erase), then relaunch from the top - /// so the app returns to its initial state — e.g. first-run onboarding - /// shows again once the teardown clears the "has onboarded" flag. + /// Run a teardown `plan` rooted at `input` (logout / erase), then + /// relaunch from the top so the app returns to its initial state — e.g. + /// first-run onboarding shows again once the teardown clears the "has + /// onboarded" flag. /// - /// The teardown steps are retained so a `retry()` after a thrown teardown - /// step resumes the teardown (then the relaunch). If a teardown step throws, - /// the runner parks in `.failed` and does not relaunch. - public func teardown(_ sequence: LifecycleSteps) async { - teardownSteps = sequence.steps - await driveTeardown(fromTeardownIndex: 0) + /// If a teardown node throws, the runner parks in the terminal `.failed` + /// and does not relaunch — the erase leaves prior state intact (a thrown + /// erase never reaches the session drop), so relaunching the app returns + /// to the working app rather than a half-erased one. The teardown's + /// detached children (if any) are drained *before* the relaunch begins, + /// so no torn-down-world work overlaps the fresh launch. + public func teardown( + _ plan: LaunchPlan, + input: Input, + ) async { + await teardownErased(nodes: plan.nodes, input: input) } - /// Cancel any in-flight drive, drain it, then run the retained `teardown` - /// from `startIndex` followed by a fresh launch from the top — landing in - /// `.ready` only if both complete. Shared by `teardown(_:)` and a `retry()` - /// resuming a failed teardown step, so the two stay in lockstep. - private func driveTeardown(fromTeardownIndex startIndex: Int) async { + /// `teardown(_:input:)` after erasure — the `LifecycleDriving` seam the + /// UI proxy forwards through (see `LifecycleDriving`). Cancels any + /// in-flight drive, drains it, walks the teardown once, then relaunches + /// from the top — landing in `.ready` only if both complete. + package func teardownErased(nodes: [LaunchPlanNode], input: any Sendable) async { let previous = currentTask previous?.cancel() let reason = reason @@ -171,191 +182,261 @@ public final class LifecycleRunner { let task = Task { [weak self] in guard let self else { return } await previous?.value - guard case .completed = await runSteps(teardownSteps, from: startIndex) else { return } - // The relaunch is a fresh attempt: clear the completed set so every - // launch step re-runs over the torn-down state rather than being - // skipped as "already done" from before the teardown. - completedStepIDs.removeAll() - if case .completed = await runSteps(steps, from: 0) { - phase = .ready + // Teardown starts from an empty run-once set: the launch attempt + // is over (its drive drained above), so clearing here means a + // teardown node may freely reuse a launch node's ID — there is no + // shared-memo collision, which is why the combinator engine no + // longer needs a cross-plan disjointness precondition. Teardown + // runs exactly once (no retry), so nothing re-reads these entries. + memo.removeAll() + let tornDown = await withDiscardingTaskGroup(returning: Bool.self) { group in + let outcome = await self.walk(nodes, input: input, group: &group) + guard case .completed = outcome, !Task.isCancelled else { return false } + return true } + guard tornDown else { return } + // The relaunch is a fresh attempt: clear the teardown's entries so + // every launch node re-runs over the torn-down state. + memo.removeAll() + detachedFailures.removeAll() + await runLaunchPlan() } state = .running(reason: reason, task: task) await task.value } - /// Cancel any in-flight drive, then drive `self.steps` from `startIndex` on - /// a fresh task, landing in `.ready` if every applicable step completes. - /// The new task drains the cancelled one before running, so two drives - /// never overlap. - private func drive(reason newReason: LifecycleReason, from startIndex: Int) async { + /// Cancel any in-flight drive, then drive the launch plan on a fresh + /// task, landing in `.ready` if the trunk completes. The new task drains + /// the cancelled one before running, so two drives never overlap. + private func drive(reason newReason: LifecycleReason) async { let previous = currentTask previous?.cancel() phase = .launching let task = Task { [weak self] in guard let self else { return } await previous?.value - if case .completed = await runSteps(steps, from: startIndex) { - phase = .ready - } + await runLaunchPlan() } state = .running(reason: newReason, task: task) await task.value } - /// The outcome of running a single step or a whole sequence — the cases line - /// up, so `runStep`/`runSteps` share it. - private enum DriveOutcome { - /// The step (or every applicable step) finished. - case completed - /// A step threw a non-cancellation error; `phase` is now `.failed`. + /// Walk the launch trunk inside a task group (the group owns any detached + /// children it spawns), publishing `.ready` with the trunk's output as + /// soon as the trunk completes — *before* the children drain, which + /// happens on scope exit. A superseded (cancelled) walk publishes nothing. + private func runLaunchPlan() async { + await withDiscardingTaskGroup { group in + let outcome = await self.walk(self.launchNodes, input: (), group: &group) + guard case let .completed(value) = outcome, !Task.isCancelled else { return } + self.phase = .ready(value as! Launch) + } + } + + /// The outcome of walking a trunk. + private enum WalkOutcome { + /// Every applicable node finished; carries the trunk value after the + /// last one. + case completed(any Sendable) + /// A node threw a non-cancellation error; `phase` is now `.failed` + /// (terminally). case failed /// The drive was superseded (cancelled); `phase` is left for the /// drive that cancelled it to set. case cancelled } - /// Walk `steps` from `startIndex`, honoring mode/condition gating and - /// delegating each applicable step to `runStep`. - private func runSteps(_ steps: [LifecycleStep], from startIndex: Int) async -> DriveOutcome { - var index = startIndex - while index < steps.count { + /// Walk `nodes` in order, threading the trunk value through steps and + /// gates, spawning detached children into `group`, and honoring the + /// memoized run-once set. + private func walk( + _ nodes: [LaunchPlanNode], + input: any Sendable, + group: inout DiscardingTaskGroup, + ) async -> WalkOutcome { + var value = input + var index = 0 + while index < nodes.count { if Task.isCancelled { return .cancelled } - let step = steps[index] + switch nodes[index] { + case let .step(node): + // Already finished this attempt (e.g. before an + // `enterForeground()` promotion re-drove from the top) — + // don't run it again; adopt its recorded output. + if let memoized = memo[node.id] { + value = memoized + break + } + // Only pass-through steps can carry a narrowed mode set + // (LaunchPlan preconditions the rest), so skipping here + // never leaves a hole in the data flow. + guard node.modes.contains(reason.modeSet) else { break } + let context = LifecycleStepContext(stepID: node.id, reason: reason) + phase = .running(context) + do { + let output = try await node.run(value, context) + memo[node.id] = output + value = output + } catch is CancellationError { + return .cancelled + } catch { + // A superseded drive can still throw a *real* error + // from its in-flight step (the work isn't required to + // be cancellation-responsive). The superseding drive + // owns `phase` now, so a dying drive must report + // `.cancelled` rather than clobber the new drive's + // state with `.failed`. + guard !Task.isCancelled else { return .cancelled } + phase = .failed(LifecycleFailure(stepID: node.id, error: error)) + return .failed + } - // Already finished this attempt (e.g. a background-safe step that - // ran before an `enterForeground()` promotion re-drove from the - // top) — don't run it again. - guard !completedStepIDs.contains(step.id) else { - index += 1 - continue - } - guard step.appliesTo(reason) else { - index += 1 - continue - } - guard await step.condition() else { - index += 1 - continue - } + case let .gate(node): + if memo[node.id] != nil { break } + guard node.modes.contains(reason.modeSet) else { break } + // A skipped gate is *not* memoized: `isNeeded` re-evaluates + // on the promotion re-drive, so a gate skipped headless + // still shows once the launch is user-visible. + guard await node.isNeeded(value) else { break } + if Task.isCancelled { return .cancelled } + let handle = LifecycleGateHandle(node: node, reason: reason, value: value) + phase = .awaitingGate(handle) + do { + try await handle.waitForResolution() + memo[node.id] = value + } catch is CancellationError { + return .cancelled + } catch { + guard !Task.isCancelled else { return .cancelled } + phase = .failed(LifecycleFailure(stepID: node.id, error: error)) + return .failed + } - switch await runStep(step) { - case .completed: - completedStepIDs.insert(step.id) - index += 1 - case .failed: return .failed - case .cancelled: return .cancelled + case let .detached(children): + // Fan out and keep walking: children never block the trunk. + for child in children { + guard memo[child.id] == nil, + child.modes.contains(reason.modeSet) else { continue } + spawnDetached(child, value: value, group: &group) + } } + index += 1 } - return .completed + return .completed(value) } - /// Publish a single applicable step, manage its presentation, await its - /// body, then hold the presentation for `minVisible`. - /// - /// The presentation bookkeeping is a *local* `ActivePresentation`, created - /// here and torn down on every exit path via `defer` — so a step's - /// deferred-timer/`minVisible` state can't outlive the step that owns it - /// (the invariant that previously lived in scattered stored properties). - private func runStep(_ step: LifecycleStep) async -> DriveOutcome { - let bridge = LifecycleStepUIBridge(reason: reason) - phase = .running(step, bridge) - - let presentation = ActivePresentation(for: step, bridge: bridge) - defer { presentation.cancel() } - - do { - try await step.perform(bridge) - } catch is CancellationError { - return .cancelled - } catch { - // A superseded drive can still throw a *real* error from its - // in-flight step (the work isn't required to be cancellation- - // responsive). The superseding drive owns `phase` now, so a dying - // drive must report `.cancelled` rather than clobber the new - // drive's state with `.failed` — the new drive re-runs the step - // and surfaces its own outcome. - guard !Task.isCancelled else { return .cancelled } - phase = .failed(LifecycleFailure(stepID: step.id, error: error)) - return .failed + /// Run one detached child in `group`. Success is memoized (a re-drive + /// doesn't repeat it); a real failure is recorded on `detachedFailures` + /// — observable, never fatal; a failed child is *not* memoized, so the + /// next re-drive retries it. A cancelled child (its drive was superseded) + /// records nothing, mirroring the trunk's cancelled-is-not-failed rule. + private func spawnDetached( + _ child: DetachedNode, + value: any Sendable, + group: inout DiscardingTaskGroup, + ) { + let reason = reason + // Formed as a main-actor `@Sendable` closure so it may capture the + // (non-`Sendable`, main-actor-confined) node; the group task then + // captures only this closure, which crosses safely. + let operation: @Sendable @MainActor () async -> Void = { [weak self] in + let context = LifecycleStepContext(stepID: child.id, reason: reason) + do { + try await child.run(value, context) + self?.memo[child.id] = () + } catch is CancellationError { + // Superseded drive draining — deliberately not a failure. + } catch { + guard !Task.isCancelled, let self else { return } + detachedFailures.append(LifecycleFailure(stepID: child.id, error: error)) + } + } + group.addTask { + await operation() } - - await presentation.hold() - return .completed } } -#if DEBUG - extension LifecycleRunner { - /// Injects a failure for SPI-enabled tests (e.g. stale step IDs in `retry()`). - @_spi(Testing) public func injectFailureForTesting(_ failure: LifecycleFailure) { - phase = .failed(failure) - } - } -#endif +// MARK: - Phase inspection -/// Per-step presentation bookkeeping, owned for the lifetime of one running -/// step. Activating the step's presentation (immediately, on a `when:` -/// predicate, or after a delay), stamping when it appeared, and enforcing -/// `minVisible` all live here so the runner doesn't carry transient -/// presentation state between steps — it can only exist while a step runs. @MainActor -private final class ActivePresentation { - private let minVisible: Duration - /// The deferred-trigger timer, if the step uses `presenting(after:)`. Nil for - /// immediate/`when:`/no-presentation steps and once cancelled. - private var pendingTimer: Task? - /// When the view actually appeared (any trigger); nil while nothing is on - /// screen (deferred-and-not-yet-fired, or a `when:` predicate that was - /// false), so there's nothing to hold. - private var shownAt: ContinuousClock.Instant? - - /// Activate `step`'s presentation per its trigger. With no presentation this - /// is inert: `hold()`/`cancel()` then do nothing. - init(for step: LifecycleStep, bridge: LifecycleStepUIBridge) { - guard let presentation = step.presentation else { - minVisible = .zero - return - } - minVisible = presentation.minVisible - switch presentation.trigger { - case .always: - show(presentation, on: bridge) - case let .when(predicate): - if predicate() { - show(presentation, on: bridge) - } - case let .after(delay): - pendingTimer = Task { [weak self, weak bridge] in - try? await Task.sleep(for: delay) - guard !Task.isCancelled, let self, let bridge else { return } - show(presentation, on: bridge) - } - } +extension LifecycleRunner.Phase { + public var isLaunching: Bool { + if case .launching = self { true } else { false } } - /// Put the view on screen and stamp when, so `hold()` can honor `minVisible` - /// regardless of which trigger fired. - private func show(_ presentation: LifecycleStepPresentation, on bridge: LifecycleStepUIBridge) { - bridge.presentation = presentation.build(bridge) - shownAt = .now + public var isReady: Bool { + if case .ready = self { true } else { false } } - /// If the presentation actually appeared, keep it up until its `minVisible` - /// window elapses, so a step that finishes right after the UI appears - /// doesn't flash it away. - func hold() async { - guard let shownAt else { return } - let remaining = minVisible - shownAt.duration(to: .now) - if remaining > .zero { - try? await Task.sleep(for: remaining) - } + /// The trunk's output, once the launch finished. + public var readyValue: Launch? { + if case let .ready(value) = self { value } else { nil } + } + + /// The context of the currently running trunk step, if any. + public var runningContext: LifecycleStepContext? { + if case let .running(context) = self { context } else { nil } + } + + /// The id of the currently running trunk step, if any. + public var runningStepID: AnyHashable? { + runningContext?.stepID + } + + /// The handle of the gate the trunk is parked at, if any. + public var gateHandle: LifecycleGateHandle? { + if case let .awaitingGate(handle) = self { handle } else { nil } } - /// Cancel the deferred timer (if any). Called on every step exit path. - func cancel() { - pendingTimer?.cancel() - pendingTimer = nil + /// The failure, if the launch failed. + public var failure: LifecycleFailure? { + if case let .failed(failure) = self { failure } else { nil } + } + + /// Whether the step with `id` is the one currently running. Handy in + /// tests that drive the runner until a particular step is active, and + /// reads better than comparing the `AnyHashable` `runningStepID` to a + /// raw token. + public func isRunning(_ id: AnyHashable) -> Bool { + runningStepID == id + } + + /// Whether the trunk is parked at the gate with `id`. + public func isAwaitingGate(_ id: AnyHashable) -> Bool { + gateHandle?.id == id + } + + /// Whether the launch failed in the node with `id`. + public func failed(at id: AnyHashable) -> Bool { + failure?.stepID == id + } +} + +@MainActor +extension LifecycleRunner.Phase { + /// A value identity for the *surface* the container renders, so it can + /// animate transitions between surfaces with `.animation(_:value:)` (the + /// phase itself isn't `Equatable`). + /// + /// `launching` and `running` collapse to `.splash`: a step advancing + /// keeps showing the splash, so it must not retrigger a top-level + /// transition and flash it. Reaching a gate, `.failed`, or `.ready` is a + /// real surface change and animates. + package enum SurfaceIdentity: Hashable { + case splash + case gate(AnyHashable) + case failed(AnyHashable) + case ready + } + + package var surfaceIdentity: SurfaceIdentity { + switch self { + case .launching, .running: .splash + case let .awaitingGate(handle): .gate(handle.id) + case let .failed(failure): .failed(failure.stepID) + case .ready: .ready + } } } diff --git a/Shared/LifecycleKit/Sources/LifecycleStep.swift b/Shared/LifecycleKit/Sources/LifecycleStep.swift index f3f68042..bd2b43d6 100644 --- a/Shared/LifecycleKit/Sources/LifecycleStep.swift +++ b/Shared/LifecycleKit/Sources/LifecycleStep.swift @@ -1,161 +1,89 @@ -import SwiftUI - -/// One unit of launch work. +/// One unit of launch (or teardown) work, modeled as its own type with +/// concrete inputs and outputs. /// -/// A step declares *whether* it should run (`condition`), *which* launch -/// reasons it applies to (`allowedModes`), the async `perform` body, and an -/// optional `presentation` to show while it runs. `condition` and `allowedModes` -/// are fixed at construction (init / `work` / `interactive` parameters) rather -/// than chained modifiers, so a step's run gating is visible where it's built; -/// UI is attached afterwards with the `.presenting` modifiers. +/// `Input` is what the step needs to run; `Output` is what finishing proves. +/// A step cannot be scheduled before something upstream has produced its +/// `Input` — the ordering of a `LaunchPlan` is checked by the compiler through +/// these associated types, not by declaration order alone. Cross-step data +/// flows through the plan's trunk value; steps never reach into shared +/// mutable state to find what an earlier step "should have" set. /// -/// The closures are `@MainActor`: heavy work should be delegated to actors -/// from inside `perform`, keeping the step itself on the main actor so it can -/// drive UI directly. -public struct LifecycleStep: Identifiable { - /// Stable identity used for retry/teardown matching and parity tests. Typed - /// as `AnyHashable` so steps carry a real `Hashable` token (a typed enum - /// case is preferred over a raw string); any `Hashable` converts implicitly. - public let id: AnyHashable +/// Steps are `@MainActor`: heavy work should be delegated to actors from +/// inside `run`, keeping the step itself on the main actor so it can touch +/// main-actor state directly. +@MainActor +public protocol LifecycleStep { + /// What this step needs. Produced by the plan's upstream trunk (or, for a + /// plan's root step, supplied by the caller — `Void` for a launch). + associatedtype Input: Sendable + /// What finishing this step proves. `Void` for side-effect-only steps. + associatedtype Output: Sendable + /// The identity domain this step belongs to — a typed enum per plan (e.g. + /// Where's `LaunchStepID`) rather than a bare `String`. A `LaunchPlan` is + /// generic over this, so every node in one plan must share it: a step + /// keyed in another plan's domain can't be composed in. + associatedtype ID: Hashable & Sendable - var allowedModes: LifecycleModeSet - var condition: @MainActor () async -> Bool - var perform: @MainActor (LifecycleStepUIBridge) async throws -> Void - var presentation: LifecycleStepPresentation? + /// Stable identity used for run-once memoization and tests. Unique within + /// its plan, which `LaunchPlan` `precondition`s at construction. + var id: ID { get } - /// - Parameters: - /// - id: stable identity for retry/teardown matching. - /// - modes: which launch reasons this step runs under (e.g. `.foreground` - /// so onboarding never runs during a headless background relaunch). - /// - condition: run this step only when the predicate holds at the moment - /// the engine reaches it. - /// - perform: the async work; delegate heavy lifting to actors from here. - public init( - id: AnyHashable, - modes: LifecycleModeSet = .all, - condition: @escaping @MainActor () async -> Bool = { true }, - perform: @escaping @MainActor (LifecycleStepUIBridge) async throws -> Void, - ) { - self.id = id - allowedModes = modes - self.condition = condition - self.perform = perform - presentation = nil - } + /// Which launch reasons this step runs under. Read once, when the plan is + /// built. Only pass-through positions may gate: `LaunchPlan.thenKeeping` + /// steps and detached children may narrow this (e.g. `.foreground`); + /// value-producing steps (`LaunchPlan.init` / `then`) must keep the + /// default `.all` — a skipped step there would leave a hole in the data + /// flow, and the plan `precondition`s against it. + var modes: LifecycleModeSet { get } - /// Show `view` for the whole time this step runs (e.g. onboarding, whose - /// entire purpose is the UI). - /// - /// `minVisible` keeps the view on screen for at least that long once it - /// appears, even if the step finishes first — so a fast step never flashes - /// its UI away instantly. It applies uniformly to every `presenting` trigger. - public func presenting( - minVisible: Duration = .zero, - @ViewBuilder _ view: @escaping @MainActor (LifecycleStepUIBridge) -> some View, - ) -> Self { - present(trigger: .always, minVisible: minVisible, view) - } - - /// Show `view` only when `predicate` holds as the step starts (e.g. a - /// migration UI shown only when a migration is predicted). Otherwise the - /// step runs silently behind the splash. See `presenting(minVisible:_:)` for - /// `minVisible`. - public func presenting( - when predicate: @escaping @MainActor () -> Bool, - minVisible: Duration = .zero, - @ViewBuilder _ view: @escaping @MainActor (LifecycleStepUIBridge) -> some View, - ) -> Self { - present(trigger: .when(predicate), minVisible: minVisible, view) - } - - /// Show `view` only if the step is still running after `delay` — a deferred - /// "this is taking a while" UI that never flashes for fast steps. See - /// `presenting(minVisible:_:)` for `minVisible`. - public func presenting( - after delay: Duration, - minVisible: Duration = .zero, - @ViewBuilder _ view: @escaping @MainActor (LifecycleStepUIBridge) -> some View, - ) -> Self { - present(trigger: .after(delay), minVisible: minVisible, view) - } - - /// Whether this step is allowed to run under `reason` (mode filtering, - /// independent of the async `condition`). - func appliesTo(_ reason: LifecycleReason) -> Bool { - allowedModes.contains(reason.modeSet) - } + /// The step's async work. Throwing from a trunk step fails the drive and + /// parks the runner in `.failed`; throwing from a detached child only + /// surfaces on `LifecycleRunner.detachedFailures`. + func run(_ input: Input, _ context: LifecycleStepContext) async throws -> Output +} - private func present( - trigger: LifecycleStepPresentation.Trigger, - minVisible: Duration, - _ view: @escaping @MainActor (LifecycleStepUIBridge) -> some View, - ) -> Self { - var copy = self - copy.presentation = LifecycleStepPresentation(trigger: trigger, minVisible: minVisible) { - AnyView(view($0)) - } - return copy +extension LifecycleStep { + public var modes: LifecycleModeSet { + .all } } -// MARK: - Declarative sugar +/// A trunk node that can park the drive awaiting external (user) resolution — +/// first-run onboarding is the canonical example. +/// +/// A gate is pass-through by construction: it never transforms the trunk +/// value, so skipping it (mode gating, or `isNeeded` returning false) cannot +/// leave a hole in the data flow. The engine parks in +/// `LifecycleRunner.Phase.awaitingGate` and hands the UI a +/// `LifecycleGateHandle`; the gate's view (registered in the UI layer by gate +/// *type*) resolves the handle to resume — or fail — the drive. The gate type +/// itself carries no view, keeping the engine free of UI. +@MainActor +public protocol LifecycleGate { + /// The trunk value at this gate's position, passed through untouched. + associatedtype Value: Sendable + /// The gate's identity domain — the plan's, same as `LifecycleStep.ID`. + associatedtype ID: Hashable & Sendable -extension LifecycleStep { - /// A silent unit of launch work: runs `perform`, shows nothing of its own - /// (the host's splash stays up) unless you add a `.presenting(...)` modifier. - public static func work( - _ id: AnyHashable, - modes: LifecycleModeSet = .all, - condition: @escaping @MainActor () async -> Bool = { true }, - _ perform: @escaping @MainActor (LifecycleStepUIBridge) async throws -> Void, - ) -> LifecycleStep { - LifecycleStep(id: id, modes: modes, condition: condition, perform: perform) - } + /// Stable identity used for run-once memoization and tests. Same + /// conventions as `LifecycleStep.id`. + var id: ID { get } - /// A UI-bearing step that presents `presenting` and, by default, suspends - /// until the view resolves the bridge (`complete()`/`fail(_:)`). Pass a - /// custom `perform` if the step also needs to do async work alongside - /// awaiting the UI. - /// - /// Interactive steps default to `modes: .foreground`: a step whose whole - /// job is to wait for the user would deadlock during a headless background - /// launch (there is no UI to resolve it), so it is skipped there. Pass - /// `modes: .all` only if `perform` can also resolve itself without the UI. - public static func interactive( - _ id: AnyHashable, - modes: LifecycleModeSet = .foreground, - condition: @escaping @MainActor () async -> Bool = { true }, - perform: @escaping @MainActor (LifecycleStepUIBridge) async throws - -> Void = { try await $0.waitForResolution() }, - @ViewBuilder presenting view: @escaping @MainActor (LifecycleStepUIBridge) -> some View, - ) -> LifecycleStep { - LifecycleStep(id: id, modes: modes, condition: condition, perform: perform) - .presenting(view) - } + /// Which launch reasons this gate applies to. Defaults to `.foreground`: + /// a gate's whole job is to wait for the user, which would park a + /// headless launch forever, so it is skipped there and re-evaluated when + /// `enterForeground()` promotes the launch. + var modes: LifecycleModeSet { get } + + /// Whether the gate needs to present at all, evaluated when the trunk + /// reaches it. `false` skips the gate and `value` flows through untouched + /// — and is re-evaluated on a later re-drive (e.g. after promotion), so a + /// gate skipped headless still shows once the launch is user-visible. + func isNeeded(_ value: Value) async -> Bool } -/// A step's optional UI: when to show it (`trigger`), how long to keep it once -/// shown (`minVisible`), and how to build it. `minVisible` is unified here -/// rather than per-trigger so the "don't flash away" guarantee is identical -/// whether the view shows immediately, conditionally, or after a delay. -struct LifecycleStepPresentation { - enum Trigger { - /// Show as soon as the step starts. - case always - /// Show at step start only if the predicate holds. - case when(@MainActor () -> Bool) - /// Show only if the step is still running after `delay`. - case after(Duration) +extension LifecycleGate { + public var modes: LifecycleModeSet { + .foreground } - - var trigger: Trigger - /// Once shown, keep the view up for at least this long even if the step - /// finishes first (`.zero` = no hold). - var minVisible: Duration - /// Builds the step's view, type-erased to `AnyView`. Erasure is required so a - /// heterogeneous `[LifecycleStep]` can share one element type without leaking - /// each step's concrete view type into `LifecycleStep`/`LifecycleSteps`; the - /// public `presenting` API still takes `some View`. See - /// `LifecycleStepUIBridge.presentation` for the full rationale. - var build: @MainActor (LifecycleStepUIBridge) -> AnyView } diff --git a/Shared/LifecycleKit/Sources/LifecycleStepContext.swift b/Shared/LifecycleKit/Sources/LifecycleStepContext.swift new file mode 100644 index 00000000..ef7e62d9 --- /dev/null +++ b/Shared/LifecycleKit/Sources/LifecycleStepContext.swift @@ -0,0 +1,32 @@ +import Observation + +/// The reporting channel the engine hands each running trunk step. +/// +/// The engine creates one per step run and publishes it in +/// `LifecycleRunner.Phase.running`, so the host's splash can show what the +/// launch is doing; the step writes `progress`/`message` as it works. Both +/// are observable, so a rendered splash re-renders as the step reports. +@MainActor +@Observable +public final class LifecycleStepContext { + /// The running step's identity, for splash copy and tests. + public let stepID: AnyHashable + + /// Why the app is launching, in case the reporting surface wants to adapt. + public let reason: LifecycleReason + + /// Determinate progress in `0...1` for the host to show, or nil for an + /// indeterminate spinner. + public var progress: Double? + + /// A short, user-presentable status line for the host to show. + public var message: String? + + /// Create a standalone context. The engine creates one per step run, but + /// this is public so consumers can drive reporting surfaces in previews + /// and tests without an engine. + public init(stepID: AnyHashable, reason: LifecycleReason) { + self.stepID = stepID + self.reason = reason + } +} diff --git a/Shared/LifecycleKit/Sources/LifecycleStepUIBridge.swift b/Shared/LifecycleKit/Sources/LifecycleStepUIBridge.swift deleted file mode 100644 index 4529c639..00000000 --- a/Shared/LifecycleKit/Sources/LifecycleStepUIBridge.swift +++ /dev/null @@ -1,110 +0,0 @@ -import Observation -import SwiftUI - -/// The bridge between a running step and the UI it presents. -/// -/// The engine hands a fresh bridge to each step. A *silent* step ignores it; -/// an *interactive* step (onboarding, migration) awaits `waitForResolution()` -/// so the engine pauses while its presented view is on screen, and the view -/// calls `complete()` (or `fail(_:)`) to resume the launch. -/// -/// `progress`/`message` and the engine-driven `presentation` are observable so -/// the presented view re-renders as the step reports work. -@MainActor -@Observable -public final class LifecycleStepUIBridge { - /// Why the app is launching, in case a presented view wants to adapt. - public let reason: LifecycleReason - - /// Determinate progress in `0...1` for the presented view to show, or nil - /// for an indeterminate spinner. - public var progress: Double? - - /// A short, user-presentable status line for the presented view. - public var message: String? - - /// The view the host should show right now, or nil to fall back to the - /// splash. Set by the engine according to the step's presentation trigger - /// (always / when / after); not meant to be set by callers. - /// - /// Type-erased to `AnyView` on purpose: this is one observable storage slot - /// that holds *whichever* step is currently presenting, and different steps - /// present different concrete view types. `some View` can't express that — an - /// opaque type is a single concrete type fixed at the declaration, so it'd - /// force every step to present the same view. The erasure is contained to - /// this one property (and `LifecycleStepPresentation.build`); steps' own - /// `presenting` closures stay `some View`. - public internal(set) var presentation: AnyView? - - /// Whether the step has been resolved yet, and with what result. Folding the - /// old `earlyResolution` + `isResolved` pair into one value makes the two - /// impossible to drift: a resolved step always carries its result, and a - /// resolution before anyone waits is simply buffered here until the next - /// `waitForResolution()` delivers it. - private enum Resolution { - case pending - case resolved(Result) - } - - @ObservationIgnored private var continuation: CheckedContinuation? - @ObservationIgnored private var resolution: Resolution = .pending - - /// Create a standalone bridge. The engine creates one per step, but this - /// is public so consumers can build and drive their presentation views in - /// previews and tests without an engine. - public init(reason: LifecycleReason) { - self.reason = reason - } - - /// Resume the awaiting interactive step successfully. - public func complete() { - resolve(.success(())) - } - - /// Resume the awaiting interactive step by throwing `error` out of - /// `waitForResolution()`, which the engine turns into a `.failed` phase. - public func fail(_ error: Error) { - resolve(.failure(error)) - } - - /// Suspends until `complete()` or `fail(_:)` is called. Resolving before a - /// caller starts waiting is supported (the buffered result is delivered - /// immediately), so there is no lost-wakeup race. - /// - /// Cancelling the surrounding task resumes the wait by throwing - /// `CancellationError`, which lets the engine drain a parked interactive - /// step when a new drive supersedes it (rather than hanging on a tap that - /// will never come). - public func waitForResolution() async throws { - try await withTaskCancellationHandler { - switch resolution { - case let .resolved(result): - try result.get() - case .pending: - try await withCheckedThrowingContinuation { continuation in - // A cancellation could land before we store the - // continuation; if so, resolution is already set, so - // deliver it here instead of parking forever. - if case let .resolved(result) = resolution { - continuation.resume(with: result) - } else { - self.continuation = continuation - } - } - } - } onCancel: { - Task { @MainActor [weak self] in - self?.resolve(.failure(CancellationError())) - } - } - } - - private func resolve(_ result: Result) { - guard case .pending = resolution else { return } - resolution = .resolved(result) - if let continuation { - self.continuation = nil - continuation.resume(with: result) - } - } -} diff --git a/Shared/LifecycleKit/Sources/LifecycleSteps.swift b/Shared/LifecycleKit/Sources/LifecycleSteps.swift deleted file mode 100644 index ce0cb51a..00000000 --- a/Shared/LifecycleKit/Sources/LifecycleSteps.swift +++ /dev/null @@ -1,66 +0,0 @@ -/// Result builder that collects `LifecycleStep`s declared in a -/// `LifecycleSteps`, with `if`/`if-else`/`for` support so steps can be -/// included conditionally. -@resultBuilder -public enum LifecycleStepsBuilder { - public static func buildExpression(_ step: LifecycleStep) -> [LifecycleStep] { - [step] - } - - public static func buildExpression(_ steps: [LifecycleStep]) -> [LifecycleStep] { - steps - } - - public static func buildBlock(_ components: [LifecycleStep]...) -> [LifecycleStep] { - components.flatMap(\.self) - } - - public static func buildOptional(_ component: [LifecycleStep]?) -> [LifecycleStep] { - component ?? [] - } - - public static func buildEither(first component: [LifecycleStep]) -> [LifecycleStep] { - component - } - - public static func buildEither(second component: [LifecycleStep]) -> [LifecycleStep] { - component - } - - public static func buildArray(_ components: [[LifecycleStep]]) -> [LifecycleStep] { - components.flatMap(\.self) - } - - public static func buildLimitedAvailability(_ component: [LifecycleStep]) -> [LifecycleStep] { - component - } -} - -/// An ordered list of lifecycle steps. The engine walks these top to bottom; -/// the declaration order is the run order. -public struct LifecycleSteps { - public let steps: [LifecycleStep] - - public init(@LifecycleStepsBuilder _ steps: () -> [LifecycleStep]) { - let steps = steps() - Self.assertUniqueIDs(steps) - self.steps = steps - } - - public init(steps: [LifecycleStep]) { - Self.assertUniqueIDs(steps) - self.steps = steps - } - - /// Step IDs must be unique within a sequence: retry/teardown resume by - /// matching `LifecycleFailure.stepID` against `step.id`, so a duplicate would - /// make resumption ambiguous. - private static func assertUniqueIDs(_ steps: [LifecycleStep]) { - var seen = Set() - let duplicates = steps.map(\.id).filter { !seen.insert($0).inserted } - precondition( - duplicates.isEmpty, - "LifecycleSteps contains duplicate step IDs: \(duplicates)", - ) - } -} diff --git a/Shared/LifecycleKit/TODOs.md b/Shared/LifecycleKit/TODOs.md index 2709cc10..36dbda3c 100644 --- a/Shared/LifecycleKit/TODOs.md +++ b/Shared/LifecycleKit/TODOs.md @@ -6,12 +6,15 @@ here. # Open issues -## P0s (Must do) -- fix [needs-design]: A cancel during the **final** step's `minVisible` hold is never observed, so a superseded drive still sets `phase = .ready`. `runStep` returns `.completed` unconditionally after `presentation.hold()` (`LifecycleRunner.swift:284`), and `runSteps`' cancellation check sits at the *top* of the loop (`:223`, `:251`) — so for the last step there is no next iteration to catch it, and `drive` (`:198`) reaches the terminal phase on a drive that was torn down mid-hold. Check `Task.isCancelled` after the hold, and gate the terminal phase on an active-drive token. (audit 2026-07-26) - - test [quick-win]: Supersede a drive *during* `minVisible` — teardown or `enterForeground()` while the hold is active. `LifecycleRunnerTests.swift:430` covers the hold itself, but nothing interrupts one. (audit 2026-07-26) - ## P2s (Nice to have) -- test [quick-win]: Replace the fixed 50 ms negative-assertion window (`LifecycleRunnerTests.swift:174`) with bounded polling, and add a test that duplicate step IDs trap. (audit 2026-07-26) -- fix [quick-win]: The `LifecycleContainer` `#Preview` hardcodes a literal that auto-extracts into `Sources/Resources/Localizable.xcstrings` as the value-less `App content` entry. Removing the entry for good means removing the literal. Same shape as the three Where-side literals filed in [`Where/TODOs.md`](../../Where/TODOs.md). (agent) +- test [quick-win]: Add a test that duplicate node IDs trap. `LaunchPlan.append` `precondition`s on a duplicate (and `LifecycleContainer` does the same for duplicate gate-view registrations), but nothing exercises either. (audit 2026-07-26) # Completed issues + +## P0s (Must do) +- fix [needs-design]: A cancel during the **final** step's `minVisible` hold is never observed, so a superseded drive still sets `phase = .ready`. (audit 2026-07-26) — gone with the typed-engine rewrite: the engine holds nothing at all (the splash minimum moved to `LifecycleContainer.minimumSplashDuration`, a view-level concern), and `drive` now publishes the terminal phase behind `guard case let .completed(value) = outcome, !Task.isCancelled` (`LifecycleRunner.swift:231`) — so a superseded walk publishes nothing, with no hold sitting between the completion check and the phase write. + - test [quick-win]: Supersede a drive *during* `minVisible`. — moot; there is no in-engine hold to interrupt. Supersession itself is covered by the drive/teardown/promotion suites and the seeded fuzz suite. + +## P2s (Nice to have) +- test [quick-win]: Replace the fixed 50 ms negative-assertion window (`LifecycleRunnerTests.swift:174`) with bounded polling. (audit 2026-07-26) — done in the test rewrite; the suites poll predicates (`waitUntil` / `waitFor`), and the only remaining fixed delay is a 1 ms yield tick in `LifecycleKitTestSupport`. +- fix [quick-win]: The `LifecycleContainer` `#Preview` hardcodes a literal that auto-extracts as the value-less `App content` entry. (agent) — resolved: the container moved to LifecycleKitUI and its preview uses `Text(verbatim:)`, which isn't extracted, so no catalog carries the entry. diff --git a/Shared/LifecycleKit/Tests/LaunchPlanTests.swift b/Shared/LifecycleKit/Tests/LaunchPlanTests.swift new file mode 100644 index 00000000..f7138cc7 --- /dev/null +++ b/Shared/LifecycleKit/Tests/LaunchPlanTests.swift @@ -0,0 +1,132 @@ +@testable import LifecycleKit +import Testing + +@MainActor +struct LaunchPlanTests { + private func context(_ id: AnyHashable = "test") -> LifecycleStepContext { + LifecycleStepContext(stepID: id, reason: .userForeground) + } + + @Test func trunkNodesAppearInDeclarationOrder() { + let plan = LaunchPlan(FixtureStep("open") { _, _ in 1 }) + .then(FixtureStep("session") { value, _ in "\(value)" }) + .gate(FixtureGate("onboarding")) + .thenKeeping(FixtureStep("auth") { _, _ in }) + .detached { + FixtureStep("reminders") { _, _ in } + FixtureStep("widgets") { _, _ in } + } + + #expect(plan.nodes.flatMap(\.ids) == [ + "open", + "session", + "onboarding", + "auth", + "reminders", + "widgets", + ]) + } + + @Test func producingStepThreadsItsOutputToTheNextNode() async throws { + let plan = LaunchPlan(FixtureStep("open") { _, _ in 41 }) + .then(FixtureStep("increment") { value, _ in value + 1 }) + + guard case let .step(open) = plan.nodes[0], case let .step(increment) = plan.nodes[1] + else { + Issue.record("expected two step nodes") + return + } + let opened = try await open.run((), context()) + let incremented = try await increment.run(opened, context()) + #expect(incremented as? Int == 42) + } + + @Test func keepingStepPassesTheTrunkValueThrough() async throws { + var ran = false + let plan = LaunchPlan(FixtureStep("open") { _, _ in "session" }) + .thenKeeping(FixtureStep("keep") { _, _ in ran = true }) + + guard case let .step(keep) = plan.nodes[1] else { + Issue.record("expected a step node") + return + } + let out = try await keep.run("session", context()) + #expect(ran) + #expect(out as? String == "session") + } + + @Test func gateNodeCarriesModesAndEvaluatesIsNeededAgainstTheTrunkValue() async { + var seen: [String] = [] + let plan = LaunchPlan(FixtureStep("open") { _, _ in "session" }) + .gate(FixtureGate("onboarding") { value in + seen.append(value) + return false + }) + + guard case let .gate(gate) = plan.nodes[1] else { + Issue.record("expected a gate node") + return + } + // Gates default to foreground-only: parking a headless launch on a tap + // that can't come would deadlock it. + #expect(gate.modes == .foreground) + let needed = await gate.isNeeded("session") + #expect(!needed) + #expect(seen == ["session"]) + } + + @Test func detachedChildrenKeepTheirDeclaredModes() { + let plan = LaunchPlan(FixtureStep("open") { _, _ in "session" }) + .detached { + FixtureStep("capture", modes: .foreground) { _, _ in } + FixtureStep("widgets") { _, _ in } + } + + guard case let .detached(children) = plan.nodes[1] else { + Issue.record("expected a detached group") + return + } + #expect(children.map(\.id) == ["capture", "widgets"]) + #expect(children[0].modes == .foreground) + #expect(children[1].modes == .all) + } + + @Test func detachedBuilderSupportsConditionalsAndLoops() { + func makePlan(includeExtra: Bool) -> LaunchPlan { + LaunchPlan(FixtureStep("open") { _, _ in "session" }) + .detached { + FixtureStep("always") { _, _ in } + if includeExtra { + FixtureStep("extra") { _, _ in } + } + for index in 0 ..< 2 { + FixtureStep("loop-\(index)") { _, _ in } + } + } + } + + guard case let .detached(with) = makePlan(includeExtra: true).nodes[1], + case let .detached(without) = makePlan(includeExtra: false).nodes[1] + else { + Issue.record("expected detached groups") + return + } + #expect(with.map(\.id) == ["always", "extra", "loop-0", "loop-1"]) + #expect(without.map(\.id) == ["always", "loop-0", "loop-1"]) + } + + @Test func teardownPlansRootAtARealInput() async throws { + // A teardown plan's root consumes a value (the thing being torn down) + // rather than Void — the plan type carries that input. + var erased: [String] = [] + let plan = LaunchPlan(FixtureStep("erase") { value, _ in + erased.append(value) + }) + guard case let .step(erase) = plan.nodes[0] else { + Issue.record("expected a step node") + return + } + _ = try await erase.run("session", context()) + #expect(erased == ["session"]) + } +} diff --git a/Shared/LifecycleKit/Tests/LifecycleContainerTests.swift b/Shared/LifecycleKit/Tests/LifecycleContainerTests.swift deleted file mode 100644 index 5fec0508..00000000 --- a/Shared/LifecycleKit/Tests/LifecycleContainerTests.swift +++ /dev/null @@ -1,281 +0,0 @@ -@testable import LifecycleKit -import SwiftUI -import TestHostSupport -import Testing - -private struct ProbeError: LocalizedError { - var errorDescription: String? { - "probe failure" - } -} - -/// A leaf view that runs `mark` when the host lays it out, so a test can detect -/// whether the container actually chose (and rendered) the branch it sits in. -/// Each test owns the `Bool`s `mark` flips, so there are no shared fixtures. -private struct ProbeView: View { - let mark: () -> Void - - var body: some View { - mark() - return Color.clear.frame(width: 1, height: 1) - } -} - -/// Reads the runner proxy the container publishes into the environment and -/// reports whether it was connected (i.e. carried a runner) when this view laid -/// out. -private struct EnvironmentRunnerProbe: View { - @Environment(\.lifecycleRunner) private var runner - let mark: (Bool) -> Void - - var body: some View { - mark(runner.base != nil) - return Color.clear.frame(width: 1, height: 1) - } -} - -@MainActor -struct LifecycleContainerTests { - @Test func readyShowsContent() async throws { - var content = false - var splash = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - - let container = LifecycleContainer(runner, splash: { ProbeView { splash = true } }) { - ProbeView { content = true } - } - try show(UIHostingController(rootView: container)) { _ in - try waitFor { content } - } - #expect(content) - #expect(!splash) - } - - @Test func launchingShowsSplash() throws { - var splash = false - var content = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in } - }) - // Not run yet, so the runner is still in .launching. - - let container = LifecycleContainer(runner, splash: { ProbeView { splash = true } }) { - ProbeView { content = true } - } - try show(UIHostingController(rootView: container)) { _ in - try waitFor { splash } - } - #expect(splash) - #expect(!content) - } - - @Test func backgroundLaunchShowsNothing() async throws { - var content = false - var splash = false - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - - let container = LifecycleContainer(runner, splash: { ProbeView { splash = true } }) { - ProbeView { content = true } - } - try show(UIHostingController(rootView: container)) { _ in - // Even at .ready, a background launch must not build the app UI: - // give the host a render budget and confirm neither branch appears. - #expect(!renders { content || splash }) - } - } - - @Test func backgroundReadyThenEnterForegroundShowsContent() async throws { - var content = false - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - #expect(runner.reason.buildsNoViewTree) - - await runner.enterForeground() - #expect(!runner.reason.buildsNoViewTree) - #expect(runner.phase.isReady) - - let container = LifecycleContainer(runner) { - ProbeView { content = true } - } - try show(UIHostingController(rootView: container)) { _ in - try waitFor { content } - } - #expect(content) - } - - @Test func undeterminedLaunchShowsNothingUntilPromoted() async throws { - var content = false - var splash = false - let runner = LifecycleRunner(reason: .undetermined, sequence: LifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - - let container = LifecycleContainer(runner, splash: { ProbeView { splash = true } }) { - ProbeView { content = true } - } - try show(UIHostingController(rootView: container)) { _ in - // An undetermined launch hasn't proven a window exists, so — like a - // background launch — it must build no view tree even at .ready. - #expect(!renders { content || splash }) - } - } - - @Test func undeterminedReadyThenEnterForegroundShowsContent() async throws { - var content = false - let runner = LifecycleRunner(reason: .undetermined, sequence: LifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - #expect(runner.reason.buildsNoViewTree) - - await runner.enterForeground() - #expect(!runner.reason.buildsNoViewTree) - #expect(runner.phase.isReady) - - let container = LifecycleContainer(runner) { - ProbeView { content = true } - } - try show(UIHostingController(rootView: container)) { _ in - try waitFor { content } - } - #expect(content) - } - - @Test func runningShowsActivePresentation() async throws { - var presentation = false - var content = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.interactive("gate") { _ in ProbeView { presentation = true } } - }) - let task = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("gate") } - - let container = LifecycleContainer(runner) { ProbeView { content = true } } - try show(UIHostingController(rootView: container)) { _ in - try waitFor { presentation } - } - #expect(presentation) - #expect(!content) - - runner.phase.runningBridge?.complete() - await task.value - } - - @Test func minimumSplashDurationDoesNotHoldWhenNoSplashWasShown() async throws { - // The minimum only holds a splash that actually appeared. A launch that's - // already ready when the container mounts never showed one, so even a long - // minimum must reveal content immediately rather than stalling on a hold - // for a splash the user never saw. - // - // (The other half — holding a splash that *did* appear until the minimum - // elapses, then revealing — is a `.task`-driven async/timing behavior that - // `show`'s synchronous closure can't drive deterministically; like the - // splash caption's own delay it's exercised on device, not host-tested. - // See `LaunchSplashView.previewShowsCaption`.) - var content = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - - let container = LifecycleContainer( - runner, - minimumSplashDuration: .seconds(60), - splash: { EmptyView() }, - failure: { _, _ in EmptyView() }, - ) { - ProbeView { content = true } - } - try show(UIHostingController(rootView: container)) { _ in - try waitFor { content } - } - #expect(content) - } - - @Test func failedShowsFailureView() async throws { - var failure = false - var content = false - var splash = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("boom") { _ in throw ProbeError() } - }) - await runner.run() - #expect(runner.phase.failed(at: "boom")) - - let container = LifecycleContainer( - runner, - splash: { ProbeView { splash = true } }, - failure: { _, _ in ProbeView { failure = true } }, - ) { - ProbeView { content = true } - } - try show(UIHostingController(rootView: container)) { _ in - try waitFor { failure } - } - #expect(failure) - #expect(!content) - #expect(!splash) - } - - @Test func publishesTheRunnerIntoTheEnvironment() async throws { - var sawRunner = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - - let container = LifecycleContainer(runner) { - EnvironmentRunnerProbe { sawRunner = $0 } - } - try show(UIHostingController(rootView: container)) { _ in - try waitFor { sawRunner } - } - #expect(sawRunner) - } - - @Test func customTransitionStillRendersContent() async throws { - // A caller-supplied transition/animation must not break which surface the - // container renders — at .ready it still builds `content`, not the splash. - var content = false - var splash = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - - let container = LifecycleContainer( - runner, - transition: .scale.combined(with: .opacity), - animation: .easeInOut, - splash: { ProbeView { splash = true } }, - failure: { _, _ in EmptyView() }, - ) { - ProbeView { content = true } - } - try show(UIHostingController(rootView: container)) { _ in - try waitFor { content } - } - #expect(content) - #expect(!splash) - } - - @Test func defaultRunnerProxyIsDisconnected() { - // The environment default: nothing to drive, so callers no-op (debug - // asserts) rather than dereferencing a missing runner. - #expect(LifecycleRunnerProxy().base == nil) - } - - @Test func connectedProxyForwardsTeardownToTheRunner() async { - var tornDown = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - - await LifecycleRunnerProxy(runner).teardown(LifecycleSteps { - LifecycleStep.work("teardown") { _ in tornDown = true } - }) - #expect(tornDown) - #expect(runner.phase.isReady) - } -} diff --git a/Shared/LifecycleKit/Tests/LifecycleStepUIBridgeTests.swift b/Shared/LifecycleKit/Tests/LifecycleGateHandleTests.swift similarity index 57% rename from Shared/LifecycleKit/Tests/LifecycleStepUIBridgeTests.swift rename to Shared/LifecycleKit/Tests/LifecycleGateHandleTests.swift index 9fc05788..c13a908f 100644 --- a/Shared/LifecycleKit/Tests/LifecycleStepUIBridgeTests.swift +++ b/Shared/LifecycleKit/Tests/LifecycleGateHandleTests.swift @@ -4,23 +4,27 @@ import Testing private struct Boom: Error {} @MainActor -struct LifecycleStepUIBridgeTests { +struct LifecycleGateHandleTests { + private func makeHandle() -> LifecycleGateHandle { + LifecycleGateHandle(id: "gate", reason: .userForeground) + } + @Test func completeResumesWaiter() async throws { - let bridge = LifecycleStepUIBridge(reason: .userForeground) + let handle = makeHandle() let waiter = Task { @MainActor in - try await bridge.waitForResolution() + try await handle.waitForResolution() return true } await Task.yield() - bridge.complete() + handle.complete() #expect(try await waiter.value) } @Test func failThrowsFromWaiter() async { - let bridge = LifecycleStepUIBridge(reason: .userForeground) + let handle = makeHandle() let waiter = Task { @MainActor in do { - try await bridge.waitForResolution() + try await handle.waitForResolution() return false } catch is Boom { return true @@ -29,36 +33,36 @@ struct LifecycleStepUIBridgeTests { } } await Task.yield() - bridge.fail(Boom()) + handle.fail(Boom()) #expect(await waiter.value) } @Test func resolvingBeforeWaitingStillDelivers() async throws { - let bridge = LifecycleStepUIBridge(reason: .userForeground) - bridge.complete() - try await bridge.waitForResolution() + let handle = makeHandle() + handle.complete() + try await handle.waitForResolution() } @Test func failingBeforeWaitingStillThrows() async { - let bridge = LifecycleStepUIBridge(reason: .userForeground) - bridge.fail(Boom()) + let handle = makeHandle() + handle.fail(Boom()) await #expect(throws: Boom.self) { - try await bridge.waitForResolution() + try await handle.waitForResolution() } } @Test func secondResolutionIsIgnored() async throws { - let bridge = LifecycleStepUIBridge(reason: .userForeground) - bridge.complete() - bridge.fail(Boom()) - try await bridge.waitForResolution() + let handle = makeHandle() + handle.complete() + handle.fail(Boom()) + try await handle.waitForResolution() } @Test func cancellingTheWaiterThrowsCancellationError() async { - let bridge = LifecycleStepUIBridge(reason: .userForeground) + let handle = makeHandle() let waiter = Task { @MainActor in do { - try await bridge.waitForResolution() + try await handle.waitForResolution() return "resolved" } catch is CancellationError { return "cancelled" @@ -70,4 +74,10 @@ struct LifecycleStepUIBridgeTests { waiter.cancel() #expect(await waiter.value == "cancelled") } + + @Test func previewHandleCarriesNoGateType() { + let handle = makeHandle() + #expect(handle.gateType == nil) + #expect(handle.value == nil) + } } diff --git a/Shared/LifecycleKit/Tests/LifecyclePhaseTests.swift b/Shared/LifecycleKit/Tests/LifecyclePhaseTests.swift deleted file mode 100644 index bc529a61..00000000 --- a/Shared/LifecycleKit/Tests/LifecyclePhaseTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -@testable import LifecycleKit -import Testing - -private struct Boom: Error {} - -@MainActor -struct LifecyclePhaseTests { - @Test func launchingAndRunningShareTheSplashSurface() { - // The anti-flicker invariant: a step advancing keeps showing the splash, - // so it must not change the surface identity and retrigger a transition. - let running = LifecyclePhase.running( - LifecycleStep.work("a") { _ in }, - LifecycleStepUIBridge(reason: .userForeground), - ) - #expect(LifecyclePhase.launching.surfaceIdentity == .splash) - #expect(running.surfaceIdentity == .splash) - } - - @Test func runningStepsAllCollapseToTheSameSurface() { - // Advancing from one running step to the next is still the splash surface. - let first = LifecyclePhase.running( - LifecycleStep.work("first") { _ in }, - LifecycleStepUIBridge(reason: .userForeground), - ) - let second = LifecyclePhase.running( - LifecycleStep.work("second") { _ in }, - LifecycleStepUIBridge(reason: .userForeground), - ) - #expect(first.surfaceIdentity == second.surfaceIdentity) - } - - @Test func readyIsItsOwnSurface() { - #expect(LifecyclePhase.ready.surfaceIdentity == .ready) - #expect(LifecyclePhase.ready.surfaceIdentity != .splash) - } - - @Test func failuresAreIdentifiedByTheFailingStep() { - // Re-failing at a *different* step is a real surface change (animates); - // failing at the same step twice is not. - let boom = LifecyclePhase.failed(LifecycleFailure(stepID: "boom", error: Boom())) - let bang = LifecyclePhase.failed(LifecycleFailure(stepID: "bang", error: Boom())) - let boomAgain = LifecyclePhase.failed(LifecycleFailure(stepID: "boom", error: Boom())) - #expect(boom.surfaceIdentity == .failed("boom")) - #expect(boom.surfaceIdentity == boomAgain.surfaceIdentity) - #expect(boom.surfaceIdentity != bang.surfaceIdentity) - } -} diff --git a/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift b/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift index d948332d..48990a7f 100644 --- a/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift +++ b/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift @@ -22,121 +22,103 @@ private struct SplitMix64: RandomNumberGenerator { } } -/// A randomized step description, kept separate from the built `LifecycleStep` -/// so the test can predict the expected outcome from the same data the runner -/// drives. -private struct FuzzStep { +/// A randomized plan-element description, kept separate from the built +/// `LaunchPlan` so the test can predict the expected outcome from the same +/// data the runner drives. +private struct FuzzElement { + enum Kind { + /// A required pass-through trunk step (`thenKeeping`). + case keep + /// A fire-and-forget child in its own `detached` group. + case detachedChild + } + let id: String + let kind: Kind let modes: LifecycleModeSet - let conditionPasses: Bool let throwsError: Bool } -private func makeFuzzSteps(_ rng: inout SplitMix64) -> [FuzzStep] { +private func makeFuzzElements(_ rng: inout SplitMix64) -> [FuzzElement] { let modeChoices: [LifecycleModeSet] = [.all, .foreground, .background] let count = Int.random(in: 1 ... 8, using: &rng) return (0 ..< count).map { index in - FuzzStep( + FuzzElement( id: "s\(index)", + kind: Bool.random(using: &rng) ? .keep : .detachedChild, modes: modeChoices[Int.random(in: 0 ..< modeChoices.count, using: &rng)], - conditionPasses: Double.random(in: 0 ... 1, using: &rng) < 0.75, throwsError: Double.random(in: 0 ... 1, using: &rng) < 0.15, ) } } -/// Property-based / adversarial coverage: rather than hand-pick sequences, run -/// many randomized ones and check the runner's behavior against an independent -/// model. Complements the targeted runner tests. +/// Property-based / adversarial coverage: rather than hand-pick plans, run +/// many randomized ones and check the runner's behavior against an +/// independent model. Complements the targeted runner tests. @MainActor struct LifecycleRunnerFuzzTests { - /// For a random reason and a random mix of mode/condition/throwing steps, the - /// runner runs exactly the applicable + condition-true prefix, stops at the - /// first throw (parking `.failed` there), and otherwise reaches `.ready`. + /// For a random reason and a random mix of mode-gated / throwing trunk + /// steps and detached children, the runner runs exactly the applicable + /// trunk prefix (stopping at the first trunk throw, which parks + /// `.failed`), spawns exactly the detached children the trunk reached, + /// records exactly the detached failures, and otherwise reaches `.ready`. @Test(arguments: 0 ..< 200) - func randomSequenceLandsWhereTheModelPredicts(seed: Int) async { + func randomPlanLandsWhereTheModelPredicts(seed: Int) async { var rng = SplitMix64(seed: UInt64(seed)) let foreground = Bool.random(using: &rng) let reason: LifecycleReason = foreground ? .userForeground : .background(.location) - let fuzz = makeFuzzSteps(&rng) + let fuzz = makeFuzzElements(&rng) - var executed: [String] = [] - let runner = LifecycleRunner(reason: reason, sequence: LifecycleSteps { - for step in fuzz { - LifecycleStep - .work(step.id, modes: step.modes, condition: { step.conditionPasses }) { _ in - executed.append(step.id) - if step.throwsError { throw FuzzError() } + var executedTrunk: [String] = [] + var executedDetached: Set = [] + var plan = LaunchPlan(FixtureStep("root") { _, _ in "value" }) + for element in fuzz { + switch element.kind { + case .keep: + plan = plan.thenKeeping( + FixtureStep(element.id, modes: element.modes) { _, _ in + executedTrunk.append(element.id) + if element.throwsError { throw FuzzError() } + }, + ) + case .detachedChild: + plan = plan.detached { + FixtureStep(element.id, modes: element.modes) { _, _ in + executedDetached.insert(element.id) + if element.throwsError { throw FuzzError() } + } } } - }) + } + let runner = LifecycleRunner(reason: reason, plan: plan) await runner.run() // Independent model of what should have happened. - var expectedExecuted: [String] = [] + var expectedTrunk: [String] = [] + var expectedDetached: Set = [] + var expectedDetachedFailures: Set = [] var expectedFailureID: String? - for step in fuzz { - guard step.modes.contains(reason.modeSet), step.conditionPasses else { continue } - expectedExecuted.append(step.id) - if step.throwsError { - expectedFailureID = step.id - break + for element in fuzz { + guard element.modes.contains(reason.modeSet) else { continue } + switch element.kind { + case .keep: + expectedTrunk.append(element.id) + if element.throwsError { expectedFailureID = element.id } + case .detachedChild: + expectedDetached.insert(element.id) + if element.throwsError { expectedDetachedFailures.insert(element.id) } } + if expectedFailureID != nil { break } } - #expect(executed == expectedExecuted) + #expect(executedTrunk == expectedTrunk) + #expect(executedDetached == expectedDetached) + #expect(Set(runner.detachedFailures.map { "\($0.stepID)" }) == expectedDetachedFailures) if let expectedFailureID { #expect(runner.phase.failed(at: expectedFailureID)) } else { #expect(runner.phase.isReady) - } - } - - /// Steps that throw their first N attempts then succeed: driving `retry()` - /// resumes from the failed step each time and, after exactly the number of - /// injected failures, drains to `.ready` with every step having run once. - @Test(arguments: 0 ..< 120) - func retryDrainsFuzzedFlakyFailuresToReady(seed: Int) async throws { - var rng = SplitMix64(seed: UInt64(seed)) - let count = Int.random(in: 1 ... 8, using: &rng) - let ids = (0 ..< count).map { "s\($0)" } - let failuresBeforeSuccess = (0 ..< count).map { _ in Int.random(in: 0 ... 2, using: &rng) } - - var attempts = [Int](repeating: 0, count: count) - var succeeded: [String] = [] - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - for index in 0 ..< count { - LifecycleStep.work(ids[index]) { _ in - attempts[index] += 1 - if attempts[index] <= failuresBeforeSuccess[index] { throw FuzzError() } - succeeded.append(ids[index]) - } - } - }) - - await runner.run() - - let expectedRetries = failuresBeforeSuccess.reduce(0, +) - var retries = 0 - // Bounded so a regression surfaces as a failed expectation, not a hang. - while runner.phase.failure != nil, retries <= expectedRetries { - let attemptsBeforeRetry = attempts.reduce(0, +) - retries += 1 - runner.retry() - // `retry()` spawns the drive; wait for it to settle. A *new* failure - // is only trusted once an attempt has been made (attempts grew), - // which rules out re-observing the pre-retry `.failed` phase. - try await waitUntil { - runner.phase.isReady - || (runner.phase.failure != nil && attempts.reduce(0, +) > attemptsBeforeRetry) - } - } - - #expect(runner.phase.isReady) - #expect(retries == expectedRetries) - #expect(succeeded == ids) - for index in 0 ..< count { - #expect(attempts[index] == failuresBeforeSuccess[index] + 1) + #expect(runner.phase.readyValue == "value") } } } diff --git a/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift b/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift index 44b77712..ed722044 100644 --- a/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift +++ b/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift @@ -1,125 +1,170 @@ @testable import LifecycleKit -import SwiftUI import Testing private struct ResetError: Error {} @MainActor struct LifecycleRunnerResetTests { - @Test func resetRunsTeardownThenRelaunches() async { + @Test func teardownRunsThenRelaunchesWithItsTypedInput() async { var events: [String] = [] - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("launch") { _ in events.append("launch") } - }) + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("launch") { _, _ in + events.append("launch") + return "session" + }), + ) await runner.run() #expect(events == ["launch"]) #expect(runner.phase.isReady) - await runner.teardown(LifecycleSteps { - LifecycleStep.work("teardown") { _ in events.append("teardown") } - }) - #expect(events == ["launch", "teardown", "launch"]) + // The teardown plan roots at a real value — the thing being torn down. + await runner.teardown( + LaunchPlan(FixtureStep("teardown") { value, _ in + events.append("teardown-\(value)") + }), + input: "session", + ) + #expect(events == ["launch", "teardown-session", "launch"]) #expect(runner.phase.isReady) } - @Test func resetRunsTeardownStepsInOrder() async { + @Test func teardownNodesRunInOrder() async { var events: [String] = [] - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("noop") { _, _ in }), + ) await runner.run() - await runner.teardown(LifecycleSteps { - LifecycleStep.work("stop-gps") { _ in events.append("stop-gps") } - LifecycleStep.work("clear-store") { _ in events.append("clear-store") } - LifecycleStep.work("clear-widget") { _ in events.append("clear-widget") } - }) + await runner.teardown( + LaunchPlan(FixtureStep("stop-gps") { _, _ in events.append("stop-gps") }) + .thenKeeping(FixtureStep("clear-store") { _, _ in + events.append("clear-store") + }) + .thenKeeping(FixtureStep("clear-widget") { _, _ in + events.append("clear-widget") + }), + input: (), + ) #expect(events == ["stop-gps", "clear-store", "clear-widget"]) } - @Test func resetStepCanPresentTeardownUI() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) + @Test func teardownGateParksAndResumes() async throws { + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("noop") { _, _ in }), + ) await runner.run() let task = Task { @MainActor in - await runner.teardown(LifecycleSteps { - LifecycleStep.interactive("signing-out") { _ in Text("Signing out") } - }) + await runner.teardown( + LaunchPlan(FixtureStep("prepare") { _, _ in "account" }) + .gate(FixtureGate("signing-out")), + input: (), + ) } - try await waitUntil { runner.phase.isRunning("signing-out") } - #expect(runner.phase.runningBridge?.presentation != nil) + try await waitUntil { runner.phase.isAwaitingGate("signing-out") } + #expect(runner.phase.gateHandle?.value as? String == "account") - runner.phase.runningBridge?.complete() + runner.phase.gateHandle?.complete() await task.value #expect(runner.phase.isReady) } + @Test func teardownDetachedChildrenDrainBeforeTheRelaunch() async { + var events: [String] = [] + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("launch") { _, _ in + events.append("launch") + }), + ) + await runner.run() + events.removeAll() + + await runner.teardown( + LaunchPlan(FixtureStep("erase") { _, _ in events.append("erase") }) + .detached { + FixtureStep("flush") { _, _ in + // Yield so the trunk finishes first; the relaunch must + // still wait for this child — no torn-down-world work + // may overlap the fresh launch. + await Task.yield() + events.append("flush") + } + }, + input: (), + ) + #expect(events == ["erase", "flush", "launch"]) + #expect(runner.phase.isReady) + } + @Test func failedTeardownParksInFailedAndSkipsRelaunch() async { var relaunched = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("launch") { _ in relaunched = true } - }) + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("launch") { _, _ in relaunched = true }), + ) await runner.run() relaunched = false - await runner.teardown(LifecycleSteps { - LifecycleStep.work("teardown") { _ in throw ResetError() } - }) + await runner.teardown( + LaunchPlan(FixtureStep("teardown") { _, _ in throw ResetError() }), + input: (), + ) #expect(runner.phase.failed(at: "teardown")) #expect(!relaunched) } - @Test func retryAfterFailedTeardownReRunsTeardownThenRelaunches() async throws { + @Test func teardownNodesMayReuseLaunchNodeIDs() async { + // Teardown starts from an empty run-once set (the launch attempt is + // over), so a teardown node may share a launch node's ID and still + // run — no cross-plan disjointness precondition needed, because there + // is no retry re-walk that would consult a live launch memo. var events: [String] = [] - var shouldFailErase = true - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("launch") { _ in events.append("launch") } - }) + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("shared-id") { _, _ in + events.append("launch-side") + }), + ) await runner.run() - events.removeAll() - await runner.teardown(LifecycleSteps { - LifecycleStep.work("erase") { _ in - events.append("erase") - if shouldFailErase { throw ResetError() } - } - LifecycleStep.work("clear-prefs") { _ in events.append("clear-prefs") } - }) - #expect(runner.phase.failed(at: "erase")) - #expect(events == ["erase"]) - - // Retry must resume the teardown (re-erasing) and only then relaunch — - // not silently re-drive the launch sequence over un-torn-down state. - shouldFailErase = false - events.removeAll() - runner.retry() - try await waitUntil { runner.phase.isReady } - #expect(events == ["erase", "clear-prefs", "launch"]) + await runner.teardown( + LaunchPlan(FixtureStep("shared-id") { _, _ in + events.append("teardown-side") + }), + input: (), + ) + #expect(events == ["launch-side", "teardown-side", "launch-side"]) + #expect(runner.phase.isReady) } - @Test func retryAfterFailedTeardownTailResumesWithoutReErasing() async throws { - var events: [String] = [] - var shouldFailPrefs = true - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("launch") { _ in events.append("launch") } - }) - await runner.run() - events.removeAll() - - await runner.teardown(LifecycleSteps { - LifecycleStep.work("erase") { _ in events.append("erase") } - LifecycleStep.work("clear-prefs") { _ in - events.append("clear-prefs") - if shouldFailPrefs { throw ResetError() } - } - }) - #expect(runner.phase.failed(at: "clear-prefs")) - #expect(events == ["erase", "clear-prefs"]) - - // The earlier teardown step ("erase") already succeeded, so retry resumes - // from the failed step rather than re-running it, then relaunches. - shouldFailPrefs = false - events.removeAll() - runner.retry() - try await waitUntil { runner.phase.isReady } - #expect(events == ["clear-prefs", "launch"]) + @Test func teardownCancelsAParkedGateInsteadOfHanging() async throws { + // Without cooperative cancellation, `teardown()` would await the run + // drive forever: it is parked on a gate waiting for a tap that never + // comes. + var teardownRan = false + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("root") { _, _ in "session" }) + // After teardown clears the gate, the relaunch skips it and + // runs to completion. + .gate(FixtureGate("gate") { _ in !teardownRan }), + ) + let runTask = Task { @MainActor in await runner.run() } + try await waitUntil { runner.phase.isAwaitingGate("gate") } + + await runner.teardown( + LaunchPlan(FixtureStep("teardown") { _, _ in teardownRan = true }), + input: (), + ) + + await runTask.value + #expect(teardownRan) + // The cancelled gate was treated as a drained drive, not a failure. + #expect(runner.phase.failure == nil) + #expect(runner.phase.isReady) } } diff --git a/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift b/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift index a2e29046..24c1a109 100644 --- a/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift +++ b/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift @@ -1,212 +1,387 @@ @_spi(Testing) @testable import LifecycleKit -import SwiftUI import Testing private struct StepError: Error {} @MainActor struct LifecycleRunnerDriveTests { - @Test func runsStepsInDeclarationOrder() async { + @Test func runsTrunkNodesInDeclarationOrderAndThreadsTheValue() async { var executed: [String] = [] - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in executed.append("a") } - LifecycleStep.work("b") { _ in executed.append("b") } - LifecycleStep.work("c") { _ in executed.append("c") } - }) + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("open") { _, _ in + executed.append("open") + return 41 + }) + .then(FixtureStep("increment") { value, _ in + executed.append("increment") + return value + 1 + }) + .thenKeeping(FixtureStep("keep") { value, _ in + executed.append("keep-\(value)") + }), + ) await runner.run() - #expect(executed == ["a", "b", "c"]) + #expect(executed == ["open", "increment", "keep-42"]) #expect(runner.phase.isReady) + // .ready carries the trunk's output — the app's input. + #expect(runner.phase.readyValue == 42) } - @Test func skipsStepsWhoseConditionIsFalse() async { + @Test func filtersPassThroughNodesByLaunchReason() async { var executed: [String] = [] - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in executed.append("a") } - LifecycleStep.work("skip", condition: { false }) { _ in executed.append("skip") } - LifecycleStep.work("c") { _ in executed.append("c") } - }) + let runner = LifecycleRunner( + reason: .background(.location), + plan: LaunchPlan(FixtureStep("root") { _, _ in + executed.append("root") + return "session" + }) + .thenKeeping(FixtureStep("always") { _, _ in executed.append("always") }) + .thenKeeping(FixtureStep("fg", modes: .foreground) { _, _ in + executed.append("fg") + }) + .thenKeeping(FixtureStep("bg", modes: .background) { _, _ in + executed.append("bg") + }), + ) await runner.run() - #expect(executed == ["a", "c"]) - } - - @Test func filtersStepsByLaunchReason() async { - var executed: [String] = [] - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps { - LifecycleStep.work("always") { _ in executed.append("always") } - LifecycleStep.work("fg", modes: .foreground) { _ in executed.append("fg") } - LifecycleStep.work("bg", modes: .background) { _ in executed.append("bg") } - }) - await runner.run() - #expect(executed == ["always", "bg"]) + #expect(executed == ["root", "always", "bg"]) + #expect(runner.phase.isReady) } - @Test func interactiveStepsAreSkippedInBackground() async { - var executed: [String] = [] - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in executed.append("a") } - LifecycleStep - .interactive("onboarding", perform: { _ in executed.append("onboarding") }) { _ in - Text("onboarding") - } - LifecycleStep.work("c") { _ in executed.append("c") } - }) + @Test func gatesAreSkippedInBackground() async { + // Gates default to .foreground: a headless launch skips them (and + // never deadlocks waiting for a tap that can't come). + var evaluated = false + let runner = LifecycleRunner( + reason: .background(.location), + plan: LaunchPlan(FixtureStep("root") { _, _ in "session" }) + .gate(FixtureGate("onboarding") { _ in + evaluated = true + return true + }), + ) await runner.run() - #expect(executed == ["a", "c"]) #expect(runner.phase.isReady) + #expect(!evaluated) } @Test func runIsIdempotent() async { var count = 0 - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in count += 1 } - }) + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("a") { _, _ in count += 1 }), + ) await runner.run() await runner.run() #expect(count == 1) } } +@MainActor +struct LifecycleRunnerDetachedTests { + @Test func detachedChildrenDoNotBlockReady() async throws { + let (parked, release) = AsyncStream.makeStream(of: Void.self) + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("root") { _, _ in "session" }) + .detached { + FixtureStep("slow") { _, _ in + for await _ in parked {} + } + }, + ) + let task = Task { @MainActor in await runner.run() } + // .ready is published as soon as the trunk finishes, while the child + // is still parked. + try await waitUntil { runner.phase.isReady } + release.finish() + await task.value + #expect(runner.phase.readyValue == "session") + } + + @Test func detachedChildrenReceiveTheTrunkValue() async { + var seen: [String] = [] + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("root") { _, _ in "session" }) + .detached { + FixtureStep("child") { value, _ in seen.append(value) } + }, + ) + await runner.run() + #expect(seen == ["session"]) + } + + @Test func detachedFailureIsRecordedButNeverFatal() async { + var ran = false + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("root") { _, _ in "session" }) + .detached { + FixtureStep("boom") { _, _ in throw StepError() } + FixtureStep("fine") { _, _ in ran = true } + }, + ) + await runner.run() + #expect(runner.phase.isReady) + #expect(ran) + #expect(runner.detachedFailures.map(\.stepID) == [AnyHashable("boom")]) + #expect(runner.detachedFailures.first?.error is StepError) + } + + @Test func detachedChildrenHonorModeGating() async { + var executed: [String] = [] + let runner = LifecycleRunner( + reason: .background(.location), + plan: LaunchPlan(FixtureStep("root") { _, _ in "session" }) + .detached { + FixtureStep("fg", modes: .foreground) { _, _ in + executed.append("fg") + } + FixtureStep("bg", modes: .background) { _, _ in + executed.append("bg") + } + }, + ) + await runner.run() + #expect(executed == ["bg"]) + } +} + +@MainActor +struct LifecycleRunnerGateTests { + @Test func gateParksTheTrunkUntilResolved() async throws { + var executed: [String] = [] + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("root") { _, _ in + executed.append("root") + return "session" + }) + .gate(FixtureGate("onboarding")) + .thenKeeping(FixtureStep("after") { _, _ in executed.append("after") }), + ) + let task = Task { @MainActor in await runner.run() } + try await waitUntil { runner.phase.isAwaitingGate("onboarding") } + #expect(executed == ["root"]) + #expect(!runner.phase.isReady) + + runner.phase.gateHandle?.complete() + await task.value + #expect(executed == ["root", "after"]) + #expect(runner.phase.isReady) + } + + @Test func gateEvaluatesIsNeededAgainstTheTrunkValue() async { + var seen: [String] = [] + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("root") { _, _ in "session" }) + .gate(FixtureGate("onboarding") { value in + seen.append(value) + return false + }), + ) + await runner.run() + // Not needed → skipped, the value flows through, no park. + #expect(runner.phase.readyValue == "session") + #expect(seen == ["session"]) + } + + @Test func gateFailurePropagatesToFailedPhase() async throws { + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("root") { _, _ in "session" }) + .gate(FixtureGate("onboarding")), + ) + let task = Task { @MainActor in await runner.run() } + try await waitUntil { runner.phase.isAwaitingGate("onboarding") } + runner.phase.gateHandle?.fail(StepError()) + await task.value + #expect(runner.phase.failed(at: "onboarding")) + } + + @Test func gateHandleCarriesTheGateTypeAndValueForTheRegistry() async throws { + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("root") { _, _ in "session" }) + .gate(FixtureGate("onboarding")), + ) + let task = Task { @MainActor in await runner.run() } + try await waitUntil { runner.phase.isAwaitingGate("onboarding") } + let handle = try #require(runner.phase.gateHandle) + #expect(handle.gateType == ObjectIdentifier(FixtureGate.self)) + #expect(handle.value as? String == "session") + handle.complete() + await task.value + } + + @Test func contextProgressIsReadableWhileAStepRuns() async throws { + let (parked, release) = AsyncStream.makeStream(of: Void.self) + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("root") { _, context in + context.progress = 0.5 + context.message = "opening" + for await _ in parked {} + return "session" + }), + ) + let task = Task { @MainActor in await runner.run() } + try await waitUntil { runner.phase.isRunning("root") } + #expect(runner.phase.runningContext?.progress == 0.5) + #expect(runner.phase.runningContext?.message == "opening") + release.finish() + await task.value + #expect(runner.phase.isReady) + } +} + @MainActor struct LifecycleRunnerForegroundPromotionTests { - @Test func enterForegroundReDrivesAndRunsForegroundOnlySteps() async { + @Test func enterForegroundReDrivesAndRunsForegroundOnlyNodes() async { var executed: [String] = [] - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps { - LifecycleStep.work("store") { _ in executed.append("store") } - LifecycleStep - .work("onboarding", modes: .foreground) { _ in executed.append("onboarding") } - }) + let runner = LifecycleRunner( + reason: .background(.location), + plan: LaunchPlan(FixtureStep("store") { _, _ in + executed.append("store") + return "session" + }) + .thenKeeping(FixtureStep("onboarding", modes: .foreground) { _, _ in + executed.append("onboarding") + }), + ) await runner.run() - // The headless background drive ran only the unrestricted step. + // The headless background drive ran only the unrestricted node. #expect(executed == ["store"]) #expect(runner.reason.buildsNoViewTree) await runner.enterForeground() - // Promotion re-drives from the top, but the already-completed unrestricted - // step is skipped (run-once); only the now-applicable foreground-only step - // runs. + // Promotion re-drives from the top, but the already-completed + // unrestricted node is skipped (memoized); only the now-applicable + // foreground-only node runs. #expect(executed == ["store", "onboarding"]) #expect(!runner.reason.buildsNoViewTree) #expect(runner.phase.isReady) } - @Test func undeterminedLaunchRunsBackgroundStepsThenPromotesToForeground() async { + @Test func undeterminedLaunchRunsBackgroundNodesThenPromotesToForeground() async { var executed: [String] = [] - let runner = LifecycleRunner(reason: .undetermined, sequence: LifecycleSteps { - LifecycleStep.work("store") { _ in executed.append("store") } - LifecycleStep - .work("onboarding", modes: .foreground) { _ in executed.append("onboarding") } - }) + let runner = LifecycleRunner( + reason: .undetermined, + plan: LaunchPlan(FixtureStep("store") { _, _ in + executed.append("store") + return "session" + }) + .thenKeeping(FixtureStep("onboarding", modes: .foreground) { _, _ in + executed.append("onboarding") + }), + ) await runner.run() // Undetermined gates to the background-safe subset: the foreground-only - // step is skipped and the host builds no view tree. + // node is skipped and the host builds no view tree. #expect(executed == ["store"]) #expect(runner.reason.buildsNoViewTree) await runner.enterForeground() - // A scene activated: the launch resolves to foreground and the - // foreground-only step runs. The already-completed "store" is skipped - // (run-once), so it doesn't run a second time. #expect(executed == ["store", "onboarding"]) - #expect(!runner.reason.buildsNoViewTree) #expect(runner.reason == .userForeground) #expect(runner.phase.isReady) } - @Test func retryAfterPromotionSkipsAStepAlreadyCompletedInTheHeadlessDrive() async throws { - // Run-once spans a promotion + a `retry()` within the same attempt: a - // later step that already completed during the headless drive must not - // re-run when `retry()` resumes from an *earlier* foreground-only step - // that failed on promotion. + @Test func promotionReEvaluatesAGateSkippedHeadless() async throws { + // A gate skipped during the headless drive is *not* memoized: once a + // scene promotes the launch, the re-drive parks on it. + let runner = LifecycleRunner( + reason: .undetermined, + plan: LaunchPlan(FixtureStep("store") { _, _ in "session" }) + .gate(FixtureGate("onboarding")), + ) + await runner.run() + #expect(runner.phase.isReady) + + let promote = Task { @MainActor in await runner.enterForeground() } + try await waitUntil { runner.phase.isAwaitingGate("onboarding") } + runner.phase.gateHandle?.complete() + await promote.value + #expect(runner.phase.isReady) + } + + @Test func promotionSkipsNodesCompletedInTheHeadlessDrive() async { + // Run-once spans the headless drive and the promotion re-drive: a node + // that already completed while headless must not re-run when + // `enterForeground()` re-drives from the top for the now-applicable + // foreground-only node. var executed: [String] = [] - var onboardingShouldFail = true - let runner = LifecycleRunner(reason: .undetermined, sequence: LifecycleSteps { - LifecycleStep.work("store") { _ in executed.append("store") } - LifecycleStep.work("onboarding", modes: .foreground) { _ in + let runner = LifecycleRunner( + reason: .undetermined, + plan: LaunchPlan(FixtureStep("store") { _, _ in + executed.append("store") + return "session" + }) + .thenKeeping(FixtureStep("onboarding", modes: .foreground) { _, _ in executed.append("onboarding") - if onboardingShouldFail { throw StepError() } - } - LifecycleStep.work("widget") { _ in executed.append("widget") } - }) - - // Headless drive: the background-safe "store" and "widget" complete; the - // foreground-only "onboarding" (index between them) is skipped. + }) + .thenKeeping(FixtureStep("widget") { _, _ in + executed.append("widget") + }), + ) + + // Headless drive: the background-safe "store" and "widget" complete; + // the foreground-only "onboarding" (between them) is skipped. await runner.run() #expect(executed == ["store", "widget"]) #expect(runner.phase.isReady) - // Promotion re-drives from the top: "store" is skipped (completed), the - // now-applicable "onboarding" runs and fails. + // Promotion re-drives from the top: "store" and "widget" are skipped + // (memoized), only the now-applicable "onboarding" runs. await runner.enterForeground() - #expect(runner.phase.failed(at: "onboarding")) #expect(executed == ["store", "widget", "onboarding"]) - - // Retry resumes from the failed "onboarding" (now succeeds). "widget", - // which sits *after* it but already completed in the headless drive, is - // skipped rather than run a second time. - onboardingShouldFail = false - runner.retry() - try await waitUntil { runner.phase.isReady } - #expect(executed == ["store", "widget", "onboarding", "onboarding"]) + #expect(runner.phase.isReady) } @Test func enterForegroundIsNoOpForAForegroundLaunch() async { var count = 0 - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in count += 1 } - }) + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("a") { _, _ in count += 1 }), + ) await runner.run() await runner.enterForeground() #expect(count == 1) #expect(runner.phase.isReady) } - @Test func backgroundWorkStepCallingWaitForResolutionDoesNotReachReadyUntilPromoted( - ) async throws { - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps { - LifecycleStep.work("wait") { bridge in try await bridge.waitForResolution() } - }) - let runTask = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("wait") } - - // Regression: a background `.work` step parked on `waitForResolution()` - // must not drain to `.ready` while still headless — there is no UI to - // resolve it. - try await Task.sleep(for: .milliseconds(50)) - #expect(runner.reason.buildsNoViewTree) - #expect(runner.phase.isRunning("wait")) - - let promote = Task { @MainActor in await runner.enterForeground() } - try await waitUntil { !runner.reason.buildsNoViewTree } - runner.phase.runningBridge?.complete() - await runTask.value - await promote.value - #expect(runner.phase.isReady) - } - @Test func enterForegroundCancelsAndDrainsAnInFlightBackgroundDrive() async throws { var starts = 0 var inFlight = 0 var maxInFlight = 0 - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps { - LifecycleStep.work("slow") { bridge in + var handles: [LifecycleGateHandle] = [] + let runner = LifecycleRunner( + reason: .background(.location), + plan: LaunchPlan(FixtureStep("slow") { _, _ in starts += 1 inFlight += 1 defer { inFlight -= 1 } maxInFlight = max(maxInFlight, inFlight) - try await bridge.waitForResolution() - } - }) + // Park on a cancellation-aware wait the test resolves. + let handle = LifecycleGateHandle(id: "park-\(starts)", reason: .userForeground) + handles.append(handle) + try await handle.waitForResolution() + return "session" + }), + ) let runTask = Task { @MainActor in await runner.run() } try await waitUntil { runner.phase.isRunning("slow") } // Promote while the background drive is parked in "slow". Promotion - // cancels that drive (its `waitForResolution()` throws), drains it, and - // only then re-drives "slow" for the foreground launch — never two at - // once. + // cancels that drive (its wait throws), drains it, and only then + // re-drives "slow" for the foreground launch — never two at once. let promote = Task { @MainActor in await runner.enterForeground() } try await waitUntil { starts == 2 } - runner.phase.runningBridge?.complete() + handles.last?.complete() await runTask.value await promote.value @@ -222,48 +397,35 @@ struct LifecycleRunnerForegroundPromotionTests { // store-open failure did exactly this). That dying drive must not park // the runner in `.failed` — the promoted drive owns the phase and // re-runs the step itself. - let (blockedStep, releaseBlockedStep) = AsyncStream.makeStream(of: Void.self) - let (gatedCondition, releaseGatedCondition) = AsyncStream.makeStream(of: Void.self) + let (blockedFirst, _) = AsyncStream.makeStream(of: Void.self) + let (blockedSecond, releaseSecond) = AsyncStream.makeStream(of: Void.self) var attempts = 0 - var conditionChecks = 0 - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps { - LifecycleStep( - id: "store", - condition: { - conditionChecks += 1 - if conditionChecks > 1 { - // Hold the promoted drive here — before it publishes - // any phase of its own — so the test can observe what - // the dying drive left behind. - for await _ in gatedCondition {} - } - return true - }, - ) { _ in + let runner = LifecycleRunner( + reason: .background(.location), + plan: LaunchPlan(FixtureStep("store") { _, _ in attempts += 1 if attempts == 1 { - // Park until released, then fail for real — after the - // promotion has already superseded this drive. - for await _ in blockedStep {} + // Parks until the promotion cancels this drive (the + // stream iteration ends on cancellation), then fails for + // real — after the promotion has superseded it. + for await _ in blockedFirst {} throw StepError() } - } - }) + for await _ in blockedSecond {} + return "session" + }), + ) let runTask = Task { @MainActor in await runner.run() } try await waitUntil { runner.phase.isRunning("store") } let promote = Task { @MainActor in await runner.enterForeground() } - try await waitUntil { !runner.reason.buildsNoViewTree } - - // Let the superseded drive's step throw now that the promotion owns - // the phase, and wait for the promoted drive to reach the gated - // condition (which it only does after fully draining the dying drive). - releaseBlockedStep.finish() - try await waitUntil { conditionChecks > 1 } + // The promoted drive only reaches its attempt after fully draining the + // dying drive — whose real error must have been discarded. + try await waitUntil { attempts == 2 } #expect(runner.phase.failure == nil) - releaseGatedCondition.finish() + releaseSecond.finish() await promote.value await runTask.value #expect(attempts == 2) @@ -271,207 +433,73 @@ struct LifecycleRunnerForegroundPromotionTests { } } -@MainActor -struct LifecycleRunnerCancellationTests { - @Test func resetCancelsAParkedInteractiveStepInsteadOfHanging() async throws { - // Without cooperative cancellation, `teardown()` would await the run - // drive forever: it is parked on an interactive step waiting for a tap - // that never comes. - var teardownRan = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - // After teardown clears the gate, the relaunch skips it and runs to - // completion. - LifecycleStep.interactive("gate", condition: { !teardownRan }) { _ in - Text("gate") - } - }) - let runTask = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("gate") } - - await runner.teardown(LifecycleSteps { - LifecycleStep.work("teardown") { _ in teardownRan = true } - }) - - await runTask.value - #expect(teardownRan) - // The cancelled gate was treated as a drained drive, not a failure. - #expect(runner.phase.failure == nil) - #expect(runner.phase.isReady) - } -} - @MainActor struct LifecycleRunnerFailureTests { - @Test func thrownErrorParksInFailedAndStopsSubsequentSteps() async { + @Test func thrownErrorParksInFailedAndStopsSubsequentNodes() async { var executed: [String] = [] - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in executed.append("a") } - LifecycleStep.work("b") { _ in throw StepError() } - LifecycleStep.work("c") { _ in executed.append("c") } - }) + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("a") { _, _ in + executed.append("a") + return "session" + }) + .thenKeeping(FixtureStep("b") { _, _ in throw StepError() }) + .thenKeeping(FixtureStep("c") { _, _ in executed.append("c") }), + ) await runner.run() #expect(executed == ["a"]) #expect(runner.phase.failed(at: "b")) #expect(runner.phase.failure?.error is StepError) } - @Test func retryResumesFromFailedStep() async throws { - var executed: [String] = [] - var shouldFail = true - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in executed.append("a") } - LifecycleStep.work("b") { _ in - if shouldFail { throw StepError() } - executed.append("b") - } - LifecycleStep.work("c") { _ in executed.append("c") } - }) + @Test func failureIsTerminalAndRunDoesNotReDrive() async { + // No retry: once a node throws, the drive is done. A later `run()` + // awaits the finished drive rather than starting a new one, so the + // launch stays `.failed` and the failing node doesn't run again — the + // recovery is relaunching the app (a fresh process, fresh runner). + var attempts = 0 + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("boom") { _, _ in + attempts += 1 + throw StepError() + }), + ) await runner.run() - #expect(runner.phase.failed(at: "b")) - #expect(executed == ["a"]) + #expect(runner.phase.failed(at: "boom")) + #expect(attempts == 1) - shouldFail = false - runner.retry() - try await waitUntil { runner.phase.isReady } - #expect(executed == ["a", "b", "c"]) - } - - @Test func retryIsNoOpWhenNotFailed() async { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in } - }) await runner.run() - runner.retry() - #expect(runner.phase.isReady) - } - - @Test func retryIsNoOpWhenFailedStepIDDoesNotMatchAnyStep() async { - var executed = 0 - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in executed += 1 } - }) - await runner.run() - #expect(runner.phase.isReady) - - runner.injectFailureForTesting(LifecycleFailure(stepID: "missing", error: StepError())) - runner.retry() - try? await Task.sleep(for: .milliseconds(50)) - #expect(runner.phase.failed(at: "missing")) - #expect(executed == 1) - } - - @Test func bridgeFailurePropagatesToFailedPhase() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.interactive("gate") { _ in Text("x") } - }) - let task = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("gate") } - runner.phase.runningBridge?.fail(StepError()) - await task.value - #expect(runner.phase.failed(at: "gate")) + #expect(runner.phase.failed(at: "boom")) + #expect(attempts == 1) } } @MainActor -struct LifecycleRunnerInteractiveTests { - @Test func interactiveStepSuspendsUntilResolved() async throws { - var executed: [String] = [] - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in executed.append("a") } - LifecycleStep.interactive("gate") { _ in Text("gate") } - LifecycleStep.work("c") { _ in executed.append("c") } - }) - let task = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("gate") } - #expect(executed == ["a"]) - #expect(!runner.phase.isReady) - - runner.phase.runningBridge?.complete() - await task.value - #expect(executed == ["a", "c"]) - #expect(runner.phase.isReady) - } - - @Test func alwaysPresentationActivatesImmediately() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.interactive("ui") { _ in Text("ui") } - }) - let task = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("ui") } - #expect(runner.phase.runningBridge?.presentation != nil) - runner.phase.runningBridge?.complete() - await task.value - } - - @Test func whenFalsePresentationStaysSilent() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("s") { bridge in try await bridge.waitForResolution() } - .presenting(when: { false }) { _ in Text("x") } - }) - let task = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("s") } - #expect(runner.phase.runningBridge?.presentation == nil) - runner.phase.runningBridge?.complete() - await task.value - } - - @Test func deferredPresentationActivatesAfterDelay() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("slow") { bridge in try await bridge.waitForResolution() } - .presenting(after: .milliseconds(20)) { _ in Text("x") } - }) - let task = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("slow") } - try await waitUntil { runner.phase.runningBridge?.presentation != nil } - runner.phase.runningBridge?.complete() - await task.value - } +struct LifecycleRunnerPhaseTests { + private typealias Phase = LifecycleRunner.Phase - @Test func minVisibleHoldsADeferredPresentationAfterTheStepFinishes() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("slow") { bridge in try await bridge.waitForResolution() } - .presenting(after: .milliseconds(10), minVisible: .milliseconds(300)) { _ in - Text("x") - } - }) - let task = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("slow") } - try await waitUntil { runner.phase.runningBridge?.presentation != nil } - - // Finish the step right after the deferred UI appeared; minVisible must - // keep the runner from reaching .ready until the hold window elapses. - let shownAt = ContinuousClock.now - runner.phase.runningBridge?.complete() - try await waitUntil(timeout: .seconds(2)) { runner.phase.isReady } - #expect(shownAt.duration(to: .now) >= .milliseconds(200)) - await task.value + @Test func launchingAndRunningCollapseToTheSplashSurface() { + let running = Phase.running(LifecycleStepContext(stepID: "a", reason: .userForeground)) + #expect(Phase.launching.surfaceIdentity == .splash) + #expect(running.surfaceIdentity == .splash) } - @Test func minVisibleHoldsAnAlwaysPresentationAfterAFastStep() async { - // minVisible is unified across triggers: an `.always` presentation on a - // step that does no async work must still stay up for its hold window - // before the runner reaches .ready, just like the deferred path. - let startedAt = ContinuousClock.now - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("fast") { _ in } - .presenting(minVisible: .milliseconds(300)) { _ in Text("x") } - }) - await runner.run() - #expect(runner.phase.isReady) - #expect(startedAt.duration(to: .now) >= .milliseconds(200)) + @Test func gateFailureAndReadyAreDistinctSurfaces() { + let gate = Phase.awaitingGate(LifecycleGateHandle(id: "g", reason: .userForeground)) + let failed = Phase.failed(LifecycleFailure(stepID: "b", error: StepError())) + #expect(gate.surfaceIdentity == .gate("g")) + #expect(failed.surfaceIdentity == .failed("b")) + #expect(Phase.ready("session").surfaceIdentity == .ready) + #expect(gate.surfaceIdentity != Phase.launching.surfaceIdentity) } - @Test func progressIsReadableWhileStepRuns() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.interactive("p", perform: { bridge in - bridge.progress = 0.5 - try await bridge.waitForResolution() - }) { _ in Text("p") } - }) - let task = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("p") } - #expect(runner.phase.runningBridge?.progress == 0.5) - runner.phase.runningBridge?.complete() - await task.value + @Test func helpersProjectTheActiveCase() { + let context = LifecycleStepContext(stepID: "a", reason: .userForeground) + #expect(Phase.running(context).isRunning("a")) + #expect(Phase.running(context).runningStepID == AnyHashable("a")) + #expect(Phase.ready("session").readyValue == "session") + #expect(Phase.failed(LifecycleFailure(stepID: "b", error: StepError())).failed(at: "b")) + #expect(Phase.launching.isLaunching) } } diff --git a/Shared/LifecycleKit/Tests/LifecycleStepFixtures.swift b/Shared/LifecycleKit/Tests/LifecycleStepFixtures.swift new file mode 100644 index 00000000..232a04b9 --- /dev/null +++ b/Shared/LifecycleKit/Tests/LifecycleStepFixtures.swift @@ -0,0 +1,45 @@ +@testable import LifecycleKit + +/// A configurable typed step for engine/plan tests: identity, mode gating, +/// and a closure body, so each test declares exactly the behavior it needs +/// without minting a one-off conforming type. +struct FixtureStep: LifecycleStep { + let id: String + var modes: LifecycleModeSet = .all + let body: @MainActor (Input, LifecycleStepContext) async throws -> Output + + init( + _ id: String, + modes: LifecycleModeSet = .all, + body: @escaping @MainActor (Input, LifecycleStepContext) async throws -> Output, + ) { + self.id = id + self.modes = modes + self.body = body + } + + func run(_ input: Input, _ context: LifecycleStepContext) async throws -> Output { + try await body(input, context) + } +} + +/// A configurable gate for engine/plan tests, mirroring `FixtureStep`. +struct FixtureGate: LifecycleGate { + let id: String + var modes: LifecycleModeSet = .foreground + var needed: @MainActor (Value) async -> Bool + + init( + _ id: String, + modes: LifecycleModeSet = .foreground, + needed: @escaping @MainActor (Value) async -> Bool = { _ in true }, + ) { + self.id = id + self.modes = modes + self.needed = needed + } + + func isNeeded(_ value: Value) async -> Bool { + await needed(value) + } +} diff --git a/Shared/LifecycleKit/Tests/LifecycleStepTests.swift b/Shared/LifecycleKit/Tests/LifecycleStepTests.swift deleted file mode 100644 index e68c5e21..00000000 --- a/Shared/LifecycleKit/Tests/LifecycleStepTests.swift +++ /dev/null @@ -1,104 +0,0 @@ -@testable import LifecycleKit -import SwiftUI -import Testing - -@MainActor -struct LifecycleStepsBuilderTests { - @Test func builderPreservesDeclarationOrder() { - let sequence = LifecycleSteps { - LifecycleStep.work("a") { _ in } - LifecycleStep.work("b") { _ in } - LifecycleStep.work("c") { _ in } - } - #expect(sequence.steps.map(\.id) == ["a", "b", "c"] as [AnyHashable]) - } - - @Test func builderSupportsConditionalInclusion() { - func ids(includeMiddle: Bool) -> [AnyHashable] { - LifecycleSteps { - LifecycleStep.work("a") { _ in } - if includeMiddle { - LifecycleStep.work("b") { _ in } - } - LifecycleStep.work("c") { _ in } - }.steps.map(\.id) - } - #expect(ids(includeMiddle: true) == ["a", "b", "c"] as [AnyHashable]) - #expect(ids(includeMiddle: false) == ["a", "c"] as [AnyHashable]) - } - - @Test func builderSupportsLoops() { - let sequence = LifecycleSteps { - for name in ["x", "y", "z"] { - LifecycleStep.work(name) { _ in } - } - } - #expect(sequence.steps.map(\.id) == ["x", "y", "z"] as [AnyHashable]) - } -} - -@MainActor -struct LifecycleStepConfigurationTests { - @Test func defaultStepAppliesToEveryReason() { - let step = LifecycleStep.work("a") { _ in } - #expect(step.appliesTo(.userForeground)) - #expect(step.appliesTo(.background(.location))) - } - - @Test func foregroundOnlyStepSkipsBackground() { - let step = LifecycleStep.work("a", modes: .foreground) { _ in } - #expect(step.appliesTo(.userForeground)) - #expect(!step.appliesTo(.background(.location))) - } - - @Test func backgroundOnlyStepSkipsForeground() { - let step = LifecycleStep.work("a", modes: .background) { _ in } - #expect(!step.appliesTo(.userForeground)) - #expect(step.appliesTo(.background(.remoteNotification))) - } - - @Test func defaultConditionIsTrue() async { - let step = LifecycleStep.work("a") { _ in } - #expect(await step.condition()) - } - - @Test func workConditionGatesTheStep() async { - let flag = MutableFlag() - let step = LifecycleStep.work("a", condition: { flag.isOn }) { _ in } - #expect(await step.condition() == false) - flag.isOn = true - #expect(await step.condition() == true) - } - - @Test func initConditionGatesTheStep() async { - let flag = MutableFlag() - let step = LifecycleStep(id: "a", condition: { flag.isOn }) { _ in } - #expect(await step.condition() == false) - flag.isOn = true - #expect(await step.condition() == true) - } - - @Test func plainWorkHasNoPresentation() { - #expect(LifecycleStep.work("a") { _ in }.presentation == nil) - } - - @Test func presentingAttachesPresentation() { - let step = LifecycleStep.work("a") { _ in }.presenting { _ in Text("hi") } - #expect(step.presentation != nil) - } - - @Test func interactiveStepPresentsItsView() { - let step = LifecycleStep.interactive("onboarding") { _ in Text("onboarding") } - #expect(step.presentation != nil) - } -} - -/// A mutable reference the `@MainActor` condition closures can flip *after* -/// they've been captured. `LifecycleStep.condition` is a `@MainActor` (and thus -/// `Sendable`) closure, so capturing and later mutating a plain local `var` -/// trips Swift 6's "mutated after capture by sendable closure"; reading through -/// a reference doesn't. -@MainActor -private final class MutableFlag { - var isOn = false -} diff --git a/Shared/LifecycleKitUI/AGENTS.md b/Shared/LifecycleKitUI/AGENTS.md new file mode 100644 index 00000000..f5065407 --- /dev/null +++ b/Shared/LifecycleKitUI/AGENTS.md @@ -0,0 +1,64 @@ +# LifecycleKitUI – Module Shape + +The SwiftUI layer for [LifecycleKit](../LifecycleKit): `LifecycleContainer` +renders a `LifecycleRunner`'s `phase` (splash / gate view / failure / app +content), `GateView(for:content:)` registers gate views by gate *type*, and +`LifecycleProxy` (`@Environment(\.lifecycle)`) lets nested views reach +`enterForeground()`/`teardown(_:input:)`. The failure surface is terminal +(no retry). See [`README.md`](README.md) for the full +narrative and API. + +This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns +build system, formatting, and global conventions. Read that first. + +## Scope & dependencies + +- **SwiftUI + LifecycleKit only.** No app imports — app-specific launch UI + (splashes, onboarding) lives in the consumer (e.g. `WhereUI`). +- The engine/UI split is deliberate: LifecycleKit must stay renderable-state + only (no SwiftUI import); anything that builds a `View` belongs here. + +## Invariants + +- **`content` is only ever built from `.ready`'s carried value** — never + re-read from shared state. Don't add a code path that renders the app + surface without the launch output in hand. It is built as soon as that value + exists (from `phase.readyValue`), *including while a splash hold still covers + it*, so the hold warms the destination instead of paying for its `.task`s and + first layout in the frame the reveal starts. Keep it to **one** `content` call + site: rendering it from separate held/revealed branches gives SwiftUI two + identities and rebuilds the whole destination at the reveal, defeating that. +- **No view tree when `reason.buildsNoViewTree`** — even at `.ready`. +- **Every splash-showing state resolves to one `LaunchOverlay.splash` case,** so + the splash keeps a single identity across `.launching` → each step → the hold. + Don't render it from per-phase `switch` arms: SwiftUI would treat each arm's + splash as a different view and remount it at every boundary, resetting the + animations and caption timers a long-lived splash (e.g. Where's + `LaunchSplashView`) documents as uninterrupted. +- **`minimumSplashDuration` only holds a splash that was actually shown.** The + hold is armed when the splash *appears*, so a launch already `.ready` when the + container mounts reveals immediately rather than stalling behind a minimum for + a splash nobody saw (`minimumSplashDurationDoesNotHoldWhenNoSplashWasShown` + guards this; the timing half is device-verified, not host-testable). Assert + "revealed" via the *absent splash*, not via `content` — content is built + during a hold too, so it no longer distinguishes the two. `isShowingSplash` + must read the runner's own surface, never `displayedSurfaceIdentity` — that + reports `.splash` for a held `.ready`, which would re-arm the hold from its own + release and never reveal. +- **Gate views resolve only their own handle.** The registry hands each gate + view the parked `LifecycleGateHandle`; a superseded drive's handle no-ops, + so don't route gate resolution through anything but the handle. +- **One registration per gate type** (construction `precondition`); a parked + gate with no registration is a programmer error — the container logs it + (`os`, subsystem `com.stuff.lifecyclekitui`) and fails the gate's handle + with `MissingGateViewError`, landing on the failure surface (visible and + named (though terminal), identical in debug and release) rather than an indefinite + splash. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` via the +`LifecycleKitUITests` bundle: container tests host `LifecycleContainer` and +assert which branch renders (probe views), proxy tests cover the +connected/disconnected environment paths. Engine behavior is tested in +LifecycleKit's own bundle — don't duplicate it here. diff --git a/Shared/LifecycleKitUI/README.md b/Shared/LifecycleKitUI/README.md new file mode 100644 index 00000000..06496d99 --- /dev/null +++ b/Shared/LifecycleKitUI/README.md @@ -0,0 +1,109 @@ +# LifecycleKitUI + +The SwiftUI layer for [LifecycleKit](../LifecycleKit): the container that +renders a `LifecycleRunner`'s observable `phase`, the gate-view registry, and +the environment proxy nested views use to reach the runner. The engine itself +(steps, plans, the runner) lives in LifecycleKit and knows nothing about +views; this module owns everything rendered. + +## Quick start + +```swift +import LifecycleKit +import LifecycleKitUI + +LifecycleContainer( + runner, // LifecycleRunner + splash: { context in + // The running step's context is handed in so a splash can show a + // caption/progress; Where's own splash ignores it and self-manages. + MySplashView(status: context?.message) + }, + failure: { failure in + LifecycleFailureView(failure: failure) // terminal — no retry + }, + gates: { + GateView(for: OnboardingGate.self) { handle, session in + OnboardingView(gate: handle, session: session) + } + }, +) { session in + MainTabs(session: session) // non-optional: .ready carries it +} +``` + +## What renders when + +| Runner phase | Surface | +|---------------------|-------------------------------------------------| +| `.launching` | `splash(nil)` | +| `.running(context)` | `splash(context)` — caption/progress off the context | +| `.awaitingGate(handle)` | the `GateView` registered for the gate's *type* | +| `.failed(failure)` | `failure(failure)` — terminal, no retry | +| `.ready(value)` | `content(value)` | + +- **`content` receives the launch's output.** `.ready` carries the trunk's + final value, so the app surface cannot be built without the proof the + launch produced — no optional re-reads from shared state. +- **Gate views are registered by gate type**, which statically recovers the + gate's `Value`: the view gets `(LifecycleGateHandle, Value)` and resolves + the handle (`complete()` / `fail(_:)`) to resume the trunk. A parked gate + with no registration is logged and failed with `MissingGateViewError`, so + the launch lands on the (terminal) failure surface — visible and named — + instead of an indefinite splash; debug and release behave identically. +- **Headless launches render nothing.** When `reason.buildsNoViewTree` (a + `.background` relaunch, or `.undetermined` before promotion) the container + renders `EmptyView()` — even at `.ready` — so `content` is never built for + a launch nobody sees. +- **Surface transitions animate on identity.** The phase's surface identity + collapses `launching`/`running` into one splash surface, so a step + advancing never re-triggers the transition; reaching a gate, `.failed`, or + `.ready` animates with the caller-supplied `transition`/`animation`. + Launch surfaces sit above `content` so a leaving splash plays its removal + transition over the entering app. + +## Holding the splash on a fast launch + +A fast launch can finish before the splash is ever seen (an optimized build +may reach `.ready` in a few frames), so its reveal flashes past. Pass +`minimumSplashDuration` to hold the splash up for at least that long once it +first appears, then play the reveal — it defaults to `.zero` (reveal as soon +as the runner is ready). The hold is per-appearance, so a reset relaunch (or +the return from a gate) gets its own minimum: + +```swift +LifecycleContainer(runner, minimumSplashDuration: .seconds(1)) { session in + MainTabs(session: session) +} +``` + +While the hold is in effect the container keeps reporting the *splash* surface +identity, so the reveal transition fires when the hold releases rather than the +instant the runner reports `.ready`. + +The hold isn't dead time. `content` is built as soon as the launch produces its +value — beneath the splash that's still covering it — so the destination's +`.task`s and first layout happen *during* the hold instead of in the frame the +reveal animation starts. It stays gated on the launch's output either way +(that value is only readable from `.ready`), so nothing is built speculatively: +the hold just stops being a stall and starts being a warm-up. + +## Reaching the runner from nested views + +`LifecycleContainer` publishes a `LifecycleProxy` under +`@Environment(\.lifecycle)`. The proxy is non-generic (environment values +must be), forwards `enterForeground()` / `teardown(_:input:)`, +and is *disconnected* by default: calling through it without a container +above asserts in debug and no-ops in release. + +```swift +@Environment(\.lifecycle) private var lifecycle +... +await lifecycle.teardown(WhereLaunch.resetPlan(for: model), input: session) +``` + +## Defaults + +`LifecycleSplash` (a plain centered spinner) and `LifecycleFailureView` (a +terminal `ContentUnavailableView`, no retry button) are used by the convenience +initializers when the host passes no custom surfaces. diff --git a/Shared/LifecycleKitUI/Sources/GateView.swift b/Shared/LifecycleKitUI/Sources/GateView.swift new file mode 100644 index 00000000..397fec81 --- /dev/null +++ b/Shared/LifecycleKitUI/Sources/GateView.swift @@ -0,0 +1,98 @@ +import LifecycleKit +import SwiftUI + +/// Registers the view for one gate *type* in a `LifecycleContainer`. +/// +/// Registration is keyed by the gate's concrete type, which is how the +/// registry recovers the gate's `Value` statically: the content closure +/// receives the parked `LifecycleGateHandle` *and* the typed trunk value at +/// the gate, so a gate view is handed its dependencies rather than trusting +/// the environment to have been populated by an earlier step. +/// +/// ```swift +/// gates: { +/// GateView(for: OnboardingGate.self) { handle, session in +/// OnboardingView(gate: handle, session: session) +/// } +/// } +/// ``` +@MainActor +public struct GateView { + let registration: GateRegistration + + public init( + for _: G.Type, + @ViewBuilder content: @escaping @MainActor (LifecycleGateHandle, G.Value) -> Content, + ) { + registration = GateRegistration(gateType: ObjectIdentifier(G.self)) { handle, value in + // The engine parked a gate of exactly this type (the handle + // carries it), so the erased trunk value is guaranteed to be the + // gate's Value. + AnyView(content(handle, value as! G.Value)) + } + } +} + +/// One erased gate-type → view entry, as `LifecycleContainer` stores it. +/// Built only by `GateView(for:content:)`, whose generic signature is where +/// the type recovery is checked. +@MainActor +public struct GateRegistration { + let gateType: ObjectIdentifier + let build: @MainActor (LifecycleGateHandle, any Sendable) -> AnyView +} + +/// A gate parked but its type has no `GateView` registration — a +/// misconfiguration (the plan gates on something the UI can't render). The +/// container fails the gate's handle with this error, so the launch lands on +/// the failure surface (visible and named, though terminal) instead of an indefinite +/// splash that reads as progress; the behavior is identical in debug and +/// release. +public struct MissingGateViewError: Error, LocalizedError { + /// The parked gate's identity, so the failure is diagnosable from the + /// failure surface or a log line alone. + public let gateID: AnyHashable + + public var errorDescription: String? { + String(localized: .failureGateUnregistered(String(describing: gateID))) + } +} + +/// Result builder for `LifecycleContainer`'s `gates:` parameter, with +/// `if`/`if-else`/`for` support so registrations can be included +/// conditionally. +@resultBuilder +@MainActor +public enum GateRegistrationsBuilder { + public static func buildExpression( + _ gateView: GateView, + ) -> [GateRegistration] { + [gateView.registration] + } + + public static func buildBlock(_ registrations: [GateRegistration]...) -> [GateRegistration] { + registrations.flatMap(\.self) + } + + public static func buildOptional(_ registrations: [GateRegistration]?) -> [GateRegistration] { + registrations ?? [] + } + + public static func buildEither(first registrations: [GateRegistration]) -> [GateRegistration] { + registrations + } + + public static func buildEither(second registrations: [GateRegistration]) -> [GateRegistration] { + registrations + } + + public static func buildArray(_ registrations: [[GateRegistration]]) -> [GateRegistration] { + registrations.flatMap(\.self) + } + + public static func buildLimitedAvailability( + _ registrations: [GateRegistration], + ) -> [GateRegistration] { + registrations + } +} diff --git a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift new file mode 100644 index 00000000..1534cdc0 --- /dev/null +++ b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift @@ -0,0 +1,370 @@ +import LifecycleKit +import os +import SwiftUI + +/// The root view that renders a `LifecycleRunner`'s `phase`. +/// +/// The launch plan is only the *prerequisites*; the destination — the app's +/// real, "logged-in" UI — is the `content` closure, shown when the runner +/// reaches `.ready` and handed the trunk's output (`Launch`), so the app +/// surface literally cannot be built without the value the launch produced. +/// Pass an already-built runner (created early, e.g. in the app delegate, so +/// a headless background launch works without a window). +/// +/// The `splash` and `failure` views are caller-injectable; convenience +/// initializers default them to the built-in `LifecycleSplash` / +/// `LifecycleFailureView`. Gate views are registered by gate *type* via +/// `GateView(for:content:)` — the engine's gates carry no views, and the +/// registry recovers each gate's `Value` statically, so the gate view +/// receives `(handle, value)` rather than trusting shared state. A proxy for +/// the runner is published into the environment (`\.lifecycle`) for nested +/// views to reach `enterForeground()`/`teardown(_:input:)`. A failed launch +/// is terminal — the failure surface offers no retry. +/// +/// Surface changes (splash → gate → failure → app `content`) are animated +/// with the caller-supplied `transition`/`animation` (a crossfade by +/// default), keyed on the phase's surface identity so a step *advancing* — +/// which keeps showing the splash — doesn't retrigger the transition and +/// flash it. The launch surfaces are layered above `content`, so a *leaving* +/// surface plays its removal transition over the *entering* destination (a +/// scale-up-and-fade reveal, say) instead of being clipped to a pop behind +/// it. +/// +/// For a launch that builds no view tree (`.background`, or an +/// `.undetermined` one not yet promoted), the container renders nothing at +/// all (iOS never shows UI for a headless relaunch and reclaims memory +/// aggressively), so `content` is never constructed even once the runner +/// reaches `.ready`. +public struct LifecycleContainer< + Launch: Sendable, + Content: View, + Splash: View, + Failure: View, +>: View { + private let runner: LifecycleRunner + private let transition: AnyTransition + private let animation: Animation? + private let minimumSplashDuration: Duration + private let splash: (LifecycleStepContext?) -> Splash + private let failureView: (LifecycleFailure) -> Failure + private let gates: [GateRegistration] + private let content: (Launch) -> Content + + /// When the splash may be dismissed: the deadline the current appearance's + /// `minimumSplashDuration` set. `nil` means nothing is holding the reveal — + /// no minimum was requested, no splash was shown, or the hold has elapsed. + @State private var splashHoldUntil: ContinuousClock.Instant? + + /// - Parameters: + /// - transition: how each surface enters/leaves. Defaults to a crossfade. + /// - animation: the animation driving `transition`. Pass `nil` to swap + /// surfaces instantly (no animation). + /// - minimumSplashDuration: the least time the splash stays up before the + /// `.ready` reveal, so a very fast launch still shows the splash (and + /// its reveal) rather than flashing past. `.zero` (the default) reveals + /// as soon as the runner is ready. The hold isn't dead time: `content` + /// is already built beneath the splash, so the destination warms up + /// during it rather than in the frame the reveal starts. + /// - splash: the waiting surface; receives the running step's context + /// (nil between steps) so it can show a caption/progress. + /// - failure: the (terminal) error surface, given the failure. There is + /// no retry — the recovery for a failed launch is relaunching the app. + /// - gates: the gate-type → view registrations (`GateView(for:content:)`). + /// - content: the app's destination UI, given the launch's output. + public init( + _ runner: LifecycleRunner, + transition: AnyTransition = .opacity, + animation: Animation? = .default, + minimumSplashDuration: Duration = .zero, + @ViewBuilder splash: @escaping (LifecycleStepContext?) -> Splash, + @ViewBuilder failure: @escaping (LifecycleFailure) -> Failure, + @GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] }, + @ViewBuilder content: @escaping (Launch) -> Content, + ) { + self.runner = runner + self.transition = transition + self.animation = animation + self.minimumSplashDuration = minimumSplashDuration + self.splash = splash + failureView = failure + self.gates = gates() + self.content = content + Self.assertUniqueGateTypes(self.gates) + } + + /// One registration per gate type: the parked handle is matched by type, + /// so a duplicate would make the lookup ambiguous. A duplicate is a + /// programmer error — fail fast at construction. + private static func assertUniqueGateTypes(_ gates: [GateRegistration]) { + var seen = Set() + let duplicates = gates.map(\.gateType).filter { !seen.insert($0).inserted } + precondition( + duplicates.isEmpty, + "LifecycleContainer registered the same gate type more than once.", + ) + } + + public var body: some View { + Group { + if runner.reason.buildsNoViewTree { + EmptyView() + } else { + phaseContent + } + } + .environment(\.lifecycle, LifecycleProxy(runner)) + .animation(animation, value: displayedSurfaceIdentity) + // Arm the hold each time the splash appears, so every episode (a reset + // relaunch, the return from a gate) gets its own minimum. + .onChange(of: isShowingSplash, initial: true) { _, showing in + guard showing, minimumSplashDuration > .zero else { return } + splashHoldUntil = ContinuousClock.now.advanced(by: minimumSplashDuration) + } + // Once the runner is ready, wait out whatever is left of the hold, then + // release the reveal. + .task(id: runner.phase.isReady) { + guard runner.phase.isReady, let deadline = splashHoldUntil else { return } + do { + // Returns immediately once the deadline has passed, so a launch + // slower than the minimum reveals without waiting. + try await Task.sleep(until: deadline, clock: .continuous) + } catch { + return // Superseded — a new appearance re-armed the hold. + } + // Drive the reveal in an explicit transaction: `.animation(_:value:)` + // doesn't reliably animate this async, `.task`-driven flip, so the + // splash would be removed without its reveal transition. + withAnimation(animation) { splashHoldUntil = nil } + } + } + + /// Whether the *runner* wants the splash on screen: a launch that builds a + /// view tree, parked on `.launching` or running a step (step UI lives in + /// gates, which have their own surface). + /// + /// Deliberately reads the runner's own surface, not `displayedSurfaceIdentity` + /// — that reports `.splash` for a held `.ready`, which would re-arm the hold + /// from its own release and never reveal. + private var isShowingSplash: Bool { + !runner.reason.buildsNoViewTree && runner.phase.surfaceIdentity == .splash + } + + /// Whether the app content may be revealed: nothing is holding it — no + /// minimum was requested, no splash was shown, or the hold has elapsed. + private var canRevealReady: Bool { + splashHoldUntil == nil + } + + /// The surface actually on screen, for `.animation(_:value:)`. While the + /// `.ready` reveal is held behind `minimumSplashDuration` this stays + /// `.splash`, so the reveal transition fires when the hold releases — not + /// the instant the runner reports `.ready`. + private var displayedSurfaceIdentity: LifecycleRunner.Phase.SurfaceIdentity { + if runner.phase.isReady, !canRevealReady { + return .splash + } + return runner.phase.surfaceIdentity + } + + /// Launch surfaces (splash / gate view / failure) sit above the app + /// `content` so that when the runner reaches `.ready`, a *leaving* surface + /// animates on top of the *entering* content — letting a removal + /// transition (e.g. the Where launch splash scaling up and fading to + /// reveal the UI) play over the destination instead of being hidden + /// behind freshly-inserted content. With equal z-indices SwiftUI draws + /// the inserted view last, which would clip the reveal to a plain pop. + private static var launchSurfaceZIndex: Double { + 1 + } + + private static var contentZIndex: Double { + 0 + } + + /// The app content beneath whatever launch surface is up. + /// + /// Built as soon as the launch *produces* its value rather than when the + /// splash hold releases, so the hold warms the destination — its `.task`s, + /// its first layout — instead of paying for it in the same frame the reveal + /// animation starts. It's still gated on the launch's own output: + /// `readyValue` is non-nil only in `.ready`, so `content` can no more be + /// built without the value than before. + /// + /// One `content` call site on purpose: rendering it from two branches + /// (held vs. revealed) would give SwiftUI two identities and rebuild the + /// whole destination when the hold releases, which is exactly what + /// building it early is meant to avoid. + /// + /// Because it's in the tree early, it's explicitly hidden from VoiceOver + /// and hit-testing while a surface covers it — an opaque splash hides it + /// visually, but not from assistive technology. + private var phaseContent: some View { + let overlay = launchOverlay + return ZStack { + if let value = runner.phase.readyValue { + content(value) + // Warmed up beneath the launch surface, but not *reachable* + // through it. An opaque splash hides the content visually; + // without these it stays in the accessibility tree and the + // hit-test path, so during the hold VoiceOver could focus an + // app the user can't see yet. + .accessibilityHidden(overlay.coversContent) + .allowsHitTesting(!overlay.coversContent) + .transition(transition) + .zIndex(Self.contentZIndex) + } + launchSurface(overlay) + } + } + + /// Which launch surface covers the content right now. Exhaustive over the + /// phase, so a new case is a compile error here — and every splash-showing + /// state resolves to the *same* `.splash` case, so the splash keeps one + /// identity (and its animation/caption timers) from `.launching` through + /// the steps and the hold, instead of remounting at each boundary. + enum LaunchOverlay { + case splash(LifecycleStepContext?) + case gate(LifecycleGateHandle) + case failure(LifecycleFailure) + /// Nothing covers the content — the reveal has happened. + case revealed + + /// Whether this surface stands between the user and `content`. Drives + /// the content's accessibility/hit-testing so "covered" means covered + /// for every input method, not just visually. + var coversContent: Bool { + switch self { + case .splash, .gate, .failure: true + case .revealed: false + } + } + } + + private var launchOverlay: LaunchOverlay { + Self.overlay(for: runner.phase, canRevealReady: canRevealReady) + } + + /// Pure so the surface decision — including the held-`.ready` case, which + /// depends on view `@State` and can't be reached from a test otherwise — + /// is checkable without hosting the container. + static func overlay( + for phase: LifecycleRunner.Phase, + canRevealReady: Bool, + ) -> LaunchOverlay { + switch phase { + case .launching: .splash(nil) + case let .running(context): .splash(context) + case let .awaitingGate(handle): .gate(handle) + case let .failed(failure): .failure(failure) + // Held behind `minimumSplashDuration`? Keep the splash on top + // until it elapses; otherwise nothing covers the content. + case .ready: canRevealReady ? .revealed : .splash(nil) + } + } + + @ViewBuilder private func launchSurface(_ overlay: LaunchOverlay) -> some View { + switch overlay { + case let .splash(context): + splash(context).transition(transition).zIndex(Self.launchSurfaceZIndex) + case let .gate(handle): + gateView(for: handle).transition(transition).zIndex(Self.launchSurfaceZIndex) + case let .failure(failure): + failureView(failure).transition(transition).zIndex(Self.launchSurfaceZIndex) + case .revealed: + EmptyView() + } + } + + /// The registered view for the parked gate. A parked gate with no + /// registration is a programmer error (the plan gates on something the + /// UI can't render); rather than an indefinite splash that reads as + /// progress, log it and fail the gate's handle so the launch lands on + /// the failure surface — visible and named (though terminal). Identical + /// in debug and release, which also keeps the path testable. + /// + /// The handle is failed from `onAppear`, not during `body`: `fail(_:)` + /// only resumes the drive's parked continuation, so the phase change it + /// causes lands on the drive task after this render commits. The splash + /// shows for the beat in between. + @ViewBuilder + private func gateView(for handle: LifecycleGateHandle) -> some View { + if let gateType = handle.gateType, + let value = handle.value, + let registration = gates.first(where: { $0.gateType == gateType }) + { + registration.build(handle, value) + } else { + splash(nil).onAppear { + Self.logger.error( + "No gate view registered for gate '\(String(describing: handle.id), privacy: .public)' — add a GateView(for:) entry.", + ) + handle.fail(MissingGateViewError(gateID: handle.id)) + } + } + } + + /// LifecycleKitUI deliberately has no app logging facade; the one + /// misconfiguration it can detect logs through `os` directly so the + /// signal isn't state-only. + private static var logger: Logger { + Logger(subsystem: "com.stuff.lifecyclekitui", category: "gates") + } +} + +extension LifecycleContainer where Splash == LifecycleSplash, Failure == LifecycleFailureView { + /// Convenience initializer using the built-in splash and failure views. + public init( + _ runner: LifecycleRunner, + minimumSplashDuration: Duration = .zero, + @GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] }, + @ViewBuilder content: @escaping (Launch) -> Content, + ) { + self.init( + runner, + minimumSplashDuration: minimumSplashDuration, + splash: { _ in LifecycleSplash() }, + failure: { LifecycleFailureView(failure: $0) }, + gates: gates, + content: content, + ) + } +} + +extension LifecycleContainer where Failure == LifecycleFailureView { + /// Convenience initializer with a custom splash but the built-in failure + /// view. + public init( + _ runner: LifecycleRunner, + minimumSplashDuration: Duration = .zero, + @ViewBuilder splash: @escaping (LifecycleStepContext?) -> Splash, + @GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] }, + @ViewBuilder content: @escaping (Launch) -> Content, + ) { + self.init( + runner, + minimumSplashDuration: minimumSplashDuration, + splash: splash, + failure: { LifecycleFailureView(failure: $0) }, + gates: gates, + content: content, + ) + } +} + +#if DEBUG + private struct PreviewStep: LifecycleStep { + let id = "open" + + func run(_: Void, _: LifecycleStepContext) async throws -> String { + "session" + } + } + + #Preview("Launching") { + LifecycleContainer( + LifecycleRunner(reason: .userForeground, plan: LaunchPlan(PreviewStep())), + ) { value in + Text(verbatim: "App content for \(value)") + } + } +#endif diff --git a/Shared/LifecycleKit/Sources/LifecycleFailureView.swift b/Shared/LifecycleKitUI/Sources/LifecycleFailureView.swift similarity index 65% rename from Shared/LifecycleKit/Sources/LifecycleFailureView.swift rename to Shared/LifecycleKitUI/Sources/LifecycleFailureView.swift index 005d20f9..45ddf31f 100644 --- a/Shared/LifecycleKit/Sources/LifecycleFailureView.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleFailureView.swift @@ -1,14 +1,13 @@ +import LifecycleKit import SwiftUI -/// The UI shown when a launch step throws. Describes the failure and offers a -/// retry that resumes the runner from the step that failed. +/// The UI shown when a launch step throws. Describes the failure. Terminal by +/// design — there is no retry, so the recovery is relaunching the app. public struct LifecycleFailureView: View { private let failure: LifecycleFailure - private let retry: () -> Void - public init(failure: LifecycleFailure, retry: @escaping () -> Void) { + public init(failure: LifecycleFailure) { self.failure = failure - self.retry = retry } public var body: some View { @@ -16,9 +15,6 @@ public struct LifecycleFailureView: View { Label(.failureLaunchTitle, systemImage: "exclamationmark.triangle") } description: { Text(failure.error.localizedDescription) - } actions: { - Button(.failureLaunchRetry, action: retry) - .buttonStyle(.borderedProminent) } } } @@ -30,6 +26,6 @@ public struct LifecycleFailureView: View { stepID: "open-store", error: URLError(.notConnectedToInternet), ), - ) {} + ) } #endif diff --git a/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift b/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift new file mode 100644 index 00000000..d747b5fe --- /dev/null +++ b/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift @@ -0,0 +1,77 @@ +import LifecycleKit +import SwiftUI + +extension EnvironmentValues { + /// A handle to the running `LifecycleRunner`, published by + /// `LifecycleContainer` so nested views (a Settings "reset" button) can + /// reach `enterForeground()`/`teardown(_:input:)` without prop-drilling. + /// + /// It's a `LifecycleProxy` rather than a bare runner for two reasons: an + /// environment value must be non-generic (the runner is generic over its + /// `Launch`), and a view that reads it can just *call* the runner — when + /// no container is above (previews, isolated tests) the proxy is + /// disconnected and every call asserts in debug (surfacing the missing + /// container) and no-ops in release, instead of each call site silently + /// `guard`ing an optional away. + @Entry public var lifecycle = LifecycleProxy() +} + +/// A debug-loud, release-quiet handle to the environment's `LifecycleRunner`. +/// +/// `LifecycleContainer` publishes a *connected* proxy; the environment +/// default is *disconnected* (no runner). Calling through a disconnected +/// proxy `assertionFailure`s in debug (so a view used outside a container is +/// caught in development) and no-ops in release (so a stray reset/retry tap +/// can't crash a shipping build). Views therefore drive the runner without +/// `guard`ing — the "is there a runner?" decision lives here, once. +/// +/// The wrapped runner is `@MainActor`; every forwarding entry point is +/// annotated `@MainActor` accordingly. The struct itself stays `Sendable` so +/// it can be the `@Entry` default — callers must invoke it from the main +/// actor (SwiftUI views already do). +public struct LifecycleProxy: Sendable { + let base: (any LifecycleDriving)? + + /// A disconnected proxy (no runner): the environment default, and what + /// previews get so reset/retry quietly do nothing. + public init() { + base = nil + } + + init(_ runner: any LifecycleDriving) { + base = runner + } + + /// Promote a headless launch to the foreground. + /// See `LifecycleRunner.enterForeground()`. + @MainActor public func enterForeground(file: StaticString = #fileID, line: UInt = #line) async { + await connected(file: file, line: line)?.enterForeground() + } + + /// Run a typed teardown `plan` rooted at `input`, then relaunch from the + /// top. See `LifecycleRunner.teardown(_:input:)`. The plan and input are + /// type-checked here, at the call site, and erased only to cross the + /// non-generic environment seam. + @MainActor public func teardown( + _ plan: LaunchPlan, + input: Input, + file: StaticString = #fileID, + line: UInt = #line, + ) async { + await connected(file: file, line: line)?.teardownErased(nodes: plan.nodes, input: input) + } + + /// The wrapped runner, or nil with a debug assertion pointing at the + /// caller — so a disconnected proxy is loud in development and a silent + /// no-op in production. + private func connected(file: StaticString, line: UInt) -> (any LifecycleDriving)? { + if base == nil { + assertionFailure( + "No LifecycleRunner in the environment — is this view inside a LifecycleContainer?", + file: file, + line: line, + ) + } + return base + } +} diff --git a/Shared/LifecycleKit/Sources/LifecycleSplash.swift b/Shared/LifecycleKitUI/Sources/LifecycleSplash.swift similarity index 100% rename from Shared/LifecycleKit/Sources/LifecycleSplash.swift rename to Shared/LifecycleKitUI/Sources/LifecycleSplash.swift diff --git a/Shared/LifecycleKit/Sources/Resources/Localizable.xcstrings b/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings similarity index 83% rename from Shared/LifecycleKit/Sources/Resources/Localizable.xcstrings rename to Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings index 6a93d913..41414f13 100644 --- a/Shared/LifecycleKit/Sources/Resources/Localizable.xcstrings +++ b/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings @@ -1,16 +1,13 @@ { "sourceLanguage" : "en", "strings" : { - "App content" : { - - }, - "failure.launch.retry" : { + "failure.gate.unregistered %@" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Try Again" + "value" : "This step's screen isn't available. (%@)" } } } diff --git a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift new file mode 100644 index 00000000..7c3ef130 --- /dev/null +++ b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift @@ -0,0 +1,355 @@ +import LifecycleKit +@testable import LifecycleKitUI +import SwiftUI +import TestHostSupport +import Testing + +private struct ProbeError: LocalizedError { + var errorDescription: String? { + "probe failure" + } +} + +/// Reads the proxy the container publishes into the environment and reports +/// whether it was connected (i.e. carried a runner) when this view laid out. +private struct EnvironmentProxyProbe: View { + @Environment(\.lifecycle) private var lifecycle + let mark: (Bool) -> Void + + var body: some View { + mark(lifecycle.base != nil) + return Color.clear.frame(width: 1, height: 1) + } +} + +@MainActor +struct LifecycleContainerTests { + private func makeReadyRunner( + reason: LifecycleReason = .userForeground, + ) async -> LifecycleRunner { + let runner = LifecycleRunner( + reason: reason, + plan: LaunchPlan(FixtureStep("open") { _, _ in "session" }), + ) + await runner.run() + return runner + } + + @Test func readyShowsContentBuiltFromTheLaunchValue() async throws { + var contentValue: String? + var splash = false + let runner = await makeReadyRunner() + #expect(runner.phase.isReady) + + let container = LifecycleContainer( + runner, + splash: { _ in ProbeView { splash = true } }, + ) { value in + ProbeView { contentValue = value } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { contentValue != nil } + } + // The content closure received the trunk's output — not a re-read of + // shared state. + #expect(contentValue == "session") + #expect(!splash) + } + + @Test func minimumSplashDurationDoesNotHoldWhenNoSplashWasShown() async throws { + // The minimum only holds a splash that actually appeared. A launch that's + // already ready when the container mounts never showed one, so even a long + // minimum must reveal content immediately rather than stalling on a hold + // for a splash the user never saw. + // + // (The other half — holding a splash that *did* appear until the minimum + // elapses, then revealing — is a `.task`-driven async/timing behavior that + // `show`'s synchronous closure can't drive deterministically; like the + // splash caption's own delay it's exercised on device, not host-tested.) + // + // Asserted via the *splash*, not via `content`: content is built as soon + // as the runner produces its value — behind the splash while a hold is up, + // so that the hold warms the destination — so building it no longer + // distinguishes "revealed" from "held". An absent splash does. + var content = false + var splashShown = false + let runner = await makeReadyRunner() + #expect(runner.phase.isReady) + + let container = LifecycleContainer( + runner, + minimumSplashDuration: .seconds(60), + splash: { _ in ProbeView { splashShown = true } }, + failure: { _ in EmptyView() }, + ) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { content } + } + #expect(content) + // Nothing covering it: the reveal happened rather than stalling behind a + // 60-second hold for a splash the user never saw. + #expect(!splashShown) + } + + @Test func aCoveringSurfaceHidesTheContentBeneathIt() { + // `content` is built as soon as the launch produces its value — including + // while a surface still covers it, so the hold warms it up — which means + // "covered" has to hold for VoiceOver and touches too, not just visually: + // an opaque splash doesn't take the content out of the accessibility tree. + typealias Container = LifecycleContainer + let context = LifecycleStepContext(stepID: "open", reason: .userForeground) + let handle = LifecycleGateHandle(id: "onboarding", reason: .userForeground) + let failure = LifecycleFailure(stepID: "boom", error: ProbeError()) + + #expect(Container.overlay(for: .launching, canRevealReady: true).coversContent) + #expect(Container.overlay(for: .running(context), canRevealReady: true).coversContent) + #expect(Container.overlay(for: .awaitingGate(handle), canRevealReady: true).coversContent) + #expect(Container.overlay(for: .failed(failure), canRevealReady: true).coversContent) + // The case that regressed: ready (so `content` exists) but still held + // behind `minimumSplashDuration`, with the splash on top of it. + #expect(Container.overlay(for: .ready("session"), canRevealReady: false).coversContent) + // Revealed — nothing between the user and the app. + #expect(!Container.overlay(for: .ready("session"), canRevealReady: true).coversContent) + } + + @Test func launchingShowsSplash() throws { + var splash = false + var content = false + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("open") { _, _ in "session" }), + ) + // Not run yet, so the runner is still in .launching. + + let container = LifecycleContainer( + runner, + splash: { _ in ProbeView { splash = true } }, + ) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { splash } + } + #expect(splash) + #expect(!content) + } + + @Test func runningStepContextReachesTheSplash() async throws { + let (parked, release) = AsyncStream.makeStream(of: Void.self) + var caption: String? + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("open") { _, context in + context.message = "opening the store" + for await _ in parked {} + return "session" + }), + ) + let task = Task { @MainActor in await runner.run() } + try await waitUntil { runner.phase.isRunning("open") } + + let container = LifecycleContainer( + runner, + splash: { context in ProbeView { caption = context?.message } }, + ) { _ in + ProbeView {} + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { caption != nil } + } + #expect(caption == "opening the store") + + release.finish() + await task.value + } + + @Test func backgroundLaunchShowsNothing() async throws { + var content = false + var splash = false + let runner = await makeReadyRunner(reason: .background(.location)) + #expect(runner.phase.isReady) + + let container = LifecycleContainer( + runner, + splash: { _ in ProbeView { splash = true } }, + ) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + // Even at .ready, a background launch must not build the app UI: + // give the host a render budget and confirm neither branch appears. + #expect(!renders { content || splash }) + } + } + + @Test func undeterminedLaunchShowsNothingUntilPromoted() async throws { + var content = false + var splash = false + let runner = await makeReadyRunner(reason: .undetermined) + #expect(runner.phase.isReady) + + let container = LifecycleContainer( + runner, + splash: { _ in ProbeView { splash = true } }, + ) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + // An undetermined launch hasn't proven a window exists, so — like a + // background launch — it must build no view tree even at .ready. + #expect(!renders { content || splash }) + } + } + + @Test func backgroundReadyThenEnterForegroundShowsContent() async throws { + var content = false + let runner = await makeReadyRunner(reason: .background(.location)) + #expect(runner.reason.buildsNoViewTree) + + await runner.enterForeground() + #expect(!runner.reason.buildsNoViewTree) + #expect(runner.phase.isReady) + + let container = LifecycleContainer(runner) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { content } + } + #expect(content) + } + + @Test func awaitingGateShowsTheRegisteredGateViewWithTheTrunkValue() async throws { + var gateValue: String? + var content = false + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("open") { _, _ in "session" }) + .gate(FixtureGate("onboarding")), + ) + let task = Task { @MainActor in await runner.run() } + try await waitUntil { runner.phase.isAwaitingGate("onboarding") } + + let container = LifecycleContainer( + runner, + gates: { + GateView(for: FixtureGate.self) { _, value in + ProbeView { gateValue = value } + } + }, + ) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { gateValue != nil } + } + // The registry recovered the gate's Value statically and handed the + // view the typed trunk value. + #expect(gateValue == "session") + #expect(!content) + + runner.phase.gateHandle?.complete() + await task.value + #expect(runner.phase.isReady) + } + + @Test func parkedGateWithNoRegistrationFailsTheGateOntoTheFailureSurface() async throws { + // A parked gate whose type has no GateView entry is a + // misconfiguration: the container must fail the handle — landing on + // the visible (terminal) failure surface (rendering of `.failed` is + // pinned by `failedShowsFailureView`) — rather than leave the launch + // behind an indefinite splash that reads as progress. + var splashShown = false + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("open") { _, _ in "session" }) + .gate(FixtureGate("onboarding")), + ) + let task = Task { @MainActor in await runner.run() } + try await waitUntil { runner.phase.isAwaitingGate("onboarding") } + + let container = LifecycleContainer( + runner, + splash: { _ in ProbeView { splashShown = true } }, + ) { _ in + ProbeView {} + } + try show(UIHostingController(rootView: container)) { _ in + // Hosting renders the interim splash and fires the fallback's + // `onAppear`, which fails the handle. The drive's resumption is a + // main-actor task continuation, so it can only land after this + // synchronous block yields — hence the async wait below, not a + // run-loop-pumping `waitFor` here. + try waitFor { splashShown } + } + + try await waitUntil { runner.phase.failed(at: "onboarding") } + await task.value + let error = try #require(runner.phase.failure?.error as? MissingGateViewError) + #expect(error.gateID == AnyHashable("onboarding")) + } + + @Test func failedShowsFailureView() async throws { + var failure = false + var content = false + var splash = false + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("boom") { _, _ in throw ProbeError() }), + ) + await runner.run() + #expect(runner.phase.failed(at: "boom")) + + let container = LifecycleContainer( + runner, + splash: { _ in ProbeView { splash = true } }, + failure: { _ in ProbeView { failure = true } }, + ) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { failure } + } + #expect(failure) + #expect(!content) + #expect(!splash) + } + + @Test func publishesAConnectedProxyIntoTheEnvironment() async throws { + var sawRunner = false + let runner = await makeReadyRunner() + + let container = LifecycleContainer(runner) { _ in + EnvironmentProxyProbe { sawRunner = $0 } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { sawRunner } + } + #expect(sawRunner) + } + + @Test func customTransitionStillRendersContent() async throws { + // A caller-supplied transition/animation must not break which surface + // the container renders — at .ready it still builds `content`. + var content = false + var splash = false + let runner = await makeReadyRunner() + + let container = LifecycleContainer( + runner, + transition: .scale.combined(with: .opacity), + animation: .easeInOut, + splash: { _ in ProbeView { splash = true } }, + failure: { _ in EmptyView() }, + ) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { content } + } + #expect(content) + #expect(!splash) + } +} diff --git a/Shared/LifecycleKitUI/Tests/LifecycleKitUITestSupport.swift b/Shared/LifecycleKitUI/Tests/LifecycleKitUITestSupport.swift new file mode 100644 index 00000000..2c0db86e --- /dev/null +++ b/Shared/LifecycleKitUI/Tests/LifecycleKitUITestSupport.swift @@ -0,0 +1,77 @@ +import LifecycleKit +import SwiftUI +import Testing + +private struct WaitTimeoutError: Error {} + +/// Polls `condition` on the main actor until it holds or the timeout elapses. +/// Sleeping yields to the runner's drive task between checks. +@MainActor +func waitUntil( + timeout: Duration = .seconds(2), + _ condition: () -> Bool, +) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while !condition() { + if ContinuousClock.now >= deadline { + throw WaitTimeoutError() + } + try await Task.sleep(for: .milliseconds(1)) + } +} + +/// A leaf view that runs `mark` when the host lays it out, so a test can +/// detect whether the container actually chose (and rendered) the branch it +/// sits in. Each test owns the `Bool`s `mark` flips, so there are no shared +/// fixtures. +struct ProbeView: View { + let mark: () -> Void + + var body: some View { + mark() + return Color.clear.frame(width: 1, height: 1) + } +} + +/// A configurable typed step for container tests: identity, mode gating, and +/// a closure body (mirrors the fixture in LifecycleKit's own bundle). +struct FixtureStep: LifecycleStep { + let id: String + var modes: LifecycleModeSet = .all + let body: @MainActor (Input, LifecycleStepContext) async throws -> Output + + init( + _ id: String, + modes: LifecycleModeSet = .all, + body: @escaping @MainActor (Input, LifecycleStepContext) async throws -> Output, + ) { + self.id = id + self.modes = modes + self.body = body + } + + func run(_ input: Input, _ context: LifecycleStepContext) async throws -> Output { + try await body(input, context) + } +} + +/// A configurable gate for container tests, mirroring `FixtureStep`. +struct FixtureGate: LifecycleGate { + let id: String + var modes: LifecycleModeSet = .foreground + var needed: @MainActor (Value) async -> Bool + + init( + _ id: String, + modes: LifecycleModeSet = .foreground, + needed: @escaping @MainActor (Value) async -> Bool = { _ in true }, + ) { + self.id = id + self.modes = modes + self.needed = needed + } + + func isNeeded(_ value: Value) async -> Bool { + await needed(value) + } +} diff --git a/Shared/LifecycleKitUI/Tests/LifecycleProxyTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleProxyTests.swift new file mode 100644 index 00000000..75c6fb61 --- /dev/null +++ b/Shared/LifecycleKitUI/Tests/LifecycleProxyTests.swift @@ -0,0 +1,53 @@ +import LifecycleKit +@testable import LifecycleKitUI +import Testing + +@MainActor +struct LifecycleProxyTests { + @Test func defaultProxyIsDisconnected() { + // The environment default: nothing to drive, so callers no-op (debug + // asserts) rather than dereferencing a missing runner. + #expect(LifecycleProxy().base == nil) + } + + @Test func connectedProxyForwardsTypedTeardownToTheRunner() async { + var tornDownWith: String? + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("open") { _, _ in "session" }), + ) + await runner.run() + #expect(runner.phase.isReady) + + // The typed plan + input cross the non-generic environment seam and + // land on the runner with the value intact. + await LifecycleProxy(runner).teardown( + LaunchPlan(FixtureStep("teardown") { value, _ in + tornDownWith = value + }), + input: "session", + ) + #expect(tornDownWith == "session") + #expect(runner.phase.isReady) + } + + @Test func connectedProxyForwardsEnterForegroundToTheRunner() async { + var executed: [String] = [] + let runner = LifecycleRunner( + reason: .undetermined, + plan: LaunchPlan(FixtureStep("store") { _, _ in + executed.append("store") + return "session" + }) + .thenKeeping(FixtureStep("foreground-only", modes: .foreground) { _, _ in + executed.append("foreground-only") + }), + ) + await runner.run() + #expect(executed == ["store"]) + + await LifecycleProxy(runner).enterForeground() + #expect(executed == ["store", "foreground-only"]) + #expect(runner.phase.isReady) + } +} diff --git a/Where/AGENTS.md b/Where/AGENTS.md index b3b367c9..bfd75b5e 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -50,7 +50,10 @@ Rules the code enforces and agents must preserve: and readers refresh purely off it — write intents just commit, they don't refresh inline. The scene's `YearReportModel` subscribes while it's active; `DataIssueScanner` drops its cache on the same signal. Launch is driven by - [`LifecycleKit`](../Shared/LifecycleKit) (see `WhereLaunch` in WhereUI). + [`LifecycleKit`](../Shared/LifecycleKit)'s typed `LaunchPlan` (see + `WhereLaunch` in WhereUI: each step is a type whose `Input`/`Output` thread + the store scope → session scope through the trunk), rendered by + [`LifecycleKitUI`](../Shared/LifecycleKitUI)'s container in `RootView`. - **All logging goes through [Periscope](../Shared/Periscope)** via `WhereCore`'s `WhereLog` facade — never a raw string. A collaborator derives a typed `LogEvent` leaf off a grouping scope (`WhereLog.(SomeLog.self)` / diff --git a/Where/TODOs.md b/Where/TODOs.md index daf95f77..dd8c8213 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -39,6 +39,7 @@ The item format and the placement rule live in the root - test(WhereUI) [quick-win]: Add an adversarial test for toggle ordering (stop while a slow scripted `startTracking()` is in flight); `WhereSessionTrackingTests` covers only the launch/foreground paths today. (audit 2026-07-26) - refactor(WhereUI) [needs-design]: Split `WhereSession` into an always-on coordinator + a presentation view-model whose lifetime scopes its subscriptions. **Partial progress (July 2026):** `YearReportModel` is now scene-scoped in `MainTabs` — `activate()` / `deactivate()` on `scenePhase` drive `observeDataChanges()` and refresh, closing the headless-relaunch rescan leak that previously wired the subscription through launch `syncAuth`. `ResolveModel`, `BackupModel`, and `RemindersSettingsModel` are already view-scoped. Remaining: the coordinator is still ~460 lines mixing tracking intent, authorization, reset, and region-style mirrors; finish extracting presentation collaborators and drive any leftover reactive work from scene lifetime. (agent) - refactor(WhereUI) [needs-design]: Extract one shared region-selection form. `DayRelabelView.swift:106` renders a flat region list where `ManualDayView.swift:212` has grouped sections plus `loadGrouping()`, so the two screens disagree on how regions are picked. (audit 2026-07-26) +- fix(WhereUI) [needs-design]: Notification authorization is requested during launch, with no context and unprompted. The launch's detached fan (`WhereLaunch`'s `reminders` / `summary` / `issue-alerts` steps → `session.applyReminderConfiguration()` etc. → the `services.reminders` scheduler) reaches the notification center while the app is still launching, so a fresh install shows the system "Where Would Like to Send You Notifications" alert over the splash — before the user has expressed any interest in reminders and with no in-app rationale. Observed on a fresh-install simulator screen recording: the alert lands roughly a second after the splash appears and then sits on top of the revealed app. Ask in context instead — request when the user turns reminders/summary on in Settings (or immediately after onboarding, with a sentence of explanation) — and have the launch fan only *reconcile* schedules against authorization that was already granted, never trigger the prompt. (agent) - refactor(WhereCore) [needs-design]: Rewrite the controller layer as a state machine so invariants can't exist. (human) - test(WhereIntents) [quick-win]: The per-intent `perform()` glue — guards, snippet wiring, error→dialog mapping — is untested, because `@Dependency` traps outside the perform flow. Either extract a thin testable seam or say so in `README.md`; the reader/writer seams themselves are now well covered. (audit 2026-07-26) - refactor(WhereShareExtension) [needs-design]: Consolidate the share/add evidence form. `ShareEvidenceView.swift:65` and `AddEvidenceView.swift:85` are parallel implementations over parallel catalog namespaces (`share.form.*` / `evidence.form.*`). (audit 2026-07-26) @@ -53,6 +54,8 @@ The item format and the placement rule live in the root - design(WhereCore): Open question — the exclusion UX, where an older device progressively hides days a newer device has touched, needs a deliberate warning surface, not a silent drop. (agent) ## P2s (Nice to have) +- feat(WhereUI) [needs-design]: Give the app a branded launch screen. `UILaunchScreen` is an empty dictionary (`Project.swift`), so the pre-main frame is plain white. Measured from a fresh-install simulator recording, a first run reads as ~1.7s of white → ~0.25s of the dark `LaunchSplashView` → the light onboarding screen, so the splash registers as a quarter-second dark blip between two light screens rather than as the app opening. A launch screen matching the splash's background + icon would make that continuous. Note this is the right layer to fix it at: the splash's own `minimumSplashDuration` hold deliberately gates only the `.ready` reveal, not a gate transition like onboarding, so lengthening the hold would just delay interactive UI. (agent) +- fix(WhereUI) [needs-design]: A launch can park indefinitely behind a system permission prompt. If location authorization is still `.notDetermined` when the launch trunk reaches `reconcile-tracking`, `WhereSession.reconcileTracking()` awaits the authorization *request*, so the splash stays up until the user answers the system alert — and if the unprompted notification alert (filed as a P1 above) is queued ahead of it, until they answer both. Not reachable on the normal path (onboarding settles location permission before the trunk resumes), but reproducible by marking `where.hasOnboarded` true without granting location, and it means a modal system prompt sits on the launch critical path. Consider reconciling against the *current* status only and letting the live authorization observer pick up the user's answer, so launch can never block on a prompt. (agent) - feat(WhereUI) [quick-win]: Bound the Periscope log store by size, not just age. `WhereLaunch.bootstrapLogging` prunes events older than the retention window (now 100 days); with the built-in ambient sources emitting continuously, a heavy-logging device could still grow the store large within the window. Add a count cap alongside the time prune — `PeriscopeStore.pruneEvents(keepingNewest:)` already exists — so the store is bounded regardless of volume. (pr#94 review) - refactor(WhereUI) [needs-design]: Make the scene-scoped model wiring compiler-checked rather than an `@Environment` lookup that fails silently. `WhereSession` (the always-on coordinator) is read from the environment, so a screen mounted without a parent injecting it resolves to a runtime fallback/precondition instead of a compile error. The scoped models (`YearReportModel`, `ResolveModel`, `BackupModel`, `RemindersSettingsModel`) are already constructor-injected; explore threading the coordinator the same way (or a non-defaulting typed `EnvironmentKey`) so a broken wiring can't build. Follow-up from the `WhereSession` split. (agent) - refactor(WhereUI) [needs-design]: Split `YearReportModel` further. Post-split it still fuses several roles for the selected year: the loaded report + everything derived from it (ranking, missing days, calendar inputs, tracked-day count), the Resolve badge *count*, the day-write intents (`setManualDay(s)`, `overrideDay`, `clearManualDay`, `clearSelectedYear`), and the Elsewhere drill-in reads (`days(in:)`, `locations(in:)`, `representativeCoordinates()`). The read-only presentation state and the write-intent/drill-in surface could be separate collaborators so a view only holds what it uses. Follow-up from the `WhereSession` split. (agent) @@ -74,7 +77,7 @@ The item format and the placement rule live in the root - convention(RegionKit) [quick-win]: Reference a generated catalog symbol for `region.other` instead of the raw `String(localized:)` key (`RegionCatalog.swift:65`). (audit 2026-07-26) - convention(WhereShareExtension) [quick-win]: Log the discarded provider `error` in `SharedItemLoader.loadDataRepresentation` (`:80`); only the nil-data branch logs today. (audit 2026-07-26) - perf(WhereUI) [needs-design]: Profile the `RegionSummaryCard` Canvas rosette — `ringCount` derives from size with no cap (`:98`). Cap or pre-render if it shows up. (audit 2026-07-26) -- fix(WhereUI) [quick-win]: Three literals in source get auto-extracted into the catalogs as value-less entries, which is why an IDE build had anything to write back at all (see the serialization normalization PR). They're committed as Xcode writes them; removing an entry for good means removing the literal. `Marker("", coordinate:)` in `RecordedPointsMap` produces the empty `""` key (an unlabeled dev-map pin — `Annotation` with an explicit accessibility label would say what it means); `Text("\(group.outlineCount)")` in `RegionMapView` and `Text("\(day.dayOfMonth)")` in `CalendarContentView` produce `%lld` and bypass `WhereFormat`'s number styling. (The fourth such entry, `App content`, comes from a `LifecycleContainer` `#Preview` in LifecycleKit's own catalog and is filed in [`Shared/LifecycleKit/TODOs.md`](../Shared/LifecycleKit/TODOs.md).) (agent) +- fix(WhereUI) [quick-win]: Three literals in source get auto-extracted into the catalogs as value-less entries, which is why an IDE build had anything to write back at all (see the serialization normalization PR). They're committed as Xcode writes them; removing an entry for good means removing the literal. `Marker("", coordinate:)` in `RecordedPointsMap` produces the empty `""` key (an unlabeled dev-map pin — `Annotation` with an explicit accessibility label would say what it means); `Text("\(group.outlineCount)")` in `RegionMapView` and `Text("\(day.dayOfMonth)")` in `CalendarContentView` produce `%lld` and bypass `WhereFormat`'s number styling. (A fourth such entry, `App content`, came from a `LifecycleContainer` `#Preview`; it's gone — that preview now uses `Text(verbatim:)`, which isn't extracted.) (agent) - feat(WhereUI): Raw data browser (similar to the SwiftData browser). (human) - docs(WhereUI): Add comments to strings in the xcstrings files. (human) @@ -96,7 +99,7 @@ The item format and the placement rule live in the root ## P2s (Nice to have) - The `guard let controller else { return }` in the WhereModel in WhereUI is weird — gone with the controller: `WhereModel`'s guards now read as ownership invariants (`attach(services:)` is idempotent, `startSession()` requires attached services), and the type itself is `WhereServices`. -- Move test only code behind @_spi — done throughout; test seams are `@_spi(Testing)` in both Where and the shared modules (direct store mutation, failure injection, queue introspection, clock/capacity overrides), and the only `…ForTesting`-named API left (`LifecycleRunner.injectFailureForTesting`) is itself behind it. +- Move test only code behind @_spi — done throughout; test seams are `@_spi(Testing)` in both Where and the shared modules (direct store mutation, failure injection, queue introspection, clock/capacity overrides), and no `…ForTesting`-named API remains (`LifecycleRunner.injectFailureForTesting`, the last one, went with the engine's retry removal). - Move `let calendar = Calendar.current` into a var on the controller? There’s a few of these — the call sites moved onto explicit Gregorian calendars owned by `YearReportModel` / `WhereModel`. What's left isn't a controller-hoisting problem but three helper-level defaults, refiled as a P1 above (`CalendarDay.displayDate`, `DateRangeFormatting.abbreviated`, `PresenceTimeline.stints`). - refactor: Code-gen the strings so keys aren't referenced manually — adopted Xcode's String Catalog `LocalizedStringResource` symbol generation across the app (`STRING_CATALOG_GENERATE_SYMBOLS`), deleted the hand-maintained `Strings`/`ShareStrings`/`WidgetStrings` facades in favor of generated symbols + the `WhereFormat`/`IntentStrings` composition helpers, so a typo'd or removed key is now a compile error. (RegionKit region names stay data-driven by design.) - refactor: Remove get/set closure-based `Binding` values from SwiftUI views — replaced with computed properties on `@Observable` models (e.g. `SaveErrorAlertState`). diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index 61cdef7f..cd085116 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -29,7 +29,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { /// The launch engine, built in `didFinishLaunching` (launching /// `.undetermined`, since the UIScene lifecycle can't yet tell a user launch /// from a headless wake here) and handed to `RootView` via `WhereApp`. - private(set) var launcher: LifecycleRunner! + private(set) var launcher: LifecycleRunner! func application( _: UIApplication, @@ -70,7 +70,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { // (so a queued location event isn't lost) and registers the // foreground-notification presenter; the rest (store open, etc.) runs as // async steps off this synchronous launch path. - // `onServicesReady` fires from the `open-store` step on every session + // `onServicesReady` fires from the `start-session` step on every session // (re)start: derive the App Intents stack from the launch's services — // same store, attribution, and clock; GPS-free — and install it, so // the launch's open is the process's *only* store open and an intent diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 6c988771..98d8d5dc 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -18,17 +18,20 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's ### App shell & view models -- **`RootView`** — the app root: the launch sequence (via - [`LifecycleKit`](../../Shared/LifecycleKit)) gated in front of `MainTabs`, the - Liquid Glass tab bar over three tabs — Locations, Your Year, Settings. - Elsewhere is an entry card on Locations, Resolve a Locations toolbar button, - and the data screens (attachments, logged days, regions) sit in the Settings - "Data" group. The app injects the launch-built model + runner +- **`RootView`** — the app root: the typed launch plan (via + [`LifecycleKit`](../../Shared/LifecycleKit), rendered by + [`LifecycleKitUI`](../../Shared/LifecycleKitUI)'s container) gated in front of + `MainTabs`, the Liquid Glass tab bar over three tabs — Locations, Your Year, + Settings. Elsewhere is an entry card on Locations, Resolve a Locations toolbar + button, and the data screens (attachments, logged days, regions) sit in the + Settings "Data" group. `MainTabs` is built from the `WhereSession` the + launch's `.ready` carries. The app injects the launch-built model + runner (`init(model:launcher:)`); a no-arg `init()` builds its own for previews and the hosted UI test. - **`WhereModel`** — app-level state: the onboarding flag, the owned `WhereSession`, and the lifecycle intents (`attach(services:)`, - `startSession()` / `endSession()`, `eraseAllData()`, `resetPreferences()`). + `startSession(services:)` — which *returns* the session the launch's + `start-session` step threads onward — `endSession()`, `resetPreferences()`). - **`WhereSession`** — the always-on coordinator: tracking + location authorization state and the intents that drive them (`requestPermission()`, `startTracking()` / `stopTracking()`, `refreshWidgetSnapshot()`). It holds no @@ -41,12 +44,14 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's ### Reusable views & styling -- **`OnboardingView`** — the first-run flow, driven by a `LifecycleStepUIBridge`: - a paged intro, then picking up to five primary US regions (map or searchable - list) and giving each a look, then the location-permission ask. It commits the - picks as the tracked-region set + appearances before finishing. The intro also - offers **Restore from a backup**, which imports a backup (`.replace`) and skips - the manual pick/customize steps straight to the location ask. +- **`OnboardingView`** — the first-run flow, registered for the launch's + `OnboardingGate` and handed its `LifecycleGateHandle` + the gate's + `WhereSession`: a paged intro, then picking up to five primary US regions + (map or searchable list) and giving each a look, then the + location-permission ask. It commits the picks as the tracked-region set + + appearances before resolving the gate. The intro also offers **Restore from + a backup**, which imports a backup (`.replace`) and skips the manual + pick/customize steps straight to the location ask. - **`RegionPickerView` / `RegionCustomizeView`** — the shared primary-region picker (segmented map/list) and per-region color/emoji/icon customization, backed by `PrimaryRegionSelectionModel`. Reused by onboarding and the Settings diff --git a/Where/WhereUI/Sources/Launch/DetachedFailureReporter.swift b/Where/WhereUI/Sources/Launch/DetachedFailureReporter.swift new file mode 100644 index 00000000..b8e34496 --- /dev/null +++ b/Where/WhereUI/Sources/Launch/DetachedFailureReporter.swift @@ -0,0 +1,77 @@ +import LifecycleKit +import Observation +import WhereCore + +/// Mirrors a runner's detached-step failures into `WhereLog`, so a +/// fire-and-forget failure is observable in logs as well as on the runner's +/// `detachedFailures` state (the never-silently-swallow rule: a failure must +/// surface in logs, state, or both — the kit deliberately only records, and +/// no UI renders the diagnostics surface). +/// +/// Today every detached launch step wraps a non-throwing `WhereSession` +/// method, so nothing can land here; this is the seam that keeps a *future* +/// throwing detached step from failing invisibly. +/// +/// Installed by `WhereLaunch.makeLauncher`. Observation is UI-independent +/// (`withObservationTracking`, re-armed per change), so failures during a +/// headless background drive — where no view tree exists — are still logged. +/// The reporter is kept alive by its own observation chain and holds the +/// runner weakly, so it never extends the runner's lifetime; the chain ends +/// when the runner deallocates. +@MainActor +final class DetachedFailureReporter { + private static let logger = WhereLog.root(WhereLaunchLog.self) + + /// How many entries of the runner's current `detachedFailures` array have + /// been reported. The runner clears the array when a fresh attempt + /// begins, so a shrink means "new attempt" and reporting restarts from + /// zero — an entry is logged exactly once per appearance. + private var reportedCount = 0 + + /// Total failures reported over the reporter's lifetime (monotonic, + /// unlike `reportedCount`, which resets with the runner's attempts). + /// Lets tests pin the end-to-end observation plumbing without asserting + /// on log output. + private(set) var reportedTotal = 0 + + /// Observe `runner.detachedFailures` for the runner's lifetime, logging + /// new entries as they land. + static func observe(_ runner: LifecycleRunner) { + DetachedFailureReporter().observe(runner) + } + + /// Instance flavor of `observe(_:)`, so a test can keep the reporter and + /// read `reportedTotal`. + func observe(_ runner: LifecycleRunner) { + withObservationTracking { + report(runner.detachedFailures) + } onChange: { [weak runner] in + Task { @MainActor [weak runner] in + guard let runner else { return } + self.observe(runner) + } + } + } + + /// Log any not-yet-reported entries of `failures`, exactly once each, + /// tolerating the runner's per-attempt resets. Returns the newly reported + /// failures so tests can pin the exactly-once and reset semantics. + @discardableResult + func report(_ failures: [LifecycleFailure]) -> [LifecycleFailure] { + if failures.count < reportedCount { + reportedCount = 0 + } + let new = Array(failures[reportedCount...]) + reportedCount = failures.count + reportedTotal += new.count + for failure in new { + Self.logger(attachments: [.error(failure.error, name: "detached-error")]) { + .detachedStepFailed( + stepID: String(describing: failure.stepID), + description: failure.error.localizedDescription, + ) + } + } + return new + } +} diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index 6e72f254..054bafc9 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -4,20 +4,23 @@ import SwiftUI import UserNotifications import WhereCore -/// Stable identifiers for the steps in `WhereLaunch.sequence` and -/// `resetSequence`. Raw strings drift silently (a typo just creates a new, +/// Stable identifiers for the nodes in `WhereLaunch.plan(for:)` and +/// `resetPlan(for:)`. Raw strings drift silently (a typo just creates a new, /// untracked step); an enum makes each ID a compile-checked symbol and gives /// the launch/reset parity tests a single source of truth. -public enum LaunchStepID: String { - /// Open the SwiftData store, assemble the services, and build the session. - /// The splash's slow-launch caption most often shows during this step. +public enum LaunchStepID: String, Sendable { + /// Open the SwiftData store and assemble the services — the trunk's + /// store scope. The splash's slow-launch caption most often shows here. case openStore = "open-store" + /// Build the logged-in session over the services and fire the app's + /// composition hook — promotes the trunk to the session scope. + case startSession = "start-session" /// First-run onboarding gate. Foreground-only, so a headless launch (an - /// unpromoted `.undetermined` cold launch or a `.background` relaunch) skips - /// it — it runs only once a scene promotes the launch to foreground. + /// unpromoted `.undetermined` cold launch or a `.background` relaunch) + /// skips it — it re-evaluates once a scene promotes the launch. case onboarding - /// Read location authorization into the coordinator and start observing live - /// authorization changes. The report + data-issue scan (and their + /// Read location authorization into the coordinator and start observing + /// live authorization changes. The report + data-issue scan (and their /// store-change subscription) load with the scene now, not here — so a /// headless launch (no scene) never drives a refresh no UI consumes. case syncAuth = "sync-auth" @@ -25,11 +28,11 @@ public enum LaunchStepID: String { case reconcileTracking = "reconcile-tracking" /// Take a one-shot GPS fix for today if none is logged yet, so opening the /// app on a fresh day fills the calendar in. Foreground-only — a headless - /// launch shouldn't spend a fresh fix (a `.background` relaunch is itself the - /// passive event); it runs once a scene promotes the launch. + /// launch shouldn't spend a fresh fix (a `.background` relaunch is itself + /// the passive event); it runs once a scene promotes the launch. case captureToday = "capture-today" - /// Push the logging-reminder schedule + badge (backlog + issue count) to the - /// reconciler. + /// Push the logging-reminder schedule + badge (backlog + issue count) to + /// the reconciler. case reminders /// Push the daily-summary recap to the reconciler. case summary @@ -45,15 +48,21 @@ public enum LaunchStepID: String { case resetPreferences = "reset-preferences" } -/// Assembles the Where app's cold-launch sequence and the `LifecycleRunner` -/// that drives it. +/// Assembles the Where app's cold-launch plan and the `LifecycleRunner` that +/// drives it. /// -/// The sequence is only the *prerequisites*; the destination — the real tab UI -/// — is `LifecycleContainer`'s `content` (see `RootView`), shown once the -/// runner reaches `.ready`. It mirrors the imperative `WhereSession.start()`: -/// CoreLocation is wired synchronously in `initializePrerequisites`, then the -/// store opens, the session is built, and the rest of the work runs as ordered -/// async steps. +/// The plan is only the *prerequisites*; the destination — the real tab UI — +/// is `LifecycleContainer`'s `content` (see `RootView`), handed the +/// `WhereSession` the trunk produced once the runner reaches `.ready`. +/// +/// The trunk's value is the launch's dependency scope, growing monotonically +/// by embedding: `OpenStoreStep` mints `WhereServices` (the store scope), +/// `StartSessionStep` promotes it to `WhereSession` (which carries the +/// services non-optionally), and every downstream node takes the session as +/// its typed input. The compiler holds the ordering — a node cannot be +/// placed before its input exists, and only pass-through nodes (the +/// onboarding gate, `thenKeeping` steps, the detached fan) may skip, so a +/// skipped node can't leave a hole in the data flow. @MainActor public enum WhereLaunch { private static let logger = WhereLog.root(WhereLaunchLog.self) @@ -126,139 +135,88 @@ public enum WhereLaunch { /// Build the runner for `model`, launching for `reason`. /// /// `initializePrerequisites` runs the synchronous, must-exist-now launch - /// wiring before any async step: a `WhereBootstrap` installs the + /// wiring before any async node: a `WhereBootstrap` installs the /// `CLLocationManager` (`prepareLocation()`) so a background relaunch's /// queued event isn't lost while the async `open-store` step assembles the /// services, and the foreground-notification presenter is registered so a /// reminder fired while Where is open still shows. Keeping both here (rather /// than in the app delegate) puts app-lifecycle wiring in one place. /// - /// `onServicesReady` fires from the `open-store` step every time a session - /// is (re)started over the assembled services — first launch, a retry - /// after a failed launch, and the reset relaunch. The app uses it to hand - /// the service layer to consumers WhereUI can't see (deriving and + /// `onServicesReady` fires from the `start-session` step every time a + /// session is (re)started over the assembled services — first launch, the + /// fresh process after a failed (terminal) launch, and the in-process reset + /// relaunch. The app uses it to + /// hand the service layer to consumers WhereUI can't see (deriving and /// installing the App Intents stack — see the app's `AppDelegate`); /// previews and tests omit it. public static func makeLauncher( model: WhereModel, reason: LifecycleReason, onServicesReady: @escaping @MainActor (WhereServices) async -> Void = { _ in }, - ) -> LifecycleRunner { + ) -> LifecycleRunner { let bootstrap = WhereBootstrap() logger { .runnerCreated(reason: String(describing: reason)) } - return LifecycleRunner( + let runner = LifecycleRunner( reason: reason, initializePrerequisites: { bootstrap.prepareLocation() ForegroundNotificationPresenter.install() }, - sequence: sequence( + plan: plan( for: model, bootstrap: bootstrap, onServicesReady: onServicesReady, ), ) + // Mirror detached-step failures into WhereLog: the runner only + // records them on its observable `detachedFailures`, which nothing + // renders — without this a throwing detached step would fail with no + // trace in logs. + DetachedFailureReporter.observe(runner) + return runner } - /// The ordered launch steps. The work steps run in the same order as - /// `WhereSession.start()` (a parity test guards this); the only additions - /// are the foreground-only `open-store` presentation and the `onboarding` - /// gate, neither of which `start()` models. + /// The typed launch plan. The trunk mirrors the imperative + /// `WhereSession.start()` order (a parity test guards this); the only + /// insertions are the `start-session` scope promotion and the + /// `onboarding` gate, neither of which `start()` models. The independent + /// session-configuration steps fan out detached after the trunk — they + /// take the session, return nothing, and never block `.ready`. /// /// `bootstrap` assembles the services in the `open-store` step, and - /// `onServicesReady` fires there whenever a session is (re)started (see - /// `makeLauncher`); callers that only inspect the step list (the parity - /// test) can rely on the defaults. - public static func sequence( + /// `onServicesReady` fires from `start-session` whenever a session is + /// (re)started (see `makeLauncher`); callers that only inspect the node + /// list (the parity test) can rely on the defaults. + public static func plan( for model: WhereModel, bootstrap: WhereBootstrap = WhereBootstrap(), onServicesReady: @escaping @MainActor (WhereServices) async -> Void = { _ in }, - ) -> LifecycleSteps { - LifecycleSteps { - // Open the store, assemble the services, and create the session. - // The build is skipped when services are already retained (a - // preview/test injected them, or a prior session before a reset) so - // we never spin up a real store + CoreLocation behind it; the - // session is then (re)created from the retained layer. Opening may - // run a lightweight migration; there's no separate UI for it — the - // launch splash (shown throughout) fades in its own launch-neutral - // "taking a moment" caption when any launch phase runs long. - LifecycleStep.work(LaunchStepID.openStore) { _ in - guard model.session == nil else { return } - if !model.hasServices { - try await model.attach(services: bootstrap.makeServices()) - } - model.startSession() - // Hand the (re)started session's service layer to the app's - // composition hook before any later step — or the UI — runs, - // so consumers awaiting it (parked App Intents) resume against - // this session's store. - if let session = model.session { - await onServicesReady(session.services) - } - } - - // First run only. `LifecycleStep.interactive` defaults to - // `modes: .foreground`, so a headless launch (unpromoted - // `.undetermined`, or a `.background` relaunch) skips it (and never - // deadlocks waiting for a tap that can't come). - LifecycleStep.interactive( - LaunchStepID.onboarding, - condition: { !model.hasOnboarded }, - ) { OnboardingView(bridge: $0) } - - LifecycleStep.work(LaunchStepID.syncAuth) { _ in - await model.session?.syncAuthorization() - model.session?.observeAuthorizationChanges() - await model.session?.seedRegionStyles() - model.session?.observeRegionStyleChanges() - } - LifecycleStep.work(LaunchStepID.reconcileTracking) { _ in - await model.session?.reconcileTracking() - } - // Foreground-only: a headless launch shouldn't trigger a fresh - // foreground fix (a `.background` relaunch is itself the passive - // location event), so it runs only once a scene promotes the launch. - // Returns fast (the ingestor spawns the ~10s fix internally), so it - // never delays reaching `.ready`. - LifecycleStep.work(LaunchStepID.captureToday, modes: .foreground) { _ in - await model.session?.captureTodayIfNeeded() + ) -> LaunchPlan { + LaunchPlan(OpenStoreStep(model: model, bootstrap: bootstrap)) + .then(StartSessionStep(model: model, onServicesReady: onServicesReady)) + .gate(OnboardingGate(model: model)) + .thenKeeping(SyncAuthStep()) + .thenKeeping(ReconcileTrackingStep()) + .detached { + CaptureTodayStep() + RemindersStep() + SummaryStep() + IssueAlertsStep() + WidgetSnapshotStep() } - LifecycleStep.work(LaunchStepID.reminders) { _ in - await model.session?.applyReminderConfiguration() - } - LifecycleStep.work(LaunchStepID.summary) { _ in - await model.session?.applySummaryConfiguration() - } - LifecycleStep.work(LaunchStepID.issueAlerts) { _ in - await model.session?.applyIssueAlertConfiguration() - } - LifecycleStep.work(LaunchStepID.widgetSnapshot) { _ in - await model.session?.refreshWidgetSnapshot() - } - } } - /// The reverse of `sequence`: the teardown run by Settings' "Erase all data - /// & reset". `LifecycleRunner.teardown` runs these steps, then re-drives - /// `sequence` from the top — which, with `hasOnboarded` now cleared, lands - /// back on the onboarding step, returning the app to its first-run state. - public static func resetSequence(for model: WhereModel) -> LifecycleSteps { - LifecycleSteps { - // Stop GPS and wipe the store first, then drop the session and clear - // the preferences that gate the relaunch (onboarding, tracking - // intent, reminders). If the erase throws the runner parks in - // `.failed`, the session and preferences are left intact, so a retry - // re-erases rather than stranding the user in onboarding atop - // un-erased data. Dropping the session here makes the re-driven - // `sequence` rebuild a fresh one over the erased store. - LifecycleStep.work(LaunchStepID.eraseData) { _ in - try await model.eraseAllData() - model.endSession() - } - LifecycleStep - .work(LaunchStepID.resetPreferences) { _ in model.resetPreferences() } - } + /// The reverse of `plan(for:)`: the teardown run by Settings' "Erase all + /// data & reset", rooted at the session being torn down (Settings hands + /// it in as the teardown input — no optional re-read). `LifecycleRunner + /// .teardown` runs these nodes, then re-drives the launch plan from the + /// top — which, with `hasOnboarded` now cleared, parks on the onboarding + /// gate again, returning the app to its first-run state. + public static func resetPlan(for model: WhereModel) + -> LaunchPlan + { + LaunchPlan(EraseDataStep(model: model)) + .then(ResetPreferencesStep(model: model)) } } diff --git a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift new file mode 100644 index 00000000..c92083c4 --- /dev/null +++ b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift @@ -0,0 +1,180 @@ +import LifecycleKit +import WhereCore + +// The typed steps `WhereLaunch.plan(for:)` and `resetPlan(for:)` assemble. +// +// Each step's `Input`/`Output` is the launch's dependency scope at that +// point (see the scope convention in `WhereLaunch`): `OpenStoreStep` mints +// the store scope (`WhereServices`), `StartSessionStep` promotes it to the +// session scope (`WhereSession`, which embeds the services), and everything +// downstream takes the **non-optional** session as input — a step cannot be +// scheduled before the thing it needs exists, and no step reaches into +// `WhereModel` optionals to find what an earlier step "should have" set. + +/// Open the SwiftData store and assemble the service layer — the process's +/// **one** store open (everything else shares the instance by injection; see +/// `WhereBootstrap.makeServices`). Skipped work when services already exist +/// (a preview/test injected them, or a prior session before a reset), so we +/// never spin up a real store + CoreLocation behind a retained layer. +/// Opening may run a lightweight migration; there's no separate UI for it — +/// the launch splash (shown throughout) fades in its own launch-neutral +/// "taking a moment" caption when any launch phase runs long. +struct OpenStoreStep: LifecycleStep { + let model: WhereModel + let bootstrap: WhereBootstrap + + let id = LaunchStepID.openStore + + func run(_: Void, _: LifecycleStepContext) async throws -> WhereServices { + if let services = model.services { return services } + let services = try await bootstrap.makeServices() + model.attach(services: services) + return services + } +} + +/// Create the logged-in `WhereSession` over the assembled services and hand +/// the service layer to the app's composition hook before any later node — +/// or the UI — runs, so consumers awaiting it (parked App Intents) resume +/// against this session's store. Runs on every session (re)start: first +/// launch, a retry after a failed open, and the reset relaunch (the +/// teardown's fresh attempt clears the run-once memo). +struct StartSessionStep: LifecycleStep { + let model: WhereModel + let onServicesReady: @MainActor (WhereServices) async -> Void + + let id = LaunchStepID.startSession + + func run(_ services: WhereServices, _: LifecycleStepContext) async throws -> WhereSession { + let session = model.startSession(services: services) + await onServicesReady(session.services) + return session + } +} + +/// First-run onboarding. A gate (foreground-only by default), so a headless +/// launch skips it — and `isNeeded` re-evaluates when a scene promotes the +/// launch, so a cold `.undetermined` start still onboards once it becomes +/// user-visible. `RootView` registers `OnboardingView` for this gate type; +/// the view receives the session this gate passes through. +struct OnboardingGate: LifecycleGate { + let model: WhereModel + + let id = LaunchStepID.onboarding + + func isNeeded(_: WhereSession) async -> Bool { + !model.hasOnboarded + } +} + +/// Read location authorization into the coordinator and start observing live +/// authorization + region-style changes. Stays on the trunk: the +/// reconcile-tracking step must see the synced authorization. +struct SyncAuthStep: LifecycleStep { + let id = LaunchStepID.syncAuth + + func run(_ session: WhereSession, _: LifecycleStepContext) async throws { + await session.syncAuthorization() + session.observeAuthorizationChanges() + await session.seedRegionStyles() + session.observeRegionStyleChanges() + } +} + +/// Start or stop GPS ingestion to match the user's intent + authorization. +/// Stays on the trunk, after `SyncAuthStep`, so it reconciles against the +/// authorization that step just synced. +struct ReconcileTrackingStep: LifecycleStep { + let id = LaunchStepID.reconcileTracking + + func run(_ session: WhereSession, _: LifecycleStepContext) async throws { + await session.reconcileTracking() + } +} + +/// Take a one-shot GPS fix for today if none is logged yet, so opening the +/// app on a fresh day fills the calendar in. Foreground-only — a headless +/// launch shouldn't spend a fresh fix (a `.background` relaunch is itself +/// the passive event); it runs once a scene promotes the launch. +struct CaptureTodayStep: LifecycleStep { + let id = LaunchStepID.captureToday + let modes: LifecycleModeSet = .foreground + + func run(_ session: WhereSession, _: LifecycleStepContext) async throws { + await session.captureTodayIfNeeded() + } +} + +/// Push the logging-reminder schedule + badge (backlog + issue count) to the +/// reconciler. +struct RemindersStep: LifecycleStep { + let id = LaunchStepID.reminders + + func run(_ session: WhereSession, _: LifecycleStepContext) async throws { + await session.applyReminderConfiguration() + } +} + +/// Push the daily-summary recap to the reconciler. +struct SummaryStep: LifecycleStep { + let id = LaunchStepID.summary + + func run(_ session: WhereSession, _: LifecycleStepContext) async throws { + await session.applySummaryConfiguration() + } +} + +/// Push the "issues to resolve" notification intent to its reconciler. +struct IssueAlertsStep: LifecycleStep { + let id = LaunchStepID.issueAlerts + + func run(_ session: WhereSession, _: LifecycleStepContext) async throws { + await session.applyIssueAlertConfiguration() + } +} + +/// Republish the widget snapshot from whatever is already on disk, so a cold +/// launch with no writes this session doesn't leave the widget blank or +/// showing the previous day's "today". +struct WidgetSnapshotStep: LifecycleStep { + let id = LaunchStepID.widgetSnapshot + + func run(_ session: WhereSession, _: LifecycleStepContext) async throws { + await session.refreshWidgetSnapshot() + } +} + +// MARK: - Reset teardown steps + +/// Stop GPS, wipe the store, and drop the session. Takes the session being +/// erased as the teardown plan's root input — handed in by Settings, not +/// re-read from an optional. If the erase throws the runner parks in +/// `.failed` (terminally — teardown runs fire-once) with the session and +/// preferences *intact*, so the reset is simply re-invocable from Settings +/// after relaunching, rather than stranding the user in onboarding atop +/// un-erased data. +struct EraseDataStep: LifecycleStep { + let model: WhereModel + + let id = LaunchStepID.eraseData + + func run(_ session: WhereSession, _: LifecycleStepContext) async throws { + try await session.eraseSession() + // Dropping the session makes the relaunch rebuild a fresh one over + // the erased store (the services stay retained). + model.endSession() + } +} + +/// Clear the persisted preferences that gate the relaunch (onboarding flag, +/// tracking intent, reminder/summary schedules), so the next launch behaves +/// like a fresh install. +struct ResetPreferencesStep: LifecycleStep { + let model: WhereModel + + let id = LaunchStepID.resetPreferences + + func run(_: Void, _: LifecycleStepContext) async throws { + model.resetPreferences() + } +} diff --git a/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift b/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift index 8f4cd5b9..6afcc381 100644 --- a/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift +++ b/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift @@ -26,6 +26,11 @@ enum WhereLaunchLog: LogEvent { /// Retention pruning failed; the store is still usable (last good history /// preserved), it just isn't trimmed this launch. case historyPruneFailed(description: String) + /// A detached (fire-and-forget) launch step failed. Never fatal — the + /// launch reaches `.ready` regardless and the runner records it on + /// `detachedFailures` — but it must be visible in logs too, not just on + /// observable state nothing renders (see `DetachedFailureReporter`). + case detachedStepFailed(stepID: String, description: String) static let eventName = "WhereLaunch" @@ -34,8 +39,10 @@ enum WhereLaunchLog: LogEvent { case .runnerCreated, .servicesAssembled, .loggingStoreReady, .historyPruned: .info // The store is still usable when pruning fails (degraded-but-handled), - // unlike an outright open failure. - case .historyPruneFailed: + // unlike an outright open failure. A detached-step failure is the + // same shape: the launch stays healthy, one best-effort fan-out + // didn't land. + case .historyPruneFailed, .detachedStepFailed: .warning case .servicesAssemblyFailed, .loggingStoreUnavailable: .error @@ -58,6 +65,8 @@ enum WhereLaunchLog: LogEvent { "Pruned \(prunedEventCount) log event(s) past retention" case let .historyPruneFailed(description): "Failed to prune log history: \(description)" + case let .detachedStepFailed(stepID, description): + "Detached launch step '\(stepID)' failed: \(description)" } } } diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 09a6cba5..fa78fd7c 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -19,13 +19,17 @@ public final class WhereModel { /// The assembled service layer, retained across the app's lifetime once /// built. Survives a reset (so the re-driven launch rebuilds the session /// from it rather than reopening the store / rewiring CoreLocation) and is - /// the preview/test injection seam. Nil only in the pre-`open-store` window. - private var services: WhereServices? - - /// The logged-in, services-backed state. Nil until the launch's - /// `open-store` step calls `startSession()`; dropped by `endSession()` on - /// reset and rebuilt when the launch re-drives. Logged-in views read it via - /// `@Environment(WhereSession.self)`. + /// the preview/test injection seam. Nil only in the pre-`open-store` + /// window; the launch's `OpenStoreStep` reads it to skip rebuilding a + /// retained layer. + private(set) var services: WhereServices? + + /// The logged-in, services-backed state, mirrored here for surfaces the + /// launch container doesn't feed (the DEBUG developer overlay, the + /// environment injection in `RootView`). The authoritative handoff is the + /// launch itself: `StartSessionStep` returns the session and the runner's + /// `.ready` carries it to the UI. Nil until that step runs; dropped by + /// `endSession()` on reset and rebuilt when the launch re-drives. public private(set) var session: WhereSession? /// The process-global Periscope log store, opened at launch and attached to @@ -111,13 +115,6 @@ public final class WhereModel { ) } - /// Whether the service layer has been assembled yet. The launch's - /// `open-store` step reads this to skip building real services when a - /// preview/test injected them up front. - var hasServices: Bool { - services != nil - } - /// Retain the process-global log store the launch bootstrap opened and /// attached to `Periscope.shared`. Called once, off the launch critical /// path, so the developer surface can browse persisted history. @@ -135,19 +132,22 @@ public final class WhereModel { self.services = services } - /// Create the logged-in `WhereSession` from the retained services. The - /// launch's `open-store` step calls this after `attach(services:)`; a no-op - /// when a session already exists or before services are attached. The - /// re-driven launch after a reset rebuilds a fresh session here from the - /// retained (now-erased) services. - public func startSession() { - guard session == nil, let services else { return } - session = WhereSession( + /// Create the logged-in `WhereSession` over `services` and return it — + /// the launch's `start-session` step consumes the return value directly + /// (its typed output), rather than the session being re-read from an + /// optional. Returns the existing session when one is already live (a + /// preview/test built it up front). The re-driven launch after a reset + /// rebuilds a fresh session here over the retained (now-erased) services. + public func startSession(services: WhereServices) -> WhereSession { + if let session { return session } + let session = WhereSession( services: services, preferences: preferences, now: now, ) + self.session = session Self.logger { .startedSession(year: initialSelectedYear) } + return session } /// Drop the logged-in session (the services stay retained). Run by the @@ -160,15 +160,6 @@ public final class WhereModel { // MARK: - Reset / erase all - /// Erase all persisted data, returning the app to a clean slate. Forwards to - /// `WhereSession.eraseSession()` (which calls `WhereServices.reset()`); the - /// data half of the reset/erase teardown (see `WhereLaunch.resetSequence`). - /// Throws on persistence failure so the reset step parks the launcher in - /// `.failed` rather than silently half-erasing. - public func eraseAllData() async throws { - try await session?.eraseSession() - } - /// Clear every persisted preference so the next launch behaves like a fresh /// install: onboarding shows again (`hasOnboarded` gone), background /// tracking returns to its default intent, and the reminder/summary diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 01c454d0..d4014be0 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -155,7 +155,7 @@ public final class WhereSession { /// summary schedules, and republish the widget snapshot. Safe to call /// repeatedly; the authorization observer is only set up once. /// - /// This is the imperative equivalent of `WhereLaunch.sequence`'s coordinator + /// This is the imperative equivalent of `WhereLaunch.plan(for:)`'s coordinator /// work steps, kept for previews/tests that drive the coordinator directly /// without a `LifecycleRunner`. Report/data-issue loading is *not* here — the /// scene's `YearReportModel` owns that and starts it when the UI appears. @@ -193,7 +193,7 @@ public final class WhereSession { } /// Republish the widget snapshot from whatever is on disk. A launch step - /// in its own right (see `WhereLaunch.sequence`). + /// in its own right (see `WhereLaunch.plan(for:)`). public func refreshWidgetSnapshot() async { await services.widgets.refreshIfStale() } @@ -201,7 +201,7 @@ public final class WhereSession { /// Read the current authorization status from the ingestor into our /// observable state. Does not surface the permission alert — that's /// reserved for explicit user actions. A launch step (see - /// `WhereLaunch.sequence`). + /// `WhereLaunch.plan(for:)`). func syncAuthorization() async { authorizationStatus = await services.ingestor.authorizationStatus() warnIfAuthorizationDegraded() @@ -248,7 +248,7 @@ public final class WhereSession { /// Load the user's picked region appearances into ``regionStyles`` so the /// UI resolves them everywhere it renders a region. A launch step (see - /// `WhereLaunch.sequence`); also re-run on every store change via + /// `WhereLaunch.plan(for:)`); also re-run on every store change via /// `observeRegionStyleChanges()`. On failure it keeps the last good resolver /// (honest degraded state) and logs. func seedRegionStyles() async { @@ -278,7 +278,7 @@ public final class WhereSession { /// Start or stop GPS ingestion so it matches the user's intent and the /// current authorization. Tracking only runs with Always authorization. A - /// launch step (see `WhereLaunch.sequence`). + /// launch step (see `WhereLaunch.plan(for:)`). func reconcileTracking() async { let wasTracking = isTracking if wantsTracking, authorizationStatus.allowsBackgroundTracking { @@ -298,7 +298,7 @@ public final class WhereSession { /// and a usable authorization (When-In-Use is enough for a foreground fix — /// notably the only way When-In-Use users get any data). The ingestor is /// non-blocking and reconciles widgets / reminders + pings the read signal - /// on persist. A launch step (see `WhereLaunch.sequence`); also runs on + /// on persist. A launch step (see `WhereLaunch.plan(for:)`); also runs on /// every foreground. func captureTodayIfNeeded() async { guard wantsTracking, authorizationStatus.allowsForegroundFix else { return } @@ -355,7 +355,7 @@ public final class WhereSession { /// notifications are enabled but unauthorized. Reads `WherePreferences` /// directly (the single source of truth the `RemindersSettingsModel` also /// writes), so it re-applies whatever the user last chose. A launch step - /// (see `WhereLaunch.sequence`); also runs on every foreground. + /// (see `WhereLaunch.plan(for:)`); also runs on every foreground. func applyReminderConfiguration() async { let enabled = preferences.remindersEnabled // The reminder reconciler also owns the app-icon badge, whose value folds @@ -380,7 +380,7 @@ public final class WhereSession { /// Push the persisted daily-summary intent to the summary reconciler and warn /// if enabled but unauthorized. Reads `WherePreferences` directly, mirroring - /// `applyReminderConfiguration()`. A launch step (see `WhereLaunch.sequence`); + /// `applyReminderConfiguration()`. A launch step (see `WhereLaunch.plan(for:)`); /// also runs on every foreground. func applySummaryConfiguration() async { let enabled = preferences.summaryEnabled @@ -400,7 +400,7 @@ public final class WhereSession { /// warn if enabled but unauthorized. Fires the alert at the evening reminder /// time and scans at the current drift threshold. Reads `WherePreferences` /// directly, mirroring `applyReminderConfiguration()`. A launch step (see - /// `WhereLaunch.sequence`); also runs on every foreground. + /// `WhereLaunch.plan(for:)`); also runs on every foreground. func applyIssueAlertConfiguration() async { let enabled = preferences.issueAlertsEnabled await services.issueAlerts.configure( @@ -425,7 +425,7 @@ public final class WhereSession { /// empty widget snapshot); the coordinator only mirrors the outcome. The /// scene's `YearReportModel` is torn down and rebuilt by the relaunch, so no /// report/issue state needs clearing here. The data half of the reset/erase - /// teardown (see `WhereLaunch.resetSequence`); throws on persistence failure + /// teardown (see `WhereLaunch.resetPlan(for:)`); throws on persistence failure /// so the reset step parks the launcher in `.failed` rather than silently /// half-erasing. public func eraseSession() async throws { diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index eee068ee..ff5f5b4c 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -12,18 +12,18 @@ import WhereCore /// /// When the user finishes it commits the picked regions + appearances to the /// store (which becomes the tracked-region set), persists `hasOnboarded`, and -/// resolves the `LifecycleStepUIBridge` so the launch continues; the following +/// resolves the `LifecycleGateHandle` so the launch continues; the following /// authorization-sync step then seeds region styling and picks up whatever /// permission was granted. public struct OnboardingView: View { // Onboarding straddles both: it persists the app-level `hasOnboarded` flag - // (model) and kicks off background tracking + the region commit through the - // session, which the `open-store` step has already built by the time this - // step runs. + // (model) and kicks off background tracking + the region commit through + // the session — handed in by the gate registration (the gate passes the + // trunk's session through), not trusted to be in the environment. @Environment(WhereModel.self) private var model - @Environment(WhereSession.self) private var session @Environment(\.stylesheet) private var stylesheet - private let bridge: LifecycleStepUIBridge + private let gate: LifecycleGateHandle + private let session: WhereSession /// The ordered onboarding phases. An explicit state machine (rather than /// loose flags) so only one screen is ever showing and the transitions are @@ -48,8 +48,9 @@ public struct OnboardingView: View { private static let logger = WhereLog.session(OnboardingViewLog.self) - public init(bridge: LifecycleStepUIBridge) { - self.bridge = bridge + public init(gate: LifecycleGateHandle, session: WhereSession) { + self.gate = gate + self.session = session } private let pages = OnboardingPage.all @@ -267,7 +268,7 @@ public struct OnboardingView: View { } } model.completeOnboarding() - bridge.complete() + gate.complete() } } @@ -338,8 +339,10 @@ struct OnboardingPage: Identifiable { #if DEBUG #Preview { - OnboardingView(bridge: LifecycleStepUIBridge(reason: .userForeground)) - .environment(PreviewSupport.loadedModel()) - .environment(PreviewSupport.loadedSession()) + OnboardingView( + gate: LifecycleGateHandle(id: LaunchStepID.onboarding, reason: .userForeground), + session: PreviewSupport.loadedSession(), + ) + .environment(PreviewSupport.loadedModel()) } #endif diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index c1267cb6..47c2503e 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -1,4 +1,5 @@ import LifecycleKit +import LifecycleKitUI import PeriscopeUI import SwiftUI import WhereCore @@ -7,14 +8,15 @@ import WhereCore import PeriscopeTools #endif -/// The app's root: the launch sequence gated in front of a Liquid Glass tab bar -/// over the four top-level screens (Primary, Elsewhere, Resolve, Settings). +/// The app's root: the launch plan gated in front of `MainTabs`, the Liquid +/// Glass tab bar over three tabs (Locations, Your Year, Settings). /// -/// `LifecycleContainer` renders the splash / onboarding / migration UI while -/// the `LifecycleRunner` runs, then the `TabView` (the real "logged-in" UI — -/// the launch *destination*, not a step) once it reaches `.ready`. The model is -/// built at launch (so CoreLocation is wired for background relaunch) and shared -/// down through the environment. +/// `LifecycleContainer` renders the splash / onboarding UI while the +/// `LifecycleRunner` runs, then the `TabView` (the real "logged-in" UI — the +/// launch *destination*, not a step) once it reaches `.ready`, built from the +/// session the launch's trunk produced. The model is built at launch (so +/// CoreLocation is wired for background relaunch) and shared down through the +/// environment. public struct RootView: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -40,10 +42,10 @@ public struct RootView: View { @State private var alerter: PeriscopeAlerter? @State private var toastCenter = DeveloperToastCenter() #endif - private let launcher: LifecycleRunner + private let launcher: LifecycleRunner /// Inject the app-owned model + runner built at launch. The app uses this. - public init(model: WhereModel, launcher: LifecycleRunner) { + public init(model: WhereModel, launcher: LifecycleRunner) { _model = State(initialValue: model) self.launcher = launcher } @@ -64,22 +66,30 @@ public struct RootView: View { transition: revealTransition, animation: revealAnimation, minimumSplashDuration: stylesheet.launch.minimumSplashDuration, - splash: { LaunchSplashView() }, - failure: { LifecycleFailureView(failure: $0, retry: $1) }, - ) { - // At `.ready` the session is always present; `MainTabs` owns the - // scene-scoped `YearReportModel` and gets a fresh one whenever a reset - // rebuilds the session. Keyed on the session's monotonic `id` (never - // reused within the process) rather than its address, so a rebuilt - // session can't collide with a freed one and skip the rebuild. - if let session = model.session { - MainTabs( - session: session, - initialReport: model.initialReport, - selectedYear: model.initialSelectedYear, - ) - .id(session.id) - } + splash: { _ in LaunchSplashView() }, + failure: { LifecycleFailureView(failure: $0) }, + gates: { + // The gate passes the trunk's session through, so + // onboarding is handed the session it commits regions + // with — not left to find one in the environment. + GateView(for: OnboardingGate.self) { handle, session in + OnboardingView(gate: handle, session: session) + } + }, + ) { session in + // `.ready` carries the session the launch produced — the app + // surface cannot render without it. `MainTabs` owns the + // scene-scoped `YearReportModel` and gets a fresh one whenever + // a reset rebuilds the session. Keyed on the session's + // monotonic `id` (never reused within the process) rather than + // its address, so a rebuilt session can't collide with a freed + // one and skip the rebuild. + MainTabs( + session: session, + initialReport: model.initialReport, + selectedYear: model.initialSelectedYear, + ) + .id(session.id) } // Extend the app content's safe area by the floating HUD's footprint so // scroll views behind the non-modal window inset and their last rows @@ -112,17 +122,18 @@ public struct RootView: View { // `\.logContext` emits under the "Where" scope rather than a bare root. .logContext(WhereLog.root) .environment(model) - // The logged-in session appears once `open-store` builds it. Injected - // as an optional `Observable`, so the `TabView`'s `@Environment(WhereSession.self)` - // views resolve it (they only render at `.ready`, by which point it's - // present) and re-inject when a reset rebuilds it. The DEBUG developer - // overlay reads it optionally — it can appear before login, where the + // The logged-in session appears once the launch's `start-session` + // step builds it. Injected as an optional `Observable`, so the + // `TabView`'s `@Environment(WhereSession.self)` views resolve it + // (they only render at `.ready`, by which point it's present) and + // re-inject when a reset rebuilds it. The DEBUG developer overlay + // reads it optionally — it can appear before login, where the // SwiftData inspector row simply hides. .environment(model.session) // Settings' "Erase all data & reset" runs the teardown through the - // `LifecycleRunner` that `LifecycleContainer` publishes into the - // environment, which wipes data + preferences and re-drives the launch - // sequence back to onboarding. + // `LifecycleProxy` that `LifecycleContainer` publishes into the + // environment, which wipes data + preferences and re-drives the + // launch plan back to onboarding. // // `run()` is idempotent: in the app the delegate already kicked it off, // so this is a no-op there; in previews/tests it's what drives the diff --git a/Where/WhereUI/Sources/Settings/DataSettingsView.swift b/Where/WhereUI/Sources/Settings/DataSettingsView.swift index e98efe6b..7d38bbc1 100644 --- a/Where/WhereUI/Sources/Settings/DataSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/DataSettingsView.swift @@ -1,4 +1,4 @@ -import LifecycleKit +import LifecycleKitUI import SwiftUI import WhereCore @@ -9,7 +9,11 @@ struct DataSettingsView: View { var focus: SettingsFocus? @Environment(WhereModel.self) private var model - @Environment(\.lifecycleRunner) private var runner + // The reset plan is rooted at the session being torn down, so this screen + // reads it from the environment (it only renders at `.ready`, where the + // session is present) rather than the teardown re-reading an optional. + @Environment(WhereSession.self) private var session + @Environment(\.lifecycle) private var lifecycle @State private var showClearConfirmation = false @State private var showResetConfirmation = false @@ -57,9 +61,9 @@ struct DataSettingsView: View { } /// Whole-app teardown: wipes every year's data and returns to first-run - /// onboarding, run through the `LifecycleRunner` published into the - /// environment by `LifecycleContainer`. The runner proxy asserts in debug / - /// no-ops in release when no container is above (e.g. previews). + /// onboarding, run through the `LifecycleProxy` that `LifecycleContainer` + /// publishes under `\.lifecycle`. The proxy asserts in debug / no-ops in + /// release when no container is above (e.g. previews). private var resetSection: some View { Section { Button(role: .destructive) { @@ -86,7 +90,7 @@ struct DataSettingsView: View { } private func requestReset() { - Task { await runner.teardown(WhereLaunch.resetSequence(for: model)) } + Task { await lifecycle.teardown(WhereLaunch.resetPlan(for: model), input: session) } } } @@ -120,6 +124,7 @@ extension DataSettingsView: SettingsSection { NavigationStack { DataSettingsView(report: PreviewSupport.loadedYearReportModel()) .environment(PreviewSupport.loadedModel()) + .environment(PreviewSupport.loadedSession()) } .whereBroadwayRoot() } diff --git a/Where/WhereUI/Tests/DetachedFailureReporterTests.swift b/Where/WhereUI/Tests/DetachedFailureReporterTests.swift new file mode 100644 index 00000000..5aa9f8db --- /dev/null +++ b/Where/WhereUI/Tests/DetachedFailureReporterTests.swift @@ -0,0 +1,99 @@ +import Foundation +import LifecycleKit +import Testing +@testable import WhereUI + +private struct FanError: Error {} + +private struct WaitTimeout: Error {} + +/// Polls `predicate` on the main actor until it holds or the timeout elapses, +/// yielding to the observation re-arm hop between checks. +@MainActor +private func waitUntil( + timeout: Duration = .seconds(5), + _ predicate: () -> Bool, +) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while !predicate() { + if ContinuousClock.now >= deadline { throw WaitTimeout() } + try await Task.sleep(for: .milliseconds(1)) + } +} + +/// A minimal typed root so a reporter test can drive a real runner without +/// Where's services. +private struct RootStep: LifecycleStep { + let id = "root" + + func run(_: Void, _: LifecycleStepContext) async throws -> String { + "value" + } +} + +/// A detached child that always throws, landing on `detachedFailures`. +private struct ThrowingChildStep: LifecycleStep { + let id: String + + func run(_: String, _: LifecycleStepContext) async throws { + throw FanError() + } +} + +@MainActor +struct DetachedFailureReporterTests { + private func failure(_ id: String) -> LifecycleFailure { + LifecycleFailure(stepID: id, error: FanError()) + } + + @Test func reportsOnlyTheNewEntriesExactlyOnce() { + let reporter = DetachedFailureReporter() + + let first = reporter.report([failure("a")]) + #expect(first.map { "\($0.stepID)" } == ["a"]) + + // Re-reporting the same array adds nothing; growing it reports only + // the tail. + #expect(reporter.report([failure("a")]).isEmpty) + let second = reporter.report([failure("a"), failure("b")]) + #expect(second.map { "\($0.stepID)" } == ["b"]) + #expect(reporter.reportedTotal == 2) + } + + @Test func restartsAfterTheRunnerClearsForAFreshAttempt() { + // The runner empties `detachedFailures` when a fresh attempt begins + // (first run, post-teardown relaunch); a shrink restarts reporting so + // the next attempt's failures aren't misread as already seen. + let reporter = DetachedFailureReporter() + reporter.report([failure("a"), failure("b")]) + + #expect(reporter.report([]).isEmpty) + let next = reporter.report([failure("c")]) + #expect(next.map { "\($0.stepID)" } == ["c"]) + #expect(reporter.reportedTotal == 3) + } + + @Test func observingALiveRunnerReportsFailuresAsTheyLand() async throws { + // End-to-end plumbing: a runner whose detached fan throws must reach + // the reporter through the observation chain — no view tree involved, + // matching the headless-launch case the reporter exists for. + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(RootStep()) + .detached { + ThrowingChildStep(id: "boom-1") + ThrowingChildStep(id: "boom-2") + }, + ) + let reporter = DetachedFailureReporter() + reporter.observe(runner) + + await runner.run() + #expect(runner.phase.isReady) + #expect(runner.detachedFailures.count == 2) + + // The onChange → re-arm hop is asynchronous; wait for the reports to + // drain rather than assuming a fixed number of run-loop turns. + try await waitUntil { reporter.reportedTotal == 2 } + } +} diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift index 1cd18271..dc7b6375 100644 --- a/Where/WhereUI/Tests/OnboardingTests.swift +++ b/Where/WhereUI/Tests/OnboardingTests.swift @@ -32,12 +32,16 @@ struct OnboardingModelTests { @MainActor struct OnboardingViewTests { @Test func onboardingViewRenders() throws { - // Onboarding reads both the app model and the logged-in session; the - // injected services build the session up front, so inject both. + // Onboarding reads the app model from the environment and is handed + // the session directly (as the gate registration does); the injected + // services build the session up front. let model = WhereModel(services: PreviewSupport.previewServices()) - let view = OnboardingView(bridge: LifecycleStepUIBridge(reason: .userForeground)) - .environment(model) - .environment(model.session) + let session = try #require(model.session) + let view = OnboardingView( + gate: LifecycleGateHandle(id: LaunchStepID.onboarding, reason: .userForeground), + session: session, + ) + .environment(model) try show(UIHostingController(rootView: view)) { hosted in waitForOneRunloop() diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 97bfaed1..9c36934c 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -89,11 +89,13 @@ struct ScreenHostingTests { } @Test func dataSettingsViewHosts() throws { - // Reads the app model (reset sequence) from the environment. + // Reads the app model and the session the reset plan is rooted at from + // the environment. let rootView = NavigationStack { DataSettingsView(report: PreviewSupport.loadedYearReportModel()) } .environment(PreviewSupport.loadedModel()) + .environment(PreviewSupport.loadedSession()) try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } @@ -108,6 +110,7 @@ struct ScreenHostingTests { DataSettingsView(report: PreviewSupport.loadedYearReportModel(), focus: focus) } .environment(PreviewSupport.loadedModel()) + .environment(PreviewSupport.loadedSession()) try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index e23cfb3a..f827d196 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -94,13 +94,15 @@ struct WhereLaunchTests { ) } - @Test func sequenceStepsRunInStartParityOrder() throws { - // The work steps mirror WhereSession.start()'s order; the only insertion - // is the onboarding gate. + @Test func planNodesRunInStartParityOrder() throws { + // The work steps mirror WhereSession.start()'s order; the only + // insertions are the start-session scope promotion and the onboarding + // gate. let model = try makeModel(preferences: makePreferences()) - let ids = WhereLaunch.sequence(for: model).steps.map(\.id) + let ids = WhereLaunch.plan(for: model).nodeIDs #expect(ids == [ - LaunchStepID.openStore, + .openStore, + .startSession, .onboarding, .syncAuth, .reconcileTracking, @@ -109,7 +111,7 @@ struct WhereLaunchTests { .summary, .issueAlerts, .widgetSnapshot, - ].map { AnyHashable($0) }) + ]) } @Test func coldForegroundLaunchReachesReadyAndReconcilesTracking() async throws { @@ -190,19 +192,21 @@ struct WhereLaunchTests { #expect(try await store.allSamples().isEmpty) } - @Test func firstRunForegroundLaunchPresentsOnboarding() async throws { + @Test func firstRunForegroundLaunchParksOnTheOnboardingGate() async throws { let model = try makeModel(status: .notDetermined, preferences: makePreferences()) #expect(!model.hasOnboarded) let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) let task = Task { @MainActor in await launcher.run() } - try await waitUntil { launcher.phase.isRunning(LaunchStepID.onboarding) } - #expect(launcher.phase.runningBridge?.presentation != nil) + try await waitUntil { launcher.phase.isAwaitingGate(LaunchStepID.onboarding) } + #expect(launcher.phase.gateHandle != nil) // Resolve the gate as OnboardingView would, letting the launch finish. - launcher.phase.runningBridge?.complete() + launcher.phase.gateHandle?.complete() await task.value #expect(launcher.phase.isReady) + // .ready carries the session the trunk produced. + #expect(launcher.phase.readyValue === model.session) } @Test func secondLaunchSkipsOnboarding() async throws { @@ -231,10 +235,10 @@ struct WhereLaunchTests { #expect(model.logStore === store) } - @Test func openStoreHandsTheSessionsServicesToTheOnServicesReadyHook() async throws { + @Test func startSessionHandsTheSessionsServicesToTheOnServicesReadyHook() async throws { // A model with services attached but no session yet — the app's shape // when the open-store step runs (the preview/test init pre-builds the - // session, which would skip the step and the hook with it). + // session; the open-store step then reuses the injected layer). let store = try SwiftDataStore.inMemory() let services = WhereServices( store: store, @@ -287,15 +291,16 @@ struct WhereLaunchTests { // The reset teardown drops the session and re-drives the launch; the // rebuilt session (over the retained, erased services) must be handed // to the hook again so consumers ride the fresh session. The cleared - // onboarding flag parks the relaunch on onboarding — resolve it as - // OnboardingView would. + // onboarding flag parks the relaunch on the onboarding gate — resolve + // it as OnboardingView would. + let session = try #require(model.session) let teardown = Task { @MainActor in - await launcher.teardown(WhereLaunch.resetSequence(for: model)) + await launcher.teardown(WhereLaunch.resetPlan(for: model), input: session) } - try await waitUntil { launcher.phase.isRunning(LaunchStepID.onboarding) } + try await waitUntil { launcher.phase.isAwaitingGate(LaunchStepID.onboarding) } #expect(hookFires == 2) model.completeOnboarding() - launcher.phase.runningBridge?.complete() + launcher.phase.gateHandle?.complete() await teardown.value #expect(launcher.phase.isReady) } diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index 04d1c7da..a667ca16 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -21,10 +21,10 @@ private func waitUntil( } } -/// Covers the `WhereLaunch.resetSequence` teardown the Settings "Erase all data -/// & reset" action runs through `LifecycleRunner.reset`: it wipes the store, stops -/// tracking, drops the session, clears the preferences that gate onboarding, then -/// re-drives the launch back to its first-run state. +/// Covers the `WhereLaunch.resetPlan(for:)` teardown the Settings "Erase all +/// data & reset" action runs through `LifecycleRunner.teardown`: it wipes the +/// store, stops tracking, drops the session, clears the preferences that gate +/// onboarding, then re-drives the launch back to its first-run state. @MainActor struct WhereResetTests { private func makePreferences() -> WherePreferences { @@ -72,10 +72,10 @@ struct WhereResetTests { return (WhereModel(services: services, preferences: preferences), source) } - @Test func resetSequenceErasesThenClearsPreferences() throws { + @Test func resetPlanErasesThenClearsPreferences() throws { let model = try makeModel(preferences: makePreferences()) - let ids = WhereLaunch.resetSequence(for: model).steps.map(\.id) - #expect(ids == [LaunchStepID.eraseData, .resetPreferences].map { AnyHashable($0) }) + let ids = WhereLaunch.resetPlan(for: model).nodeIDs + #expect(ids == [.eraseData, .resetPreferences]) } @Test func resetPreferencesRestoresFirstInstallDefaults() throws { @@ -112,7 +112,7 @@ struct WhereResetTests { await report.refresh() #expect(report.trackedDayCount == 1) - try await model.eraseAllData() + try await session.eraseSession() #expect(!session.isTracking) // The store really is empty, not just the in-memory report: a reload @@ -153,13 +153,20 @@ struct WhereResetTests { await launcher.run() #expect(launcher.phase.isReady) - // Reset re-drives into onboarding (hasOnboarded cleared); complete it so - // the relaunch rebuilds a fresh session and reaches .ready. - let task = Task { @MainActor in - await launcher.teardown(WhereLaunch.resetSequence(for: model)) + // Reset re-drives into onboarding (hasOnboarded cleared); complete it + // so the relaunch rebuilds a fresh session and reaches .ready. The + // strong reference to the original session is scoped to the task's + // closure (released when the teardown finishes), so the weak check + // below observes only the runner's own retention. + let task: Task + do { + let original = try #require(model.session) + task = Task { @MainActor in + await launcher.teardown(WhereLaunch.resetPlan(for: model), input: original) + } } - try await waitUntil { launcher.phase.isRunning(LaunchStepID.onboarding) } - launcher.phase.runningBridge?.complete() + try await waitUntil { launcher.phase.isAwaitingGate(LaunchStepID.onboarding) } + launcher.phase.gateHandle?.complete() await task.value #expect(launcher.phase.isReady) @@ -191,13 +198,14 @@ struct WhereResetTests { await report.refresh() #expect(report.trackedDayCount == 1) - // reset re-drives the launch, which parks on onboarding again (now that - // hasOnboarded is cleared), so reset() doesn't return until onboarding - // is resolved — drive it from a task and wait for the parked step. + // reset re-drives the launch, which parks on the onboarding gate again + // (now that hasOnboarded is cleared), so the teardown doesn't return + // until onboarding is resolved — drive it from a task and wait for the + // parked gate. let task = Task { @MainActor in - await launcher.teardown(WhereLaunch.resetSequence(for: model)) + await launcher.teardown(WhereLaunch.resetPlan(for: model), input: session) } - try await waitUntil { launcher.phase.isRunning(LaunchStepID.onboarding) } + try await waitUntil { launcher.phase.isAwaitingGate(LaunchStepID.onboarding) } // Teardown ran before the relaunch reached onboarding: data erased, the // session dropped + rebuilt, and the onboarding gate reopened. @@ -205,9 +213,9 @@ struct WhereResetTests { let rebuilt = try #require(model.session) #expect(rebuilt !== session) #expect(!rebuilt.isTracking) - #expect(launcher.phase.runningBridge?.presentation != nil) + #expect(launcher.phase.gateHandle != nil) - launcher.phase.runningBridge?.complete() + launcher.phase.gateHandle?.complete() await task.value #expect(launcher.phase.isReady) @@ -236,9 +244,9 @@ struct WhereResetTests { // Drive the reset and finish the onboarding it re-drives into. let task = Task { @MainActor in - await launcher.teardown(WhereLaunch.resetSequence(for: model)) + await launcher.teardown(WhereLaunch.resetPlan(for: model), input: original) } - try await waitUntil { launcher.phase.isRunning(LaunchStepID.onboarding) } + try await waitUntil { launcher.phase.isAwaitingGate(LaunchStepID.onboarding) } // Mid-relaunch: the session was dropped and rebuilt fresh, and the // preferences were cleared (onboarding gate reopened; the reminder/summary @@ -249,7 +257,7 @@ struct WhereResetTests { #expect(preferences.remindersEnabled) #expect(preferences.summaryEnabled) - launcher.phase.runningBridge?.complete() + launcher.phase.gateHandle?.complete() await task.value #expect(launcher.phase.isReady) // The store was wiped: a fresh report read against it is empty. @@ -265,20 +273,37 @@ struct WhereResetTests { let model = try makeModel(preferences: makePreferences()) model.completeOnboarding() - let failing = LifecycleSteps { - LifecycleStep.work(LaunchStepID.eraseData) { _ in - throw CocoaError(.fileWriteUnknown) - } - LifecycleStep.work(LaunchStepID.resetPreferences) { _ in - model.resetPreferences() - } - } + let failing = LaunchPlan(FailingEraseStep()) + .then(ResetPreferencesProbeStep(model: model)) let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) await launcher.run() #expect(launcher.phase.isReady) - await launcher.teardown(failing) + let session = try #require(model.session) + await launcher.teardown(failing, input: session) #expect(launcher.phase.failed(at: LaunchStepID.eraseData)) #expect(model.hasOnboarded) // reset-preferences never ran } } + +/// An erase stand-in that always throws, so the teardown-failure path can be +/// driven without corrupting a real store. +private struct FailingEraseStep: LifecycleStep { + let id = LaunchStepID.eraseData + + func run(_: WhereSession, _: LifecycleStepContext) async throws { + throw CocoaError(.fileWriteUnknown) + } +} + +/// The real reset-preferences behavior behind the failing erase, proving it +/// never runs when the erase throws. +private struct ResetPreferencesProbeStep: LifecycleStep { + let model: WhereModel + + let id = LaunchStepID.resetPreferences + + func run(_: Void, _: LifecycleStepContext) async throws { + model.resetPreferences() + } +}