From e2c1b68f14a21aefac4dbc3416306febf377aeea Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 18:31:36 -0700 Subject: [PATCH 01/17] Add typed core model to LifecycleKit (step protocol + LaunchPlan) Plan step: core-typed-model. Pure groundwork, no behavior change. - New typed model: the LifecycleStep protocol (associated Input/Output), the LifecycleGate protocol (pass-through, foreground-default), the LaunchPlan combinators (init/then/thenKeeping/gate/ detached) whose generic constraints make out-of-order or hole-leaving plans unrepresentable, plus LifecycleStepContext (reporting half of the old bridge) and LifecycleGateHandle (gate resolution token). - Types are erased in exactly one place (LaunchPlanNode and friends, package-visible for the upcoming runner/UI split); the combinators' constraints guarantee every internal cast. - Value-producing trunk steps must keep modes == .all (precondition): only pass-through positions may gate on the launch reason. - The existing linear API is renamed with a Legacy prefix (types, files, test suites) so the final names are free for the typed engine; it still drives the Where app unchanged and is deleted at the end of this plan. - Tests: LaunchPlanTests (combinators, builder conditionals, erasure, teardown-rooted plans), LifecycleGateHandleTests (resolution machinery), shared FixtureStep/FixtureGate fixtures. --- Shared/LifecycleKit/Sources/LaunchPlan.swift | 266 ++++++++++++++++++ ...r.swift => LegacyLifecycleContainer.swift} | 52 ++-- ...nner.swift => LegacyLifecycleRunner.swift} | 23 +- .../Sources/LegacyLifecycleStep.swift | 161 +++++++++++ .../Sources/LegacyLifecycleSteps.swift | 72 +++++ .../Sources/LifecycleGateHandle.swift | 105 +++++++ .../LifecycleKit/Sources/LifecyclePhase.swift | 6 +- .../Sources/LifecycleReason.swift | 2 +- .../LifecycleKit/Sources/LifecycleStep.swift | 215 +++++--------- .../Sources/LifecycleStepContext.swift | 32 +++ .../LifecycleKit/Sources/LifecycleSteps.swift | 66 ----- .../LifecycleKit/Tests/LaunchPlanTests.swift | 132 +++++++++ ...ft => LegacyLifecycleContainerTests.swift} | 72 +++-- ...t => LegacyLifecycleRunnerFuzzTests.swift} | 12 +- ... => LegacyLifecycleRunnerResetTests.swift} | 60 ++-- ...swift => LegacyLifecycleRunnerTests.swift} | 238 +++++++++------- ...s.swift => LegacyLifecycleStepTests.swift} | 40 +-- .../Tests/LifecycleGateHandleTests.swift | 83 ++++++ .../Tests/LifecyclePhaseTests.swift | 6 +- .../Tests/LifecycleStepFixtures.swift | 45 +++ Where/Where/Sources/AppDelegate.swift | 4 +- .../WhereUI/Sources/Launch/WhereLaunch.swift | 42 +-- .../WhereUI/Sources/Model/WhereSession.swift | 2 +- Where/WhereUI/Sources/RootView.swift | 12 +- .../Sources/Settings/SettingsView.swift | 4 +- Where/WhereUI/Tests/WhereResetTests.swift | 8 +- 26 files changed, 1281 insertions(+), 479 deletions(-) create mode 100644 Shared/LifecycleKit/Sources/LaunchPlan.swift rename Shared/LifecycleKit/Sources/{LifecycleContainer.swift => LegacyLifecycleContainer.swift} (82%) rename Shared/LifecycleKit/Sources/{LifecycleRunner.swift => LegacyLifecycleRunner.swift} (95%) create mode 100644 Shared/LifecycleKit/Sources/LegacyLifecycleStep.swift create mode 100644 Shared/LifecycleKit/Sources/LegacyLifecycleSteps.swift create mode 100644 Shared/LifecycleKit/Sources/LifecycleGateHandle.swift create mode 100644 Shared/LifecycleKit/Sources/LifecycleStepContext.swift delete mode 100644 Shared/LifecycleKit/Sources/LifecycleSteps.swift create mode 100644 Shared/LifecycleKit/Tests/LaunchPlanTests.swift rename Shared/LifecycleKit/Tests/{LifecycleContainerTests.swift => LegacyLifecycleContainerTests.swift} (72%) rename Shared/LifecycleKit/Tests/{LifecycleRunnerFuzzTests.swift => LegacyLifecycleRunnerFuzzTests.swift} (93%) rename Shared/LifecycleKit/Tests/{LifecycleRunnerResetTests.swift => LegacyLifecycleRunnerResetTests.swift} (57%) rename Shared/LifecycleKit/Tests/{LifecycleRunnerTests.swift => LegacyLifecycleRunnerTests.swift} (64%) rename Shared/LifecycleKit/Tests/{LifecycleStepTests.swift => LegacyLifecycleStepTests.swift} (65%) create mode 100644 Shared/LifecycleKit/Tests/LifecycleGateHandleTests.swift create mode 100644 Shared/LifecycleKit/Tests/LifecycleStepFixtures.swift diff --git a/Shared/LifecycleKit/Sources/LaunchPlan.swift b/Shared/LifecycleKit/Sources/LaunchPlan.swift new file mode 100644 index 00000000..21cceb53 --- /dev/null +++ b/Shared/LifecycleKit/Sources/LaunchPlan.swift @@ -0,0 +1,266 @@ +/// 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`. +/// +/// 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. +/// - `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 resume a retry mid-trunk; 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] + + private init(nodes: [LaunchPlanNode]) { + self.nodes = nodes + } + + /// Root the plan at `step`. The plan's `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 { + 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, and `retry()` resumes here with + /// the memoized upstream value. + public func then(_ step: S) -> LaunchPlan + where S.Input == Output + { + 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 + { + 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 + { + 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: retry resumption, run-once + /// memoization, and gate-view registration all 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 { + 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 + { + [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/LegacyLifecycleContainer.swift similarity index 82% rename from Shared/LifecycleKit/Sources/LifecycleContainer.swift rename to Shared/LifecycleKit/Sources/LegacyLifecycleContainer.swift index e3bce817..26c27856 100644 --- a/Shared/LifecycleKit/Sources/LifecycleContainer.swift +++ b/Shared/LifecycleKit/Sources/LegacyLifecycleContainer.swift @@ -1,11 +1,11 @@ import SwiftUI extension EnvironmentValues { - /// A handle to the running `LifecycleRunner`, published by - /// `LifecycleContainer` so nested views (a custom failure view, a Settings + /// A handle to the running `LegacyLifecycleRunner`, published by + /// `LegacyLifecycleContainer` 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 + /// It's a `LifecycleRunnerProxy` rather than a bare `LegacyLifecycleRunner?` 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 @@ -13,21 +13,21 @@ extension EnvironmentValues { @Entry public var lifecycleRunner = LifecycleRunnerProxy() } -/// A debug-loud, release-quiet handle to the environment's `LifecycleRunner`. +/// A debug-loud, release-quiet handle to the environment's `LegacyLifecycleRunner`. /// -/// `LifecycleContainer` publishes a *connected* proxy; the environment default +/// `LegacyLifecycleContainer` 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 +/// The wrapped `LegacyLifecycleRunner` 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? + let base: LegacyLifecycleRunner? /// A disconnected proxy (no runner): the environment default, and what /// previews get so reset/retry quietly do nothing. @@ -35,20 +35,20 @@ public struct LifecycleRunnerProxy: Sendable { base = nil } - init(_ runner: LifecycleRunner) { + init(_ runner: LegacyLifecycleRunner) { base = runner } /// Resume a failed launch from the step that failed. - /// See `LifecycleRunner.retry()`. + /// See `LegacyLifecycleRunner.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(_:)`. + /// See `LegacyLifecycleRunner.teardown(_:)`. @MainActor public func teardown( - _ sequence: LifecycleSteps, + _ sequence: LegacyLifecycleSteps, file: StaticString = #fileID, line: UInt = #line, ) async { @@ -56,7 +56,7 @@ public struct LifecycleRunnerProxy: Sendable { } /// Promote a headless background launch to the foreground. - /// See `LifecycleRunner.enterForeground()`. + /// See `LegacyLifecycleRunner.enterForeground()`. @MainActor public func enterForeground(file: StaticString = #fileID, line: UInt = #line) async { await connected(file: file, line: line)?.enterForeground() } @@ -64,10 +64,10 @@ public struct LifecycleRunnerProxy: Sendable { /// 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? { + private func connected(file: StaticString, line: UInt) -> LegacyLifecycleRunner? { if base == nil { assertionFailure( - "No LifecycleRunner in the environment — is this view inside a LifecycleContainer?", + "No LegacyLifecycleRunner in the environment — is this view inside a LegacyLifecycleContainer?", file: file, line: line, ) @@ -76,7 +76,7 @@ public struct LifecycleRunnerProxy: Sendable { } } -/// The root view that renders a `LifecycleRunner`'s `phase`. +/// The root view that renders a `LegacyLifecycleRunner`'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 @@ -101,8 +101,8 @@ public struct LifecycleRunnerProxy: Sendable { /// 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 +public struct LegacyLifecycleContainer: View { + private let runner: LegacyLifecycleRunner private let transition: AnyTransition private let animation: Animation? private let splash: () -> Splash @@ -114,7 +114,7 @@ public struct LifecycleContainer: Vi /// - animation: the animation driving `transition`. Pass `nil` to swap /// surfaces instantly (no animation). public init( - _ runner: LifecycleRunner, + _ runner: LegacyLifecycleRunner, transition: AnyTransition = .opacity, animation: Animation? = .default, @ViewBuilder splash: @escaping () -> Splash, @@ -180,10 +180,12 @@ public struct LifecycleContainer: Vi } } -extension LifecycleContainer where Splash == LifecycleSplash, Failure == LifecycleFailureView { +extension LegacyLifecycleContainer where Splash == LifecycleSplash, + Failure == LifecycleFailureView +{ /// Convenience initializer using the built-in splash and failure views. public init( - _ runner: LifecycleRunner, + _ runner: LegacyLifecycleRunner, @ViewBuilder content: @escaping () -> Content, ) { self.init( @@ -195,11 +197,11 @@ extension LifecycleContainer where Splash == LifecycleSplash, Failure == Lifecyc } } -extension LifecycleContainer where Failure == LifecycleFailureView { +extension LegacyLifecycleContainer where Failure == LifecycleFailureView { /// Convenience initializer with a custom splash but the built-in failure /// view. public init( - _ runner: LifecycleRunner, + _ runner: LegacyLifecycleRunner, @ViewBuilder splash: @escaping () -> Splash, @ViewBuilder content: @escaping () -> Content, ) { @@ -214,9 +216,9 @@ extension LifecycleContainer where Failure == LifecycleFailureView { #if DEBUG #Preview("Launching") { - LifecycleContainer( - LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("open") { _ in } + LegacyLifecycleContainer( + LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("open") { _ in } }), ) { Text("App content") diff --git a/Shared/LifecycleKit/Sources/LifecycleRunner.swift b/Shared/LifecycleKit/Sources/LegacyLifecycleRunner.swift similarity index 95% rename from Shared/LifecycleKit/Sources/LifecycleRunner.swift rename to Shared/LifecycleKit/Sources/LegacyLifecycleRunner.swift index ce447bbc..d995346c 100644 --- a/Shared/LifecycleKit/Sources/LifecycleRunner.swift +++ b/Shared/LifecycleKit/Sources/LegacyLifecycleRunner.swift @@ -1,7 +1,7 @@ import Observation import SwiftUI -/// Drives a `LifecycleSteps` to completion, publishing a single observable +/// Drives a `LegacyLifecycleSteps` 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. /// @@ -28,7 +28,7 @@ import SwiftUI /// that will never come. @MainActor @Observable -public final class LifecycleRunner { +public final class LegacyLifecycleRunner { /// The single value the host renders. public private(set) var phase: LifecyclePhase = .launching @@ -62,12 +62,12 @@ public final class LifecycleRunner { state.reason } - @ObservationIgnored private let steps: [LifecycleStep] + @ObservationIgnored private let steps: [LegacyLifecycleStep] /// 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] = [] + @ObservationIgnored private var teardownSteps: [LegacyLifecycleStep] = [] /// Steps that have already finished during the current launch attempt, so a /// re-drive doesn't repeat completed work. It's what lets `enterForeground()` @@ -87,7 +87,7 @@ public final class LifecycleRunner { public init( reason: LifecycleReason, initializePrerequisites: @MainActor () -> Void = {}, - sequence: LifecycleSteps, + sequence: LegacyLifecycleSteps, ) { state = .notStarted(reason) steps = sequence.steps @@ -154,7 +154,7 @@ public final class LifecycleRunner { /// 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 { + public func teardown(_ sequence: LegacyLifecycleSteps) async { teardownSteps = sequence.steps await driveTeardown(fromTeardownIndex: 0) } @@ -217,7 +217,10 @@ public final class LifecycleRunner { /// 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 { + private func runSteps( + _ steps: [LegacyLifecycleStep], + from startIndex: Int, + ) async -> DriveOutcome { var index = startIndex while index < steps.count { if Task.isCancelled { return .cancelled } @@ -258,7 +261,7 @@ public final class LifecycleRunner { /// 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 { + private func runStep(_ step: LegacyLifecycleStep) async -> DriveOutcome { let bridge = LifecycleStepUIBridge(reason: reason) phase = .running(step, bridge) @@ -287,7 +290,7 @@ public final class LifecycleRunner { } #if DEBUG - extension LifecycleRunner { + extension LegacyLifecycleRunner { /// Injects a failure for SPI-enabled tests (e.g. stale step IDs in `retry()`). @_spi(Testing) public func injectFailureForTesting(_ failure: LifecycleFailure) { phase = .failed(failure) @@ -313,7 +316,7 @@ private final class ActivePresentation { /// Activate `step`'s presentation per its trigger. With no presentation this /// is inert: `hold()`/`cancel()` then do nothing. - init(for step: LifecycleStep, bridge: LifecycleStepUIBridge) { + init(for step: LegacyLifecycleStep, bridge: LifecycleStepUIBridge) { guard let presentation = step.presentation else { minVisible = .zero return diff --git a/Shared/LifecycleKit/Sources/LegacyLifecycleStep.swift b/Shared/LifecycleKit/Sources/LegacyLifecycleStep.swift new file mode 100644 index 00000000..00904f60 --- /dev/null +++ b/Shared/LifecycleKit/Sources/LegacyLifecycleStep.swift @@ -0,0 +1,161 @@ +import SwiftUI + +/// One unit of launch work. +/// +/// 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. +/// +/// 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 LegacyLifecycleStep: 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 + + var allowedModes: LifecycleModeSet + var condition: @MainActor () async -> Bool + var perform: @MainActor (LifecycleStepUIBridge) async throws -> Void + var presentation: LifecycleStepPresentation? + + /// - 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 + } + + /// 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) + } + + 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 + } +} + +// MARK: - Declarative sugar + +extension LegacyLifecycleStep { + /// 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, + ) -> LegacyLifecycleStep { + LegacyLifecycleStep(id: id, modes: modes, condition: condition, perform: perform) + } + + /// 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, + ) -> LegacyLifecycleStep { + LegacyLifecycleStep(id: id, modes: modes, condition: condition, perform: perform) + .presenting(view) + } +} + +/// 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) + } + + 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 `[LegacyLifecycleStep]` can share one element type without leaking + /// each step's concrete view type into `LegacyLifecycleStep`/`LegacyLifecycleSteps`; 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/LegacyLifecycleSteps.swift b/Shared/LifecycleKit/Sources/LegacyLifecycleSteps.swift new file mode 100644 index 00000000..e5ae7d37 --- /dev/null +++ b/Shared/LifecycleKit/Sources/LegacyLifecycleSteps.swift @@ -0,0 +1,72 @@ +/// Result builder that collects `LegacyLifecycleStep`s declared in a +/// `LegacyLifecycleSteps`, with `if`/`if-else`/`for` support so steps can be +/// included conditionally. +@resultBuilder +public enum LegacyLifecycleStepsBuilder { + public static func buildExpression(_ step: LegacyLifecycleStep) -> [LegacyLifecycleStep] { + [step] + } + + public static func buildExpression(_ steps: [LegacyLifecycleStep]) -> [LegacyLifecycleStep] { + steps + } + + public static func buildBlock(_ components: [LegacyLifecycleStep]...) -> [LegacyLifecycleStep] { + components.flatMap(\.self) + } + + public static func buildOptional(_ component: [LegacyLifecycleStep]?) -> [LegacyLifecycleStep] { + component ?? [] + } + + public static func buildEither(first component: [LegacyLifecycleStep]) + -> [LegacyLifecycleStep] + { + component + } + + public static func buildEither(second component: [LegacyLifecycleStep]) + -> [LegacyLifecycleStep] + { + component + } + + public static func buildArray(_ components: [[LegacyLifecycleStep]]) -> [LegacyLifecycleStep] { + components.flatMap(\.self) + } + + public static func buildLimitedAvailability(_ component: [LegacyLifecycleStep]) + -> [LegacyLifecycleStep] + { + component + } +} + +/// An ordered list of lifecycle steps. The engine walks these top to bottom; +/// the declaration order is the run order. +public struct LegacyLifecycleSteps { + public let steps: [LegacyLifecycleStep] + + public init(@LegacyLifecycleStepsBuilder _ steps: () -> [LegacyLifecycleStep]) { + let steps = steps() + Self.assertUniqueIDs(steps) + self.steps = steps + } + + public init(steps: [LegacyLifecycleStep]) { + 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: [LegacyLifecycleStep]) { + var seen = Set() + let duplicates = steps.map(\.id).filter { !seen.insert($0).inserted } + precondition( + duplicates.isEmpty, + "LegacyLifecycleSteps contains duplicate step IDs: \(duplicates)", + ) + } +} 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 index e52a0366..7e0a360a 100644 --- a/Shared/LifecycleKit/Sources/LifecyclePhase.swift +++ b/Shared/LifecycleKit/Sources/LifecyclePhase.swift @@ -1,11 +1,11 @@ /// The single observable value the host renders. The runner publishes a -/// `LifecyclePhase`; `LifecycleContainer` maps each case to UI. +/// `LifecyclePhase`; `LegacyLifecycleContainer` 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) + case running(LegacyLifecycleStep, 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. @@ -62,7 +62,7 @@ extension LifecyclePhase { } extension LifecyclePhase { - /// A value identity for the *surface* `LifecycleContainer` renders, so it can + /// A value identity for the *surface* `LegacyLifecycleContainer` renders, so it can /// animate transitions between surfaces with `.animation(_:value:)` (the /// phase itself isn't `Equatable`). /// diff --git a/Shared/LifecycleKit/Sources/LifecycleReason.swift b/Shared/LifecycleKit/Sources/LifecycleReason.swift index c2b8e508..15aaf11a 100644 --- a/Shared/LifecycleKit/Sources/LifecycleReason.swift +++ b/Shared/LifecycleKit/Sources/LifecycleReason.swift @@ -13,7 +13,7 @@ public enum LifecycleReason: Sendable, Hashable { /// can't distinguish a user-tap launch from a headless wake at /// `didFinishLaunching` time — it reads `.background` for both. Behaves like /// a background launch (no window, background-safe steps only) until a scene - /// activates and `LifecycleRunner.enterForeground()` promotes it to + /// activates and `LegacyLifecycleRunner.enterForeground()` promotes it to /// `.userForeground`. If no scene ever connects (a genuine headless wake), /// it honestly stays `.undetermined` for the process's life — the /// background-safe steps that ran serviced the wake, and no fabricated cause diff --git a/Shared/LifecycleKit/Sources/LifecycleStep.swift b/Shared/LifecycleKit/Sources/LifecycleStep.swift index f3f68042..a6535289 100644 --- a/Shared/LifecycleKit/Sources/LifecycleStep.swift +++ b/Shared/LifecycleKit/Sources/LifecycleStep.swift @@ -1,161 +1,84 @@ -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 - var allowedModes: LifecycleModeSet - var condition: @MainActor () async -> Bool - var perform: @MainActor (LifecycleStepUIBridge) async throws -> Void - var presentation: LifecycleStepPresentation? + /// Stable identity used for retry resumption, run-once memoization, and + /// 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. + var id: AnyHashable { 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 -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 retry resumption, run-once memoization, and + /// tests. Same conventions as `LifecycleStep.id`. + var id: AnyHashable { 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/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/Tests/LaunchPlanTests.swift b/Shared/LifecycleKit/Tests/LaunchPlanTests.swift new file mode 100644 index 00000000..d24952af --- /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/LegacyLifecycleContainerTests.swift similarity index 72% rename from Shared/LifecycleKit/Tests/LifecycleContainerTests.swift rename to Shared/LifecycleKit/Tests/LegacyLifecycleContainerTests.swift index 6d0c55b3..16ba23a1 100644 --- a/Shared/LifecycleKit/Tests/LifecycleContainerTests.swift +++ b/Shared/LifecycleKit/Tests/LegacyLifecycleContainerTests.swift @@ -35,15 +35,18 @@ private struct EnvironmentRunnerProbe: View { } @MainActor -struct LifecycleContainerTests { +struct LegacyLifecycleContainerTests { @Test func readyShowsContent() async throws { var content = false var splash = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) + let runner = LegacyLifecycleRunner( + reason: .userForeground, + sequence: LegacyLifecycleSteps {}, + ) await runner.run() #expect(runner.phase.isReady) - let container = LifecycleContainer(runner, splash: { ProbeView { splash = true } }) { + let container = LegacyLifecycleContainer(runner, splash: { ProbeView { splash = true } }) { ProbeView { content = true } } try show(UIHostingController(rootView: container)) { _ in @@ -56,12 +59,12 @@ struct LifecycleContainerTests { @Test func launchingShowsSplash() throws { var splash = false var content = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in } }) // Not run yet, so the runner is still in .launching. - let container = LifecycleContainer(runner, splash: { ProbeView { splash = true } }) { + let container = LegacyLifecycleContainer(runner, splash: { ProbeView { splash = true } }) { ProbeView { content = true } } try show(UIHostingController(rootView: container)) { _ in @@ -74,11 +77,14 @@ struct LifecycleContainerTests { @Test func backgroundLaunchShowsNothing() async throws { var content = false var splash = false - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps {}) + let runner = LegacyLifecycleRunner( + reason: .background(.location), + sequence: LegacyLifecycleSteps {}, + ) await runner.run() #expect(runner.phase.isReady) - let container = LifecycleContainer(runner, splash: { ProbeView { splash = true } }) { + let container = LegacyLifecycleContainer(runner, splash: { ProbeView { splash = true } }) { ProbeView { content = true } } try show(UIHostingController(rootView: container)) { _ in @@ -90,7 +96,10 @@ struct LifecycleContainerTests { @Test func backgroundReadyThenEnterForegroundShowsContent() async throws { var content = false - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps {}) + let runner = LegacyLifecycleRunner( + reason: .background(.location), + sequence: LegacyLifecycleSteps {}, + ) await runner.run() #expect(runner.phase.isReady) #expect(runner.reason.buildsNoViewTree) @@ -99,7 +108,7 @@ struct LifecycleContainerTests { #expect(!runner.reason.buildsNoViewTree) #expect(runner.phase.isReady) - let container = LifecycleContainer(runner) { + let container = LegacyLifecycleContainer(runner) { ProbeView { content = true } } try show(UIHostingController(rootView: container)) { _ in @@ -111,11 +120,11 @@ struct LifecycleContainerTests { @Test func undeterminedLaunchShowsNothingUntilPromoted() async throws { var content = false var splash = false - let runner = LifecycleRunner(reason: .undetermined, sequence: LifecycleSteps {}) + let runner = LegacyLifecycleRunner(reason: .undetermined, sequence: LegacyLifecycleSteps {}) await runner.run() #expect(runner.phase.isReady) - let container = LifecycleContainer(runner, splash: { ProbeView { splash = true } }) { + let container = LegacyLifecycleContainer(runner, splash: { ProbeView { splash = true } }) { ProbeView { content = true } } try show(UIHostingController(rootView: container)) { _ in @@ -127,7 +136,7 @@ struct LifecycleContainerTests { @Test func undeterminedReadyThenEnterForegroundShowsContent() async throws { var content = false - let runner = LifecycleRunner(reason: .undetermined, sequence: LifecycleSteps {}) + let runner = LegacyLifecycleRunner(reason: .undetermined, sequence: LegacyLifecycleSteps {}) await runner.run() #expect(runner.phase.isReady) #expect(runner.reason.buildsNoViewTree) @@ -136,7 +145,7 @@ struct LifecycleContainerTests { #expect(!runner.reason.buildsNoViewTree) #expect(runner.phase.isReady) - let container = LifecycleContainer(runner) { + let container = LegacyLifecycleContainer(runner) { ProbeView { content = true } } try show(UIHostingController(rootView: container)) { _ in @@ -148,13 +157,13 @@ struct LifecycleContainerTests { @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 runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.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 } } + let container = LegacyLifecycleContainer(runner) { ProbeView { content = true } } try show(UIHostingController(rootView: container)) { _ in try waitFor { presentation } } @@ -169,13 +178,13 @@ struct LifecycleContainerTests { var failure = false var content = false var splash = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("boom") { _ in throw ProbeError() } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("boom") { _ in throw ProbeError() } }) await runner.run() #expect(runner.phase.failed(at: "boom")) - let container = LifecycleContainer( + let container = LegacyLifecycleContainer( runner, splash: { ProbeView { splash = true } }, failure: { _, _ in ProbeView { failure = true } }, @@ -192,11 +201,14 @@ struct LifecycleContainerTests { @Test func publishesTheRunnerIntoTheEnvironment() async throws { var sawRunner = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) + let runner = LegacyLifecycleRunner( + reason: .userForeground, + sequence: LegacyLifecycleSteps {}, + ) await runner.run() #expect(runner.phase.isReady) - let container = LifecycleContainer(runner) { + let container = LegacyLifecycleContainer(runner) { EnvironmentRunnerProbe { sawRunner = $0 } } try show(UIHostingController(rootView: container)) { _ in @@ -210,11 +222,14 @@ struct LifecycleContainerTests { // container renders — at .ready it still builds `content`, not the splash. var content = false var splash = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) + let runner = LegacyLifecycleRunner( + reason: .userForeground, + sequence: LegacyLifecycleSteps {}, + ) await runner.run() #expect(runner.phase.isReady) - let container = LifecycleContainer( + let container = LegacyLifecycleContainer( runner, transition: .scale.combined(with: .opacity), animation: .easeInOut, @@ -238,12 +253,15 @@ struct LifecycleContainerTests { @Test func connectedProxyForwardsTeardownToTheRunner() async { var tornDown = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) + let runner = LegacyLifecycleRunner( + reason: .userForeground, + sequence: LegacyLifecycleSteps {}, + ) await runner.run() #expect(runner.phase.isReady) - await LifecycleRunnerProxy(runner).teardown(LifecycleSteps { - LifecycleStep.work("teardown") { _ in tornDown = true } + await LifecycleRunnerProxy(runner).teardown(LegacyLifecycleSteps { + LegacyLifecycleStep.work("teardown") { _ in tornDown = true } }) #expect(tornDown) #expect(runner.phase.isReady) diff --git a/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift b/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerFuzzTests.swift similarity index 93% rename from Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift rename to Shared/LifecycleKit/Tests/LegacyLifecycleRunnerFuzzTests.swift index d948332d..2597e6af 100644 --- a/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift +++ b/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerFuzzTests.swift @@ -22,7 +22,7 @@ private struct SplitMix64: RandomNumberGenerator { } } -/// A randomized step description, kept separate from the built `LifecycleStep` +/// A randomized step description, kept separate from the built `LegacyLifecycleStep` /// so the test can predict the expected outcome from the same data the runner /// drives. private struct FuzzStep { @@ -49,7 +49,7 @@ private func makeFuzzSteps(_ rng: inout SplitMix64) -> [FuzzStep] { /// many randomized ones and check the runner's behavior against an independent /// model. Complements the targeted runner tests. @MainActor -struct LifecycleRunnerFuzzTests { +struct LegacyLifecycleRunnerFuzzTests { /// 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`. @@ -61,9 +61,9 @@ struct LifecycleRunnerFuzzTests { let fuzz = makeFuzzSteps(&rng) var executed: [String] = [] - let runner = LifecycleRunner(reason: reason, sequence: LifecycleSteps { + let runner = LegacyLifecycleRunner(reason: reason, sequence: LegacyLifecycleSteps { for step in fuzz { - LifecycleStep + LegacyLifecycleStep .work(step.id, modes: step.modes, condition: { step.conditionPasses }) { _ in executed.append(step.id) if step.throwsError { throw FuzzError() } @@ -104,9 +104,9 @@ struct LifecycleRunnerFuzzTests { var attempts = [Int](repeating: 0, count: count) var succeeded: [String] = [] - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { for index in 0 ..< count { - LifecycleStep.work(ids[index]) { _ in + LegacyLifecycleStep.work(ids[index]) { _ in attempts[index] += 1 if attempts[index] <= failuresBeforeSuccess[index] { throw FuzzError() } succeeded.append(ids[index]) diff --git a/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift b/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerResetTests.swift similarity index 57% rename from Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift rename to Shared/LifecycleKit/Tests/LegacyLifecycleRunnerResetTests.swift index 44b77712..9ec9b841 100644 --- a/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift +++ b/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerResetTests.swift @@ -5,18 +5,18 @@ import Testing private struct ResetError: Error {} @MainActor -struct LifecycleRunnerResetTests { +struct LegacyLifecycleRunnerResetTests { @Test func resetRunsTeardownThenRelaunches() async { var events: [String] = [] - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("launch") { _ in events.append("launch") } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("launch") { _ in events.append("launch") } }) await runner.run() #expect(events == ["launch"]) #expect(runner.phase.isReady) - await runner.teardown(LifecycleSteps { - LifecycleStep.work("teardown") { _ in events.append("teardown") } + await runner.teardown(LegacyLifecycleSteps { + LegacyLifecycleStep.work("teardown") { _ in events.append("teardown") } }) #expect(events == ["launch", "teardown", "launch"]) #expect(runner.phase.isReady) @@ -24,24 +24,30 @@ struct LifecycleRunnerResetTests { @Test func resetRunsTeardownStepsInOrder() async { var events: [String] = [] - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) + let runner = LegacyLifecycleRunner( + reason: .userForeground, + sequence: LegacyLifecycleSteps {}, + ) 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(LegacyLifecycleSteps { + LegacyLifecycleStep.work("stop-gps") { _ in events.append("stop-gps") } + LegacyLifecycleStep.work("clear-store") { _ in events.append("clear-store") } + LegacyLifecycleStep.work("clear-widget") { _ in events.append("clear-widget") } }) #expect(events == ["stop-gps", "clear-store", "clear-widget"]) } @Test func resetStepCanPresentTeardownUI() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps {}) + let runner = LegacyLifecycleRunner( + reason: .userForeground, + sequence: LegacyLifecycleSteps {}, + ) await runner.run() let task = Task { @MainActor in - await runner.teardown(LifecycleSteps { - LifecycleStep.interactive("signing-out") { _ in Text("Signing out") } + await runner.teardown(LegacyLifecycleSteps { + LegacyLifecycleStep.interactive("signing-out") { _ in Text("Signing out") } }) } try await waitUntil { runner.phase.isRunning("signing-out") } @@ -54,14 +60,14 @@ struct LifecycleRunnerResetTests { @Test func failedTeardownParksInFailedAndSkipsRelaunch() async { var relaunched = false - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("launch") { _ in relaunched = true } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("launch") { _ in relaunched = true } }) await runner.run() relaunched = false - await runner.teardown(LifecycleSteps { - LifecycleStep.work("teardown") { _ in throw ResetError() } + await runner.teardown(LegacyLifecycleSteps { + LegacyLifecycleStep.work("teardown") { _ in throw ResetError() } }) #expect(runner.phase.failed(at: "teardown")) #expect(!relaunched) @@ -70,18 +76,18 @@ struct LifecycleRunnerResetTests { @Test func retryAfterFailedTeardownReRunsTeardownThenRelaunches() async throws { var events: [String] = [] var shouldFailErase = true - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("launch") { _ in events.append("launch") } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("launch") { _ in events.append("launch") } }) await runner.run() events.removeAll() - await runner.teardown(LifecycleSteps { - LifecycleStep.work("erase") { _ in + await runner.teardown(LegacyLifecycleSteps { + LegacyLifecycleStep.work("erase") { _ in events.append("erase") if shouldFailErase { throw ResetError() } } - LifecycleStep.work("clear-prefs") { _ in events.append("clear-prefs") } + LegacyLifecycleStep.work("clear-prefs") { _ in events.append("clear-prefs") } }) #expect(runner.phase.failed(at: "erase")) #expect(events == ["erase"]) @@ -98,15 +104,15 @@ struct LifecycleRunnerResetTests { @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") } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.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 + await runner.teardown(LegacyLifecycleSteps { + LegacyLifecycleStep.work("erase") { _ in events.append("erase") } + LegacyLifecycleStep.work("clear-prefs") { _ in events.append("clear-prefs") if shouldFailPrefs { throw ResetError() } } diff --git a/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift b/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerTests.swift similarity index 64% rename from Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift rename to Shared/LifecycleKit/Tests/LegacyLifecycleRunnerTests.swift index a2e29046..9c2e8272 100644 --- a/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift +++ b/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerTests.swift @@ -5,13 +5,13 @@ import Testing private struct StepError: Error {} @MainActor -struct LifecycleRunnerDriveTests { +struct LegacyLifecycleRunnerDriveTests { @Test func runsStepsInDeclarationOrder() 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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in executed.append("a") } + LegacyLifecycleStep.work("b") { _ in executed.append("b") } + LegacyLifecycleStep.work("c") { _ in executed.append("c") } }) await runner.run() #expect(executed == ["a", "b", "c"]) @@ -20,10 +20,10 @@ struct LifecycleRunnerDriveTests { @Test func skipsStepsWhoseConditionIsFalse() 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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in executed.append("a") } + LegacyLifecycleStep.work("skip", condition: { false }) { _ in executed.append("skip") } + LegacyLifecycleStep.work("c") { _ in executed.append("c") } }) await runner.run() #expect(executed == ["a", "c"]) @@ -31,25 +31,33 @@ struct LifecycleRunnerDriveTests { @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") } - }) + let runner = LegacyLifecycleRunner( + reason: .background(.location), + sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("always") { _ in executed.append("always") } + LegacyLifecycleStep.work("fg", modes: .foreground) { _ in executed.append("fg") } + LegacyLifecycleStep.work("bg", modes: .background) { _ in executed.append("bg") } + }, + ) await runner.run() #expect(executed == ["always", "bg"]) } @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") } - }) + let runner = LegacyLifecycleRunner( + reason: .background(.location), + sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in executed.append("a") } + LegacyLifecycleStep + .interactive("onboarding", perform: { _ in + executed.append("onboarding") + }) { _ in + Text("onboarding") + } + LegacyLifecycleStep.work("c") { _ in executed.append("c") } + }, + ) await runner.run() #expect(executed == ["a", "c"]) #expect(runner.phase.isReady) @@ -57,8 +65,8 @@ struct LifecycleRunnerDriveTests { @Test func runIsIdempotent() async { var count = 0 - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in count += 1 } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in count += 1 } }) await runner.run() await runner.run() @@ -67,14 +75,17 @@ struct LifecycleRunnerDriveTests { } @MainActor -struct LifecycleRunnerForegroundPromotionTests { +struct LegacyLifecycleRunnerForegroundPromotionTests { @Test func enterForegroundReDrivesAndRunsForegroundOnlySteps() 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 = LegacyLifecycleRunner( + reason: .background(.location), + sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("store") { _ in executed.append("store") } + LegacyLifecycleStep + .work("onboarding", modes: .foreground) { _ in executed.append("onboarding") } + }, + ) await runner.run() // The headless background drive ran only the unrestricted step. #expect(executed == ["store"]) @@ -91,9 +102,9 @@ struct LifecycleRunnerForegroundPromotionTests { @Test func undeterminedLaunchRunsBackgroundStepsThenPromotesToForeground() async { var executed: [String] = [] - let runner = LifecycleRunner(reason: .undetermined, sequence: LifecycleSteps { - LifecycleStep.work("store") { _ in executed.append("store") } - LifecycleStep + let runner = LegacyLifecycleRunner(reason: .undetermined, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("store") { _ in executed.append("store") } + LegacyLifecycleStep .work("onboarding", modes: .foreground) { _ in executed.append("onboarding") } }) await runner.run() @@ -119,13 +130,13 @@ struct LifecycleRunnerForegroundPromotionTests { // that failed on promotion. 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 = LegacyLifecycleRunner(reason: .undetermined, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("store") { _ in executed.append("store") } + LegacyLifecycleStep.work("onboarding", modes: .foreground) { _ in executed.append("onboarding") if onboardingShouldFail { throw StepError() } } - LifecycleStep.work("widget") { _ in executed.append("widget") } + LegacyLifecycleStep.work("widget") { _ in executed.append("widget") } }) // Headless drive: the background-safe "store" and "widget" complete; the @@ -151,8 +162,8 @@ struct LifecycleRunnerForegroundPromotionTests { @Test func enterForegroundIsNoOpForAForegroundLaunch() async { var count = 0 - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in count += 1 } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in count += 1 } }) await runner.run() await runner.enterForeground() @@ -162,9 +173,12 @@ struct LifecycleRunnerForegroundPromotionTests { @Test func backgroundWorkStepCallingWaitForResolutionDoesNotReachReadyUntilPromoted( ) async throws { - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps { - LifecycleStep.work("wait") { bridge in try await bridge.waitForResolution() } - }) + let runner = LegacyLifecycleRunner( + reason: .background(.location), + sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("wait") { bridge in try await bridge.waitForResolution() } + }, + ) let runTask = Task { @MainActor in await runner.run() } try await waitUntil { runner.phase.isRunning("wait") } @@ -187,15 +201,18 @@ struct LifecycleRunnerForegroundPromotionTests { var starts = 0 var inFlight = 0 var maxInFlight = 0 - let runner = LifecycleRunner(reason: .background(.location), sequence: LifecycleSteps { - LifecycleStep.work("slow") { bridge in - starts += 1 - inFlight += 1 - defer { inFlight -= 1 } - maxInFlight = max(maxInFlight, inFlight) - try await bridge.waitForResolution() - } - }) + let runner = LegacyLifecycleRunner( + reason: .background(.location), + sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("slow") { bridge in + starts += 1 + inFlight += 1 + defer { inFlight -= 1 } + maxInFlight = max(maxInFlight, inFlight) + try await bridge.waitForResolution() + } + }, + ) let runTask = Task { @MainActor in await runner.run() } try await waitUntil { runner.phase.isRunning("slow") } @@ -226,29 +243,32 @@ struct LifecycleRunnerForegroundPromotionTests { let (gatedCondition, releaseGatedCondition) = 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 {} + let runner = LegacyLifecycleRunner( + reason: .background(.location), + sequence: LegacyLifecycleSteps { + LegacyLifecycleStep( + 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 + attempts += 1 + if attempts == 1 { + // Park until released, then fail for real — after the + // promotion has already superseded this drive. + for await _ in blockedStep {} + throw StepError() } - return true - }, - ) { _ 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 {} - throw StepError() } - } - }) + }, + ) let runTask = Task { @MainActor in await runner.run() } try await waitUntil { runner.phase.isRunning("store") } @@ -272,24 +292,24 @@ struct LifecycleRunnerForegroundPromotionTests { } @MainActor -struct LifecycleRunnerCancellationTests { +struct LegacyLifecycleRunnerCancellationTests { @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 { + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { // After teardown clears the gate, the relaunch skips it and runs to // completion. - LifecycleStep.interactive("gate", condition: { !teardownRan }) { _ in + LegacyLifecycleStep.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 runner.teardown(LegacyLifecycleSteps { + LegacyLifecycleStep.work("teardown") { _ in teardownRan = true } }) await runTask.value @@ -301,13 +321,13 @@ struct LifecycleRunnerCancellationTests { } @MainActor -struct LifecycleRunnerFailureTests { +struct LegacyLifecycleRunnerFailureTests { @Test func thrownErrorParksInFailedAndStopsSubsequentSteps() 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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in executed.append("a") } + LegacyLifecycleStep.work("b") { _ in throw StepError() } + LegacyLifecycleStep.work("c") { _ in executed.append("c") } }) await runner.run() #expect(executed == ["a"]) @@ -318,13 +338,13 @@ struct LifecycleRunnerFailureTests { @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 + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in executed.append("a") } + LegacyLifecycleStep.work("b") { _ in if shouldFail { throw StepError() } executed.append("b") } - LifecycleStep.work("c") { _ in executed.append("c") } + LegacyLifecycleStep.work("c") { _ in executed.append("c") } }) await runner.run() #expect(runner.phase.failed(at: "b")) @@ -337,8 +357,8 @@ struct LifecycleRunnerFailureTests { } @Test func retryIsNoOpWhenNotFailed() async { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in } }) await runner.run() runner.retry() @@ -347,8 +367,8 @@ struct LifecycleRunnerFailureTests { @Test func retryIsNoOpWhenFailedStepIDDoesNotMatchAnyStep() async { var executed = 0 - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("a") { _ in executed += 1 } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in executed += 1 } }) await runner.run() #expect(runner.phase.isReady) @@ -361,8 +381,8 @@ struct LifecycleRunnerFailureTests { } @Test func bridgeFailurePropagatesToFailedPhase() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.interactive("gate") { _ in Text("x") } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.interactive("gate") { _ in Text("x") } }) let task = Task { @MainActor in await runner.run() } try await waitUntil { runner.phase.isRunning("gate") } @@ -373,13 +393,13 @@ struct LifecycleRunnerFailureTests { } @MainActor -struct LifecycleRunnerInteractiveTests { +struct LegacyLifecycleRunnerInteractiveTests { @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 runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in executed.append("a") } + LegacyLifecycleStep.interactive("gate") { _ in Text("gate") } + LegacyLifecycleStep.work("c") { _ in executed.append("c") } }) let task = Task { @MainActor in await runner.run() } try await waitUntil { runner.phase.isRunning("gate") } @@ -393,8 +413,8 @@ struct LifecycleRunnerInteractiveTests { } @Test func alwaysPresentationActivatesImmediately() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.interactive("ui") { _ in Text("ui") } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.interactive("ui") { _ in Text("ui") } }) let task = Task { @MainActor in await runner.run() } try await waitUntil { runner.phase.isRunning("ui") } @@ -404,8 +424,8 @@ struct LifecycleRunnerInteractiveTests { } @Test func whenFalsePresentationStaysSilent() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("s") { bridge in try await bridge.waitForResolution() } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("s") { bridge in try await bridge.waitForResolution() } .presenting(when: { false }) { _ in Text("x") } }) let task = Task { @MainActor in await runner.run() } @@ -416,8 +436,8 @@ struct LifecycleRunnerInteractiveTests { } @Test func deferredPresentationActivatesAfterDelay() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("slow") { bridge in try await bridge.waitForResolution() } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("slow") { bridge in try await bridge.waitForResolution() } .presenting(after: .milliseconds(20)) { _ in Text("x") } }) let task = Task { @MainActor in await runner.run() } @@ -428,8 +448,8 @@ struct LifecycleRunnerInteractiveTests { } @Test func minVisibleHoldsADeferredPresentationAfterTheStepFinishes() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.work("slow") { bridge in try await bridge.waitForResolution() } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("slow") { bridge in try await bridge.waitForResolution() } .presenting(after: .milliseconds(10), minVisible: .milliseconds(300)) { _ in Text("x") } @@ -452,8 +472,8 @@ struct LifecycleRunnerInteractiveTests { // 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 } + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.work("fast") { _ in } .presenting(minVisible: .milliseconds(300)) { _ in Text("x") } }) await runner.run() @@ -462,8 +482,8 @@ struct LifecycleRunnerInteractiveTests { } @Test func progressIsReadableWhileStepRuns() async throws { - let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { - LifecycleStep.interactive("p", perform: { bridge in + let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { + LegacyLifecycleStep.interactive("p", perform: { bridge in bridge.progress = 0.5 try await bridge.waitForResolution() }) { _ in Text("p") } diff --git a/Shared/LifecycleKit/Tests/LifecycleStepTests.swift b/Shared/LifecycleKit/Tests/LegacyLifecycleStepTests.swift similarity index 65% rename from Shared/LifecycleKit/Tests/LifecycleStepTests.swift rename to Shared/LifecycleKit/Tests/LegacyLifecycleStepTests.swift index e68c5e21..918533da 100644 --- a/Shared/LifecycleKit/Tests/LifecycleStepTests.swift +++ b/Shared/LifecycleKit/Tests/LegacyLifecycleStepTests.swift @@ -5,22 +5,22 @@ import Testing @MainActor struct LifecycleStepsBuilderTests { @Test func builderPreservesDeclarationOrder() { - let sequence = LifecycleSteps { - LifecycleStep.work("a") { _ in } - LifecycleStep.work("b") { _ in } - LifecycleStep.work("c") { _ in } + let sequence = LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in } + LegacyLifecycleStep.work("b") { _ in } + LegacyLifecycleStep.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 } + LegacyLifecycleSteps { + LegacyLifecycleStep.work("a") { _ in } if includeMiddle { - LifecycleStep.work("b") { _ in } + LegacyLifecycleStep.work("b") { _ in } } - LifecycleStep.work("c") { _ in } + LegacyLifecycleStep.work("c") { _ in } }.steps.map(\.id) } #expect(ids(includeMiddle: true) == ["a", "b", "c"] as [AnyHashable]) @@ -28,9 +28,9 @@ struct LifecycleStepsBuilderTests { } @Test func builderSupportsLoops() { - let sequence = LifecycleSteps { + let sequence = LegacyLifecycleSteps { for name in ["x", "y", "z"] { - LifecycleStep.work(name) { _ in } + LegacyLifecycleStep.work(name) { _ in } } } #expect(sequence.steps.map(\.id) == ["x", "y", "z"] as [AnyHashable]) @@ -40,31 +40,31 @@ struct LifecycleStepsBuilderTests { @MainActor struct LifecycleStepConfigurationTests { @Test func defaultStepAppliesToEveryReason() { - let step = LifecycleStep.work("a") { _ in } + let step = LegacyLifecycleStep.work("a") { _ in } #expect(step.appliesTo(.userForeground)) #expect(step.appliesTo(.background(.location))) } @Test func foregroundOnlyStepSkipsBackground() { - let step = LifecycleStep.work("a", modes: .foreground) { _ in } + let step = LegacyLifecycleStep.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 } + let step = LegacyLifecycleStep.work("a", modes: .background) { _ in } #expect(!step.appliesTo(.userForeground)) #expect(step.appliesTo(.background(.remoteNotification))) } @Test func defaultConditionIsTrue() async { - let step = LifecycleStep.work("a") { _ in } + let step = LegacyLifecycleStep.work("a") { _ in } #expect(await step.condition()) } @Test func workConditionGatesTheStep() async { let flag = MutableFlag() - let step = LifecycleStep.work("a", condition: { flag.isOn }) { _ in } + let step = LegacyLifecycleStep.work("a", condition: { flag.isOn }) { _ in } #expect(await step.condition() == false) flag.isOn = true #expect(await step.condition() == true) @@ -72,29 +72,29 @@ struct LifecycleStepConfigurationTests { @Test func initConditionGatesTheStep() async { let flag = MutableFlag() - let step = LifecycleStep(id: "a", condition: { flag.isOn }) { _ in } + let step = LegacyLifecycleStep(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) + #expect(LegacyLifecycleStep.work("a") { _ in }.presentation == nil) } @Test func presentingAttachesPresentation() { - let step = LifecycleStep.work("a") { _ in }.presenting { _ in Text("hi") } + let step = LegacyLifecycleStep.work("a") { _ in }.presenting { _ in Text("hi") } #expect(step.presentation != nil) } @Test func interactiveStepPresentsItsView() { - let step = LifecycleStep.interactive("onboarding") { _ in Text("onboarding") } + let step = LegacyLifecycleStep.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 +/// they've been captured. `LegacyLifecycleStep.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. diff --git a/Shared/LifecycleKit/Tests/LifecycleGateHandleTests.swift b/Shared/LifecycleKit/Tests/LifecycleGateHandleTests.swift new file mode 100644 index 00000000..c13a908f --- /dev/null +++ b/Shared/LifecycleKit/Tests/LifecycleGateHandleTests.swift @@ -0,0 +1,83 @@ +@testable import LifecycleKit +import Testing + +private struct Boom: Error {} + +@MainActor +struct LifecycleGateHandleTests { + private func makeHandle() -> LifecycleGateHandle { + LifecycleGateHandle(id: "gate", reason: .userForeground) + } + + @Test func completeResumesWaiter() async throws { + let handle = makeHandle() + let waiter = Task { @MainActor in + try await handle.waitForResolution() + return true + } + await Task.yield() + handle.complete() + #expect(try await waiter.value) + } + + @Test func failThrowsFromWaiter() async { + let handle = makeHandle() + let waiter = Task { @MainActor in + do { + try await handle.waitForResolution() + return false + } catch is Boom { + return true + } catch { + return false + } + } + await Task.yield() + handle.fail(Boom()) + #expect(await waiter.value) + } + + @Test func resolvingBeforeWaitingStillDelivers() async throws { + let handle = makeHandle() + handle.complete() + try await handle.waitForResolution() + } + + @Test func failingBeforeWaitingStillThrows() async { + let handle = makeHandle() + handle.fail(Boom()) + await #expect(throws: Boom.self) { + try await handle.waitForResolution() + } + } + + @Test func secondResolutionIsIgnored() async throws { + let handle = makeHandle() + handle.complete() + handle.fail(Boom()) + try await handle.waitForResolution() + } + + @Test func cancellingTheWaiterThrowsCancellationError() async { + let handle = makeHandle() + let waiter = Task { @MainActor in + do { + try await handle.waitForResolution() + return "resolved" + } catch is CancellationError { + return "cancelled" + } catch { + return "other" + } + } + await Task.yield() + 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 index bc529a61..cd39fb90 100644 --- a/Shared/LifecycleKit/Tests/LifecyclePhaseTests.swift +++ b/Shared/LifecycleKit/Tests/LifecyclePhaseTests.swift @@ -9,7 +9,7 @@ struct LifecyclePhaseTests { // 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 }, + LegacyLifecycleStep.work("a") { _ in }, LifecycleStepUIBridge(reason: .userForeground), ) #expect(LifecyclePhase.launching.surfaceIdentity == .splash) @@ -19,11 +19,11 @@ struct LifecyclePhaseTests { @Test func runningStepsAllCollapseToTheSameSurface() { // Advancing from one running step to the next is still the splash surface. let first = LifecyclePhase.running( - LifecycleStep.work("first") { _ in }, + LegacyLifecycleStep.work("first") { _ in }, LifecycleStepUIBridge(reason: .userForeground), ) let second = LifecyclePhase.running( - LifecycleStep.work("second") { _ in }, + LegacyLifecycleStep.work("second") { _ in }, LifecycleStepUIBridge(reason: .userForeground), ) #expect(first.surfaceIdentity == second.surfaceIdentity) diff --git a/Shared/LifecycleKit/Tests/LifecycleStepFixtures.swift b/Shared/LifecycleKit/Tests/LifecycleStepFixtures.swift new file mode 100644 index 00000000..2deb767a --- /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: AnyHashable + var modes: LifecycleModeSet = .all + let body: @MainActor (Input, LifecycleStepContext) async throws -> Output + + init( + _ id: AnyHashable, + 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: AnyHashable + var modes: LifecycleModeSet = .foreground + var needed: @MainActor (Value) async -> Bool + + init( + _ id: AnyHashable, + 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/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index 61cdef7f..704c5507 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -5,7 +5,7 @@ import WhereCore import WhereIntents import WhereUI -/// Owns the app's single `WhereModel` and the `LifecycleRunner` that drives +/// Owns the app's single `WhereModel` and the `LegacyLifecycleRunner` that drives /// launch, wiring both up at process launch rather than from a SwiftUI view's /// `.task`. /// @@ -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: LegacyLifecycleRunner! func application( _: UIApplication, diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index 6e72f254..f87feed5 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -45,11 +45,11 @@ public enum LaunchStepID: String { case resetPreferences = "reset-preferences" } -/// Assembles the Where app's cold-launch sequence and the `LifecycleRunner` +/// Assembles the Where app's cold-launch sequence and the `LegacyLifecycleRunner` /// that drives it. /// /// The sequence is only the *prerequisites*; the destination — the real tab UI -/// — is `LifecycleContainer`'s `content` (see `RootView`), shown once the +/// — is `LegacyLifecycleContainer`'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 @@ -143,10 +143,10 @@ public enum WhereLaunch { model: WhereModel, reason: LifecycleReason, onServicesReady: @escaping @MainActor (WhereServices) async -> Void = { _ in }, - ) -> LifecycleRunner { + ) -> LegacyLifecycleRunner { let bootstrap = WhereBootstrap() logger { .runnerCreated(reason: String(describing: reason)) } - return LifecycleRunner( + return LegacyLifecycleRunner( reason: reason, initializePrerequisites: { bootstrap.prepareLocation() @@ -173,8 +173,8 @@ public enum WhereLaunch { for model: WhereModel, bootstrap: WhereBootstrap = WhereBootstrap(), onServicesReady: @escaping @MainActor (WhereServices) async -> Void = { _ in }, - ) -> LifecycleSteps { - LifecycleSteps { + ) -> LegacyLifecycleSteps { + LegacyLifecycleSteps { // 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 @@ -183,7 +183,7 @@ public enum WhereLaunch { // 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 + LegacyLifecycleStep.work(LaunchStepID.openStore) { _ in guard model.session == nil else { return } if !model.hasServices { try await model.attach(services: bootstrap.makeServices()) @@ -198,22 +198,22 @@ public enum WhereLaunch { } } - // First run only. `LifecycleStep.interactive` defaults to + // First run only. `LegacyLifecycleStep.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( + LegacyLifecycleStep.interactive( LaunchStepID.onboarding, condition: { !model.hasOnboarded }, ) { OnboardingView(bridge: $0) } - LifecycleStep.work(LaunchStepID.syncAuth) { _ in + LegacyLifecycleStep.work(LaunchStepID.syncAuth) { _ in await model.session?.syncAuthorization() model.session?.observeAuthorizationChanges() await model.session?.seedRegionStyles() model.session?.observeRegionStyleChanges() } - LifecycleStep.work(LaunchStepID.reconcileTracking) { _ in + LegacyLifecycleStep.work(LaunchStepID.reconcileTracking) { _ in await model.session?.reconcileTracking() } // Foreground-only: a headless launch shouldn't trigger a fresh @@ -221,30 +221,30 @@ public enum WhereLaunch { // 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 + LegacyLifecycleStep.work(LaunchStepID.captureToday, modes: .foreground) { _ in await model.session?.captureTodayIfNeeded() } - LifecycleStep.work(LaunchStepID.reminders) { _ in + LegacyLifecycleStep.work(LaunchStepID.reminders) { _ in await model.session?.applyReminderConfiguration() } - LifecycleStep.work(LaunchStepID.summary) { _ in + LegacyLifecycleStep.work(LaunchStepID.summary) { _ in await model.session?.applySummaryConfiguration() } - LifecycleStep.work(LaunchStepID.issueAlerts) { _ in + LegacyLifecycleStep.work(LaunchStepID.issueAlerts) { _ in await model.session?.applyIssueAlertConfiguration() } - LifecycleStep.work(LaunchStepID.widgetSnapshot) { _ in + LegacyLifecycleStep.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 + /// & reset". `LegacyLifecycleRunner.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 { + public static func resetSequence(for model: WhereModel) -> LegacyLifecycleSteps { + LegacyLifecycleSteps { // 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 @@ -252,11 +252,11 @@ public enum WhereLaunch { // 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 + LegacyLifecycleStep.work(LaunchStepID.eraseData) { _ in try await model.eraseAllData() model.endSession() } - LifecycleStep + LegacyLifecycleStep .work(LaunchStepID.resetPreferences) { _ in model.resetPreferences() } } } diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 1a79b399..d5d0de6e 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -156,7 +156,7 @@ public final class WhereSession { /// /// This is the imperative equivalent of `WhereLaunch.sequence`'s coordinator /// work steps, kept for previews/tests that drive the coordinator directly - /// without a `LifecycleRunner`. Report/data-issue loading is *not* here — the + /// without a `LegacyLifecycleRunner`. Report/data-issue loading is *not* here — the /// scene's `YearReportModel` owns that and starts it when the UI appears. public func start() async { await syncAuthorization() diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 378df127..f8742f25 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -10,8 +10,8 @@ import WhereCore /// 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). /// -/// `LifecycleContainer` renders the splash / onboarding / migration UI while -/// the `LifecycleRunner` runs, then the `TabView` (the real "logged-in" UI — +/// `LegacyLifecycleContainer` renders the splash / onboarding / migration UI while +/// the `LegacyLifecycleRunner` 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. @@ -36,10 +36,10 @@ public struct RootView: View { @State private var alerter: PeriscopeAlerter? @State private var toastCenter = DeveloperToastCenter() #endif - private let launcher: LifecycleRunner + private let launcher: LegacyLifecycleRunner /// Inject the app-owned model + runner built at launch. The app uses this. - public init(model: WhereModel, launcher: LifecycleRunner) { + public init(model: WhereModel, launcher: LegacyLifecycleRunner) { _model = State(initialValue: model) self.launcher = launcher } @@ -55,7 +55,7 @@ public struct RootView: View { public var body: some View { ZStack { - LifecycleContainer( + LegacyLifecycleContainer( launcher, transition: revealTransition, animation: revealAnimation, @@ -107,7 +107,7 @@ public struct RootView: View { // SwiftData inspector row simply hides. .environment(model.session) // Settings' "Erase all data & reset" runs the teardown through the - // `LifecycleRunner` that `LifecycleContainer` publishes into the + // `LegacyLifecycleRunner` that `LegacyLifecycleContainer` publishes into the // environment, which wipes data + preferences and re-drives the launch // sequence back to onboarding. // diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index e1938c09..3d9c6e29 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -554,8 +554,8 @@ struct SettingsView: 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 / + /// onboarding, run through the `LegacyLifecycleRunner` published into the + /// environment by `LegacyLifecycleContainer`. The runner proxy asserts in debug / /// no-ops in release when no container is above (e.g. previews). private var resetSection: some View { Section { diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index 04d1c7da..c2c363ae 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -22,7 +22,7 @@ private func waitUntil( } /// Covers the `WhereLaunch.resetSequence` teardown the Settings "Erase all data -/// & reset" action runs through `LifecycleRunner.reset`: it wipes the store, stops +/// & reset" action runs through `LegacyLifecycleRunner.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. @MainActor @@ -265,11 +265,11 @@ struct WhereResetTests { let model = try makeModel(preferences: makePreferences()) model.completeOnboarding() - let failing = LifecycleSteps { - LifecycleStep.work(LaunchStepID.eraseData) { _ in + let failing = LegacyLifecycleSteps { + LegacyLifecycleStep.work(LaunchStepID.eraseData) { _ in throw CocoaError(.fileWriteUnknown) } - LifecycleStep.work(LaunchStepID.resetPreferences) { _ in + LegacyLifecycleStep.work(LaunchStepID.resetPreferences) { _ in model.resetPreferences() } } From d4b464e073f2c42e84cf9b30c8de65b43ccb6c35 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 18:41:01 -0700 Subject: [PATCH 02/17] Add the typed LifecycleRunner that walks a LaunchPlan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step: runner-rewrite. - LifecycleRunner is now generic over the plan's output. Its nested Phase is the explicit, value-carrying UI signal: launching | running(context) | awaitingGate(handle) | failed(failure) | ready(Launch) — .ready carries the trunk's output so the app surface cannot exist without the value the launch produced. - The walk threads the typed trunk value through steps and gates, memoizes each completed node's output (run-once across promotion + retry; a retry resumes the failed node with the same input), and spawns detached children into a task group owned by the drive: .ready publishes as soon as the trunk finishes, children drain behind it, and their failures land on the off-phase detachedFailures diagnostics surface (recorded, logged by consumers, never fatal). - Preserved invariants from the legacy runner: single cancel-and-drain drive, mode gating per LifecycleReason, idempotent enterForeground() promotion (skipped gates re-evaluate, completed work doesn't repeat), a superseded drive can't clobber the phase (PR #99 regression, extended to gate handles), teardown(plan:input:) runs a typed teardown then relaunches fresh — with teardown's detached children drained before the relaunch, @_spi(Testing) failure injection. - Tests: ported drive/promotion/failure/reset suites and the 200+120 seeded fuzz suites to the typed model; the fuzz model now also predicts detached spawn/failure sets and proves memoized retry never re-runs completed nodes. New gate, detached, and phase-surface coverage. The legacy runner and its suites stay green untouched. --- .../Sources/LifecycleRunner.swift | 514 ++++++++++++++++ .../Tests/LifecycleRunnerFuzzTests.swift | 173 ++++++ .../Tests/LifecycleRunnerResetTests.swift | 209 +++++++ .../Tests/LifecycleRunnerTests.swift | 547 ++++++++++++++++++ 4 files changed, 1443 insertions(+) create mode 100644 Shared/LifecycleKit/Sources/LifecycleRunner.swift create mode 100644 Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift create mode 100644 Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift create mode 100644 Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift diff --git a/Shared/LifecycleKit/Sources/LifecycleRunner.swift b/Shared/LifecycleKit/Sources/LifecycleRunner.swift new file mode 100644 index 00000000..b41a51ab --- /dev/null +++ b/Shared/LifecycleKit/Sources/LifecycleRunner.swift @@ -0,0 +1,514 @@ +import Observation + +/// 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. +/// +/// Lifecycle: +/// - 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 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`; `retry()` +/// resumes from the node that failed, feeding it the same memoized input. +/// - `enterForeground()` promotes a runner that started headless (its +/// `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. +/// +/// 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 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 { + /// 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. The host shows an error UI offering retry. + 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: 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. + private enum State { + /// Built; `run()` not yet called. Carries the reason it will launch with. + case notStarted(LifecycleReason) + /// `run()` (or a re-drive) has started. Carries the current reason and + /// the most recent drive task — which may already have completed, so + /// late `run()`/`teardown()`/`enterForeground()` callers can await it. + case running(reason: LifecycleReason, task: Task) + + /// The launch reason, which every case carries. Lives on the state so + /// callers read `state.reason` instead of re-switching at each use site. + var reason: LifecycleReason { + switch self { + case let .notStarted(reason): reason + case let .running(reason, _): reason + } + } + } + + 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 + /// nothing and start building real UI. + public var reason: LifecycleReason { + state.reason + } + + @ObservationIgnored private let launchNodes: [LaunchPlanNode] + + /// The most recent teardown plan (erased) and its root input, retained so + /// a `retry()` after a thrown teardown node resumes the teardown (and the + /// relaunch that follows) rather than re-driving the launch plan over + /// un-torn-down state. Nil until the first `teardown(_:input:)`. + private struct RetainedTeardown { + var nodes: [LaunchPlanNode] + var input: any Sendable + } + + @ObservationIgnored private var teardown: RetainedTeardown? + + /// The output of every node that ran to completion during the current + /// launch attempt, keyed by node ID — both the run-once set (a re-drive + /// doesn't repeat completed work) and the value store that lets a retry + /// resume mid-trunk with the input its node originally got. + /// + /// 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()`, 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 memo: [AnyHashable: any Sendable] = [:] + + /// Where a trunk failure parked, so `retry()` can resume the same walk + /// with the same input. One value: a resume point always carries the + /// site it belongs to and the input its node needs. + private struct FailedResume { + enum Site { + case launch + case teardown + } + + var site: Site + var index: Int + var input: any Sendable + } + + @ObservationIgnored private var failedResume: FailedResume? + + public init( + reason: LifecycleReason, + initializePrerequisites: @MainActor () -> Void = {}, + plan: LaunchPlan, + ) { + state = .notStarted(reason) + launchNodes = plan.nodes + initializePrerequisites() + } + + /// The in-flight (or most recently finished) drive task, if `run()` has + /// been called. + private var currentTask: Task? { + if case let .running(_, task) = state { task } else { nil } + } + + /// 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. + memo.removeAll() + detachedFailures.removeAll() + await drive(reason: reason, from: 0, input: ()) + case let .running(_, task): + await task.value + } + } + + /// 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 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, input: ()) + } + + /// Resume from the node that failed, feeding it the same input it + /// originally got. No-op unless the runner is currently in `.failed`. + /// + /// If the failure was in a teardown node (from `teardown(_:input:)`), the + /// teardown is resumed from that node 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 plan is resumed from + /// the failed node. + public func retry() { + guard case .failed = phase, let resume = failedResume else { return } + switch resume.site { + case .launch: + let reason = reason + Task { await drive(reason: reason, from: resume.index, input: resume.input) } + case .teardown: + Task { await driveTeardown(from: resume.index, input: resume.input) } + } + } + + /// 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 plan and input are retained so a `retry()` after a thrown teardown + /// node resumes the teardown (then the relaunch). If a teardown node + /// throws, the runner parks in `.failed` and does not relaunch. 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 { + teardown = RetainedTeardown(nodes: plan.nodes, input: input) + await driveTeardown(from: 0, 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(_:input:)` and a + /// `retry()` resuming a failed teardown node, so the two stay in lockstep. + private func driveTeardown(from startIndex: Int, input: any Sendable) async { + guard let teardown else { return } + let previous = currentTask + previous?.cancel() + let reason = reason + phase = .launching + failedResume = nil + let task = Task { [weak self] in + guard let self else { return } + await previous?.value + let tornDown = await withDiscardingTaskGroup(returning: Bool.self) { group in + let outcome = await self.walk( + teardown.nodes, + from: startIndex, + input: input, + site: .teardown, + 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 memo so every launch + // node re-runs over the torn-down state rather than being skipped + // as "already done" from before the teardown. + memo.removeAll() + detachedFailures.removeAll() + await runLaunchPlan(from: 0, input: ()) + } + state = .running(reason: reason, task: task) + await task.value + } + + /// Cancel any in-flight drive, then drive the launch plan from + /// `startIndex` 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, + from startIndex: Int, + input: any Sendable, + ) async { + let previous = currentTask + previous?.cancel() + phase = .launching + failedResume = nil + let task = Task { [weak self] in + guard let self else { return } + await previous?.value + await runLaunchPlan(from: startIndex, input: input) + } + state = .running(reason: newReason, task: task) + await task.value + } + + /// 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(from startIndex: Int, input: any Sendable) async { + await withDiscardingTaskGroup { group in + let outcome = await self.walk( + self.launchNodes, + from: startIndex, + input: input, + site: .launch, + group: &group, + ) + guard case let .completed(value) = outcome, !Task.isCancelled else { return } + self.phase = .ready(value as! Launch) + } + } + + /// The outcome of walking a trunk from some node onward. + 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` + /// and `failedResume` points at the node. + case failed + /// The drive was superseded (cancelled); `phase` is left for the + /// drive that cancelled it to set. + case cancelled + } + + /// Walk `nodes` from `startIndex`, 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], + from startIndex: Int, + input: any Sendable, + site: FailedResume.Site, + group: inout DiscardingTaskGroup, + ) async -> WalkOutcome { + var value = input + var index = startIndex + while index < nodes.count { + if Task.isCancelled { return .cancelled } + + 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` — the new drive re-runs the + // step and surfaces its own outcome. + guard !Task.isCancelled else { return .cancelled } + failedResume = FailedResume(site: site, index: index, input: value) + phase = .failed(LifecycleFailure(stepID: node.id, error: error)) + return .failed + } + + 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 } + failedResume = FailedResume(site: site, index: index, input: value) + phase = .failed(LifecycleFailure(stepID: node.id, error: error)) + return .failed + } + + 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(value) + } + + /// 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() + } + } +} + +#if DEBUG + extension LifecycleRunner { + /// Injects a failure for SPI-enabled tests. Carries no resume point, + /// so a subsequent `retry()` is a no-op. + @_spi(Testing) public func injectFailureForTesting(_ failure: LifecycleFailure) { + phase = .failed(failure) + } + } +#endif + +// MARK: - Phase inspection + +@MainActor +extension LifecycleRunner.Phase { + public var isLaunching: Bool { + if case .launching = self { true } else { false } + } + + public var isReady: Bool { + if case .ready = self { true } else { false } + } + + /// 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 } + } + + /// 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/Tests/LifecycleRunnerFuzzTests.swift b/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift new file mode 100644 index 00000000..b1312751 --- /dev/null +++ b/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift @@ -0,0 +1,173 @@ +@testable import LifecycleKit +import Testing + +private struct FuzzError: Error {} + +/// A small, *seedable* PRNG so each fuzz case is fully reproducible: a failure +/// reports the `seed`, and re-running that seed replays the exact sequence. +/// (`SystemRandomNumberGenerator` can't be seeded.) +private struct SplitMix64: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } +} + +/// 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 throwsError: Bool +} + +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 + FuzzElement( + id: "s\(index)", + kind: Bool.random(using: &rng) ? .keep : .detachedChild, + modes: modeChoices[Int.random(in: 0 ..< modeChoices.count, using: &rng)], + throwsError: Double.random(in: 0 ... 1, using: &rng) < 0.15, + ) + } +} + +/// 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-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 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 = makeFuzzElements(&rng) + + 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 expectedTrunk: [String] = [] + var expectedDetached: Set = [] + var expectedDetachedFailures: Set = [] + var expectedFailureID: String? + 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(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) + #expect(runner.phase.readyValue == "value") + } + } + + /// Trunk steps that throw their first N attempts then succeed: driving + /// `retry()` resumes from the failed node each time (with its memoized + /// input — earlier nodes never re-run) and, after exactly the number of + /// injected failures, drains to `.ready` with every node 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] = [] + var plan = LaunchPlan(FixtureStep("root") { _, _ in "value" }) + for index in 0 ..< count { + plan = plan.thenKeeping(FixtureStep(ids[index]) { _, _ in + attempts[index] += 1 + if attempts[index] <= failuresBeforeSuccess[index] { throw FuzzError() } + succeeded.append(ids[index]) + }) + } + let runner = LifecycleRunner(reason: .userForeground, plan: plan) + + 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) + } + } +} diff --git a/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift b/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift new file mode 100644 index 00000000..81d8513c --- /dev/null +++ b/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift @@ -0,0 +1,209 @@ +@testable import LifecycleKit +import Testing + +private struct ResetError: Error {} + +@MainActor +struct LifecycleRunnerResetTests { + @Test func teardownRunsThenRelaunchesWithItsTypedInput() async { + var events: [String] = [] + 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) + + // 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 teardownNodesRunInOrder() async { + var events: [String] = [] + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("noop") { _, _ in }), + ) + await runner.run() + + 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 teardownGateParksAndResumes() async throws { + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("noop") { _, _ in }), + ) + await runner.run() + + let task = Task { @MainActor in + await runner.teardown( + LaunchPlan(FixtureStep("prepare") { _, _ in "account" }) + .gate(FixtureGate("signing-out")), + input: (), + ) + } + try await waitUntil { runner.phase.isAwaitingGate("signing-out") } + #expect(runner.phase.gateHandle?.value as? String == "account") + + 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, + plan: LaunchPlan(FixtureStep("launch") { _, _ in relaunched = true }), + ) + await runner.run() + relaunched = false + + await runner.teardown( + LaunchPlan(FixtureStep("teardown") { _, _ in throw ResetError() }), + input: (), + ) + #expect(runner.phase.failed(at: "teardown")) + #expect(!relaunched) + } + + @Test func retryAfterFailedTeardownReRunsTeardownThenRelaunches() async throws { + var events: [String] = [] + var shouldFailErase = true + 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") + if shouldFailErase { throw ResetError() } + }) + .thenKeeping(FixtureStep("clear-prefs") { _, _ in + events.append("clear-prefs") + }), + input: (), + ) + #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 plan over un-torn-down state. + shouldFailErase = false + events.removeAll() + runner.retry() + try await waitUntil { runner.phase.isReady } + #expect(events == ["erase", "clear-prefs", "launch"]) + } + + @Test func retryAfterFailedTeardownTailResumesWithoutReErasing() async throws { + var events: [String] = [] + var shouldFailPrefs = true + 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") }) + .thenKeeping(FixtureStep("clear-prefs") { _, _ in + events.append("clear-prefs") + if shouldFailPrefs { throw ResetError() } + }), + input: (), + ) + #expect(runner.phase.failed(at: "clear-prefs")) + #expect(events == ["erase", "clear-prefs"]) + + // The earlier teardown node ("erase") already succeeded, so retry + // resumes from the failed node 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 new file mode 100644 index 00000000..3e020b5f --- /dev/null +++ b/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift @@ -0,0 +1,547 @@ +@_spi(Testing) @testable import LifecycleKit +import Testing + +private struct StepError: Error {} + +@MainActor +struct LifecycleRunnerDriveTests { + @Test func runsTrunkNodesInDeclarationOrderAndThreadsTheValue() async { + var executed: [String] = [] + 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 == ["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 filtersPassThroughNodesByLaunchReason() async { + var executed: [String] = [] + 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 == ["root", "always", "bg"]) + #expect(runner.phase.isReady) + } + + @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(runner.phase.isReady) + #expect(!evaluated) + } + + @Test func runIsIdempotent() async { + var count = 0 + 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 enterForegroundReDrivesAndRunsForegroundOnlyNodes() async { + var executed: [String] = [] + 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 node. + #expect(executed == ["store"]) + #expect(runner.reason.buildsNoViewTree) + + await runner.enterForeground() + // 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 undeterminedLaunchRunsBackgroundNodesThenPromotesToForeground() async { + var executed: [String] = [] + 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 + // node is skipped and the host builds no view tree. + #expect(executed == ["store"]) + #expect(runner.reason.buildsNoViewTree) + + await runner.enterForeground() + #expect(executed == ["store", "onboarding"]) + #expect(runner.reason == .userForeground) + #expect(runner.phase.isReady) + } + + @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 retryAfterPromotionSkipsANodeAlreadyCompletedInTheHeadlessDrive() async throws { + // Run-once spans a promotion + a `retry()` within the same attempt: a + // later node that already completed during the headless drive must not + // re-run when `retry()` resumes from an *earlier* foreground-only node + // that failed on promotion. + var executed: [String] = [] + var onboardingShouldFail = true + 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() } + }) + .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 (memoized), the + // now-applicable "onboarding" runs and fails. + 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"]) + } + + @Test func enterForegroundIsNoOpForAForegroundLaunch() async { + var count = 0 + 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 enterForegroundCancelsAndDrainsAnInFlightBackgroundDrive() async throws { + var starts = 0 + var inFlight = 0 + var maxInFlight = 0 + 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) + // 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 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 } + + handles.last?.complete() + await runTask.value + await promote.value + + #expect(maxInFlight == 1) + #expect(!runner.reason.buildsNoViewTree) + #expect(runner.phase.isReady) + } + + @Test func supersededDriveThatThrowsDoesNotClobberThePromotedDrivesPhase() async throws { + // A superseded drive's in-flight step isn't required to be + // cancellation-responsive: it can keep working after the promotion + // cancels its drive and then throw a *real* error (the fresh-install + // 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 (blockedFirst, _) = AsyncStream.makeStream(of: Void.self) + let (blockedSecond, releaseSecond) = AsyncStream.makeStream(of: Void.self) + var attempts = 0 + let runner = LifecycleRunner( + reason: .background(.location), + plan: LaunchPlan(FixtureStep("store") { _, _ in + attempts += 1 + if attempts == 1 { + // 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() } + // 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) + + releaseSecond.finish() + await promote.value + await runTask.value + #expect(attempts == 2) + #expect(runner.phase.isReady) + } +} + +@MainActor +struct LifecycleRunnerFailureTests { + @Test func thrownErrorParksInFailedAndStopsSubsequentNodes() async { + var executed: [String] = [] + 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 retryResumesFromTheFailedNodeWithItsMemoizedInput() async throws { + var rootRuns = 0 + var received: [Int] = [] + var shouldFail = true + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("root") { _, _ in + rootRuns += 1 + return 42 + }) + .then(FixtureStep("flaky") { value, _ in + received.append(value) + if shouldFail { throw StepError() } + return value + 1 + }), + ) + await runner.run() + #expect(runner.phase.failed(at: "flaky")) + + shouldFail = false + runner.retry() + try await waitUntil { runner.phase.isReady } + // The root ran once; the retried node got the same memoized input. + #expect(rootRuns == 1) + #expect(received == [42, 42]) + #expect(runner.phase.readyValue == 43) + } + + @Test func retryIsNoOpWhenNotFailed() async { + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("a") { _, _ in }), + ) + await runner.run() + runner.retry() + #expect(runner.phase.isReady) + } + + @Test func retryIsNoOpForAnInjectedFailureWithNoResumePoint() async { + var executed = 0 + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("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) + } +} + +@MainActor +struct LifecycleRunnerPhaseTests { + private typealias Phase = LifecycleRunner.Phase + + @Test func launchingAndRunningCollapseToTheSplashSurface() { + let running = Phase.running(LifecycleStepContext(stepID: "a", reason: .userForeground)) + #expect(Phase.launching.surfaceIdentity == .splash) + #expect(running.surfaceIdentity == .splash) + } + + @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 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) + } +} From f06a76c060e23c684732c1c2f285d0a27a6220d2 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 18:47:17 -0700 Subject: [PATCH 03/17] Add the LifecycleKitUI target: container, gate-view registry, proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step: ui-split. - New Shared/LifecycleKitUI module (SwiftUI + LifecycleKit only) owning everything rendered: the generic LifecycleContainer switches on the typed runner's phase — splash(context) / registered gate view / failure / content(Launch) — preserving the legacy container's hard-won behavior (buildsNoViewTree renders nothing even at .ready, surface-identity-keyed transitions, launch surfaces layered above content for the reveal). - Gate views register by gate *type* via GateView(for:content:), which statically recovers the gate's Value: the view receives (LifecycleGateHandle, Value) instead of trusting the environment. One registration per gate type (construction precondition); a parked gate with no registration debug-asserts and falls back to the splash. - LifecycleProxy under @Environment(\.lifecycle) forwards retry / enterForeground / typed teardown(_:input:) through the new package-level LifecycleDriving seam in core (an environment value must be non-generic; the plan+input are type-checked at the call site and erased only to cross it). Disconnected default asserts in debug, no-ops in release, like the legacy proxy. - Wired in Package.swift (product + target), Project.swift (LifecycleKitUITests hosted bundle + scheme + Stuff-iOS-Tests build and test entries). Module README.md + AGENTS.md added. - Core still exports the legacy SwiftUI surface (old container, bridge, splash/failure views) until the Where migration lands; the SwiftUI import leaves core when the legacy API is deleted in the cleanup step. - Tests: container rendering per phase (including the gate registry receiving the typed trunk value and the context-fed splash), proxy connected/disconnected + typed teardown forwarding. --- Package.swift | 8 + Project.swift | 10 + .../Sources/LifecycleDriving.swift | 21 ++ .../Sources/LifecycleRunner.swift | 8 +- Shared/LifecycleKitUI/AGENTS.md | 39 +++ Shared/LifecycleKitUI/README.md | 80 ++++++ Shared/LifecycleKitUI/Sources/GateView.swift | 82 ++++++ .../Sources/LifecycleContainer.swift | 206 ++++++++++++++ .../Sources/LifecycleProxy.swift | 84 ++++++ .../Tests/LifecycleContainerTests.swift | 261 ++++++++++++++++++ .../Tests/LifecycleKitUITestSupport.swift | 77 ++++++ .../Tests/LifecycleProxyTests.swift | 51 ++++ 12 files changed, 926 insertions(+), 1 deletion(-) create mode 100644 Shared/LifecycleKit/Sources/LifecycleDriving.swift create mode 100644 Shared/LifecycleKitUI/AGENTS.md create mode 100644 Shared/LifecycleKitUI/README.md create mode 100644 Shared/LifecycleKitUI/Sources/GateView.swift create mode 100644 Shared/LifecycleKitUI/Sources/LifecycleContainer.swift create mode 100644 Shared/LifecycleKitUI/Sources/LifecycleProxy.swift create mode 100644 Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift create mode 100644 Shared/LifecycleKitUI/Tests/LifecycleKitUITestSupport.swift create mode 100644 Shared/LifecycleKitUI/Tests/LifecycleProxyTests.swift diff --git a/Package.swift b/Package.swift index adadb7a1..5038b98c 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( .process("Resources"), ], ), + .target( + name: "LifecycleKitUI", + dependencies: [ + .target(name: "LifecycleKit"), + ], + path: "Shared/LifecycleKitUI/Sources", + ), .target( name: "JournalKit", path: "Shared/JournalKit/Sources", diff --git a/Project.swift b/Project.swift index 97f39ba1..115e50d2 100644 --- a/Project.swift +++ b/Project.swift @@ -267,6 +267,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", @@ -419,6 +426,7 @@ let project = Project( "StuffTestHost", "StuffCoreTests", "LifecycleKitTests", + "LifecycleKitUITests", "JournalKitTests", "PeriscopeCoreTests", "PeriscopeUITests", @@ -437,6 +445,7 @@ let project = Project( testAction: .targets([ "StuffCoreTests", "LifecycleKitTests", + "LifecycleKitUITests", "JournalKitTests", "PeriscopeCoreTests", "PeriscopeUITests", @@ -454,6 +463,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/Sources/LifecycleDriving.swift b/Shared/LifecycleKit/Sources/LifecycleDriving.swift new file mode 100644 index 00000000..20d10936 --- /dev/null +++ b/Shared/LifecycleKit/Sources/LifecycleDriving.swift @@ -0,0 +1,21 @@ +/// 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.retry()`. + func retry() + + /// 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/LifecycleRunner.swift b/Shared/LifecycleKit/Sources/LifecycleRunner.swift index b41a51ab..52ea423c 100644 --- a/Shared/LifecycleKit/Sources/LifecycleRunner.swift +++ b/Shared/LifecycleKit/Sources/LifecycleRunner.swift @@ -209,7 +209,13 @@ public final class LifecycleRunner { _ plan: LaunchPlan, input: Input, ) async { - teardown = RetainedTeardown(nodes: plan.nodes, input: input) + await teardownErased(nodes: plan.nodes, input: input) + } + + /// `teardown(_:input:)` after erasure — the `LifecycleDriving` seam the + /// UI proxy forwards through (see `LifecycleDriving`). + package func teardownErased(nodes: [LaunchPlanNode], input: any Sendable) async { + teardown = RetainedTeardown(nodes: nodes, input: input) await driveTeardown(from: 0, input: input) } diff --git a/Shared/LifecycleKitUI/AGENTS.md b/Shared/LifecycleKitUI/AGENTS.md new file mode 100644 index 00000000..a921907b --- /dev/null +++ b/Shared/LifecycleKitUI/AGENTS.md @@ -0,0 +1,39 @@ +# 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 +`retry()`/`teardown(_:input:)`. 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. +- **No view tree when `reason.buildsNoViewTree`** — even at `.ready`. +- **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 (debug assert, splash + fallback in release). + +## 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..fc020a5e --- /dev/null +++ b/Shared/LifecycleKitUI/README.md @@ -0,0 +1,80 @@ +# 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 + LaunchSplashView(caption: context?.message) + }, + failure: { failure, retry in + LifecycleFailureView(failure: failure, retry: 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, 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 asserts in debug and falls back to the splash in + release. +- **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. + +## Reaching the runner from nested views + +`LifecycleContainer` publishes a `LifecycleProxy` under +`@Environment(\.lifecycle)`. The proxy is non-generic (environment values +must be), forwards `retry()` / `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 +`ContentUnavailableView` with a 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..18d7d270 --- /dev/null +++ b/Shared/LifecycleKitUI/Sources/GateView.swift @@ -0,0 +1,82 @@ +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 +} + +/// 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..3ad6046b --- /dev/null +++ b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift @@ -0,0 +1,206 @@ +import LifecycleKit +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 `retry()`/`teardown(_:input:)`. +/// +/// 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 splash: (LifecycleStepContext?) -> Splash + private let failureView: (LifecycleFailure, @escaping () -> Void) -> Failure + private let gates: [GateRegistration] + private let content: (Launch) -> Content + + /// - Parameters: + /// - transition: how each surface enters/leaves. Defaults to a crossfade. + /// - animation: the animation driving `transition`. Pass `nil` to swap + /// surfaces instantly (no animation). + /// - splash: the waiting surface; receives the running step's context + /// (nil between steps) so it can show a caption/progress. + /// - failure: the error surface, given the failure and a retry action. + /// - 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, + @ViewBuilder splash: @escaping (LifecycleStepContext?) -> Splash, + @ViewBuilder failure: @escaping (LifecycleFailure, @escaping () -> Void) -> Failure, + @GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] }, + @ViewBuilder content: @escaping (Launch) -> Content, + ) { + self.runner = runner + self.transition = transition + self.animation = animation + 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: 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 + } + + @ViewBuilder private var phaseContent: some View { + switch runner.phase { + case .launching: + splash(nil).transition(transition).zIndex(Self.launchSurfaceZIndex) + case let .running(context): + splash(context).transition(transition).zIndex(Self.launchSurfaceZIndex) + case let .awaitingGate(handle): + gateView(for: handle).transition(transition).zIndex(Self.launchSurfaceZIndex) + case let .failed(failure): + failureView(failure) { runner.retry() } + .transition(transition) + .zIndex(Self.launchSurfaceZIndex) + case let .ready(value): + content(value).transition(transition).zIndex(Self.contentZIndex) + } + } + + /// 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); debug-assert and fall back to the splash so a + /// release build degrades to a wait rather than crashing. + @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 { + let _ = assertionFailure( + "No gate view registered for gate '\(handle.id)' — add a GateView(for:) entry.", + ) + splash(nil) + } + } +} + +extension LifecycleContainer where Splash == LifecycleSplash, Failure == LifecycleFailureView { + /// Convenience initializer using the built-in splash and failure views. + public init( + _ runner: LifecycleRunner, + @GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] }, + @ViewBuilder content: @escaping (Launch) -> Content, + ) { + self.init( + runner, + splash: { _ in LifecycleSplash() }, + failure: { LifecycleFailureView(failure: $0, retry: $1) }, + 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, + @ViewBuilder splash: @escaping (LifecycleStepContext?) -> Splash, + @GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] }, + @ViewBuilder content: @escaping (Launch) -> Content, + ) { + self.init( + runner, + splash: splash, + failure: { LifecycleFailureView(failure: $0, retry: $1) }, + gates: gates, + content: content, + ) + } +} + +#if DEBUG + private struct PreviewStep: LifecycleStep { + let id: AnyHashable = "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/LifecycleKitUI/Sources/LifecycleProxy.swift b/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift new file mode 100644 index 00000000..ea8b302c --- /dev/null +++ b/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift @@ -0,0 +1,84 @@ +import LifecycleKit +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(_: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 + } + + /// Resume a failed launch from the node that failed. + /// See `LifecycleRunner.retry()`. + @MainActor public func retry(file: StaticString = #fileID, line: UInt = #line) { + connected(file: file, line: line)?.retry() + } + + /// 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/LifecycleKitUI/Tests/LifecycleContainerTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift new file mode 100644 index 00000000..c2d23329 --- /dev/null +++ b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift @@ -0,0 +1,261 @@ +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 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 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..e66664a4 --- /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: AnyHashable + var modes: LifecycleModeSet = .all + let body: @MainActor (Input, LifecycleStepContext) async throws -> Output + + init( + _ id: AnyHashable, + 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: AnyHashable + var modes: LifecycleModeSet = .foreground + var needed: @MainActor (Value) async -> Bool + + init( + _ id: AnyHashable, + 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..49590065 --- /dev/null +++ b/Shared/LifecycleKitUI/Tests/LifecycleProxyTests.swift @@ -0,0 +1,51 @@ +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 connectedProxyForwardsRetryToTheRunner() async throws { + struct Boom: Error {} + var shouldFail = true + let runner = LifecycleRunner( + reason: .userForeground, + plan: LaunchPlan(FixtureStep("open") { _, _ in + if shouldFail { throw Boom() } + return "session" + }), + ) + await runner.run() + #expect(runner.phase.failed(at: "open")) + + shouldFail = false + LifecycleProxy(runner).retry() + try await waitUntil { runner.phase.isReady } + } +} From 8bb45b1f682d10fbd80ea3e64ccb92e2f75acc9e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 19:01:15 -0700 Subject: [PATCH 04/17] Migrate the Where launch and reset onto the typed LaunchPlan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step: where-migration. - WhereLaunch.plan(for:) replaces the closure sequence: OpenStoreStep (Void → WhereServices, the one store open), StartSessionStep (WhereServices → WhereSession; fires onServicesReady on every session (re)start, as before), the OnboardingGate (pass-through, foreground- only, re-evaluated on promotion), SyncAuthStep + ReconcileTrackingStep kept sequential on the trunk (reconcile must see the synced authorization), then the detached fan (captureToday [foreground], reminders, summary, issueAlerts, widgetSnapshot) — independent session-configuration steps that no longer serialize or block .ready. New LaunchStepID.startSession names the scope promotion. - Every post-session step takes a NON-OPTIONAL WhereSession as its typed input: all the model.session? optional chaining in launch steps is gone, and WhereModel.startSession(services:) now takes the services and returns the session (no stashed-optional handshake). WhereModel.session remains only as the UI mirror for surfaces the container doesn't feed; eraseAllData() (optional-chained) is deleted in favor of EraseDataStep taking the session directly. - resetPlan(for:) is the typed teardown, rooted at the session being torn down; SettingsView hands its (non-optional) environment session in through the new \.lifecycle proxy. The runner now releases the retained teardown input after a successful teardown so the dead session can't be retained for the process's life (regression-tested by the existing weak-reference reset test). - RootView renders the new LifecycleContainer: content receives the session from .ready (the 'if let session' guard is gone), and OnboardingView is registered for OnboardingGate — it now takes (gate handle, session) instead of a bridge + environment session. Scene activation keeps its single role: enterForeground() promotion. - AppDelegate/WhereApp carry LifecycleRunner; LaunchPlan gains public nodeIDs so the parity tests can inspect order from outside the package. - Tests updated: parity on plan(for:).nodeIDs (with start-session), onboarding as a parked gate resolved via its handle, teardown calls rooted at the session, and a typed failing-erase plan for the teardown-failure path. --- Package.swift | 1 + Shared/LifecycleKit/Sources/LaunchPlan.swift | 6 + .../Sources/LifecycleRunner.swift | 5 + Where/Where/Sources/AppDelegate.swift | 6 +- .../WhereUI/Sources/Launch/WhereLaunch.swift | 187 +++++++----------- .../Sources/Launch/WhereLaunchSteps.swift | 178 +++++++++++++++++ Where/WhereUI/Sources/Model/WhereModel.swift | 53 +++-- .../WhereUI/Sources/Model/WhereSession.swift | 22 +-- .../Sources/Onboarding/OnboardingView.swift | 27 +-- Where/WhereUI/Sources/RootView.swift | 73 ++++--- .../Sources/Settings/SettingsView.swift | 13 +- Where/WhereUI/Tests/OnboardingTests.swift | 14 +- Where/WhereUI/Tests/WhereLaunchTests.swift | 35 ++-- Where/WhereUI/Tests/WhereResetTests.swift | 89 ++++++--- 14 files changed, 445 insertions(+), 264 deletions(-) create mode 100644 Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift diff --git a/Package.swift b/Package.swift index 5038b98c..93fb6070 100644 --- a/Package.swift +++ b/Package.swift @@ -109,6 +109,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/Shared/LifecycleKit/Sources/LaunchPlan.swift b/Shared/LifecycleKit/Sources/LaunchPlan.swift index 21cceb53..d66b5c2a 100644 --- a/Shared/LifecycleKit/Sources/LaunchPlan.swift +++ b/Shared/LifecycleKit/Sources/LaunchPlan.swift @@ -25,6 +25,12 @@ public struct LaunchPlan { /// 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. + public var nodeIDs: [AnyHashable] { + nodes.flatMap(\.ids) + } + private init(nodes: [LaunchPlanNode]) { self.nodes = nodes } diff --git a/Shared/LifecycleKit/Sources/LifecycleRunner.swift b/Shared/LifecycleKit/Sources/LifecycleRunner.swift index 52ea423c..f39dabff 100644 --- a/Shared/LifecycleKit/Sources/LifecycleRunner.swift +++ b/Shared/LifecycleKit/Sources/LifecycleRunner.swift @@ -245,6 +245,11 @@ public final class LifecycleRunner { return true } guard tornDown else { return } + // The teardown succeeded, so no retry will need it again: release + // the retained plan and — importantly — its input, which is + // typically the very object being torn down (retaining a dead + // session past its teardown would leak it for the process's life). + self.teardown = nil // The relaunch is a fresh attempt: clear the memo so every launch // node re-runs over the torn-down state rather than being skipped // as "already done" from before the teardown. diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index 704c5507..cd085116 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -5,7 +5,7 @@ import WhereCore import WhereIntents import WhereUI -/// Owns the app's single `WhereModel` and the `LegacyLifecycleRunner` that drives +/// Owns the app's single `WhereModel` and the `LifecycleRunner` that drives /// launch, wiring both up at process launch rather than from a SwiftUI view's /// `.task`. /// @@ -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: LegacyLifecycleRunner! + 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/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index f87feed5..db3eaa0a 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. + /// 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 `LegacyLifecycleRunner` -/// 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 `LegacyLifecycleContainer`'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,33 +135,33 @@ 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, 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 /// 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 }, - ) -> LegacyLifecycleRunner { + ) -> LifecycleRunner { let bootstrap = WhereBootstrap() logger { .runnerCreated(reason: String(describing: reason)) } - return LegacyLifecycleRunner( + return LifecycleRunner( reason: reason, initializePrerequisites: { bootstrap.prepareLocation() ForegroundNotificationPresenter.install() }, - sequence: sequence( + plan: plan( for: model, bootstrap: bootstrap, onServicesReady: onServicesReady, @@ -160,105 +169,45 @@ public enum WhereLaunch { ) } - /// 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 }, - ) -> LegacyLifecycleSteps { - LegacyLifecycleSteps { - // 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. - LegacyLifecycleStep.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. `LegacyLifecycleStep.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). - LegacyLifecycleStep.interactive( - LaunchStepID.onboarding, - condition: { !model.hasOnboarded }, - ) { OnboardingView(bridge: $0) } - - LegacyLifecycleStep.work(LaunchStepID.syncAuth) { _ in - await model.session?.syncAuthorization() - model.session?.observeAuthorizationChanges() - await model.session?.seedRegionStyles() - model.session?.observeRegionStyleChanges() - } - LegacyLifecycleStep.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`. - LegacyLifecycleStep.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() } - LegacyLifecycleStep.work(LaunchStepID.reminders) { _ in - await model.session?.applyReminderConfiguration() - } - LegacyLifecycleStep.work(LaunchStepID.summary) { _ in - await model.session?.applySummaryConfiguration() - } - LegacyLifecycleStep.work(LaunchStepID.issueAlerts) { _ in - await model.session?.applyIssueAlertConfiguration() - } - LegacyLifecycleStep.work(LaunchStepID.widgetSnapshot) { _ in - await model.session?.refreshWidgetSnapshot() - } - } } - /// The reverse of `sequence`: the teardown run by Settings' "Erase all data - /// & reset". `LegacyLifecycleRunner.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) -> LegacyLifecycleSteps { - LegacyLifecycleSteps { - // 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. - LegacyLifecycleStep.work(LaunchStepID.eraseData) { _ in - try await model.eraseAllData() - model.endSession() - } - LegacyLifecycleStep - .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..8b3e04aa --- /dev/null +++ b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift @@ -0,0 +1,178 @@ +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: AnyHashable = 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: AnyHashable = 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: AnyHashable = 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: AnyHashable = 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: AnyHashable = 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: AnyHashable = 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: AnyHashable = 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: AnyHashable = 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: AnyHashable = 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: AnyHashable = 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` with the session and preferences intact, so a retry re-erases +/// rather than stranding the user in onboarding atop un-erased data. +struct EraseDataStep: LifecycleStep { + let model: WhereModel + + let id: AnyHashable = 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: AnyHashable = LaunchStepID.resetPreferences + + func run(_: Void, _: LifecycleStepContext) async throws { + model.resetPreferences() + } +} diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 6e1e3604..6fd3857d 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 @@ -109,13 +113,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. @@ -133,19 +130,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 @@ -158,15 +158,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 d5d0de6e..018b23f7 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -154,9 +154,9 @@ 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 `LegacyLifecycleRunner`. Report/data-issue loading is *not* here — the + /// without a `LifecycleRunner`. Report/data-issue loading is *not* here — the /// scene's `YearReportModel` owns that and starts it when the UI appears. public func start() async { await syncAuthorization() @@ -192,7 +192,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() } @@ -200,7 +200,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() @@ -247,7 +247,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 { @@ -277,7 +277,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 { @@ -297,7 +297,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 } @@ -354,7 +354,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 @@ -379,7 +379,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 @@ -399,7 +399,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( @@ -424,7 +424,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 e4559401..65c3efed 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 f8742f25..c2824df7 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 @@ -10,11 +11,12 @@ import WhereCore /// 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). /// -/// `LegacyLifecycleContainer` renders the splash / onboarding / migration UI while -/// the `LegacyLifecycleRunner` 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 @@ -36,10 +38,10 @@ public struct RootView: View { @State private var alerter: PeriscopeAlerter? @State private var toastCenter = DeveloperToastCenter() #endif - private let launcher: LegacyLifecycleRunner + private let launcher: LifecycleRunner /// Inject the app-owned model + runner built at launch. The app uses this. - public init(model: WhereModel, launcher: LegacyLifecycleRunner) { + public init(model: WhereModel, launcher: LifecycleRunner) { _model = State(initialValue: model) self.launcher = launcher } @@ -55,26 +57,34 @@ public struct RootView: View { public var body: some View { ZStack { - LegacyLifecycleContainer( + LifecycleContainer( launcher, transition: revealTransition, animation: revealAnimation, - splash: { LaunchSplashView() }, + splash: { _ in 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) - } + 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) } // The floating developer surface sits above every launch phase and @@ -99,17 +109,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 - // `LegacyLifecycleRunner` that `LegacyLifecycleContainer` 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/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 3d9c6e29..1f45082d 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -1,4 +1,4 @@ -import LifecycleKit +import LifecycleKitUI import PeriscopeCore import RegionKit import SwiftUI @@ -23,7 +23,7 @@ struct SettingsView: View { @Environment(WhereModel.self) private var model @Environment(WhereSession.self) private var session @Environment(\.openURL) private var openURL - @Environment(\.lifecycleRunner) private var runner + @Environment(\.lifecycle) private var lifecycle @State private var showClearConfirmation = false @State private var showResetConfirmation = false @@ -554,8 +554,8 @@ struct SettingsView: View { } /// Whole-app teardown: wipes every year's data and returns to first-run - /// onboarding, run through the `LegacyLifecycleRunner` published into the - /// environment by `LegacyLifecycleContainer`. The runner proxy asserts in debug / + /// onboarding, run through the `LifecycleProxy` published into the + /// environment by `LifecycleContainer`. The proxy asserts in debug / /// no-ops in release when no container is above (e.g. previews). private var resetSection: some View { Section { @@ -582,7 +582,10 @@ struct SettingsView: View { } private func requestReset() { - Task { await runner.teardown(WhereLaunch.resetSequence(for: model)) } + // The reset plan is rooted at the session being torn down — handed in + // here, where it's known non-optional, rather than re-read inside the + // teardown. + Task { await lifecycle.teardown(WhereLaunch.resetPlan(for: model), input: session) } } private func openSystemSettings() { 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/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index e23cfb3a..a1327cb5 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, + .startSession, .onboarding, .syncAuth, .reconcileTracking, @@ -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 c2c363ae..efab3f58 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 `LegacyLifecycleRunner.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,9 +72,9 @@ 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) + let ids = WhereLaunch.resetPlan(for: model).nodeIDs #expect(ids == [LaunchStepID.eraseData, .resetPreferences].map { AnyHashable($0) }) } @@ -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 = LegacyLifecycleSteps { - LegacyLifecycleStep.work(LaunchStepID.eraseData) { _ in - throw CocoaError(.fileWriteUnknown) - } - LegacyLifecycleStep.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: AnyHashable = 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: AnyHashable = LaunchStepID.resetPreferences + + func run(_: Void, _: LifecycleStepContext) async throws { + model.resetPreferences() + } +} From 0379d5b9b456824b06c0a0b21a305d5dfcac41bc Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 19:07:57 -0700 Subject: [PATCH 05/17] Delete the legacy linear API; core drops SwiftUI; docs refreshed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step: cleanup-docs. - Removed the Legacy* linear API and its suites: the closure step struct + LifecycleSteps builder, the non-generic runner, the old phase enum (LifecycleFailure survives in its own file), the LifecycleStepUIBridge (its reporting half lives on in LifecycleStepContext; its resolution half in LifecycleGateHandle), the old container/proxy, and the per-step presentation machinery (minVisible/deferred triggers) — gates render immediately via the registry and slow-launch captions are the splash's own concern. - LifecycleKit is now pure Foundation + Observation: LifecycleSplash, LifecycleFailureView, and the localized strings moved to LifecycleKitUI (whose bundle now owns the string catalog). - Docs: LifecycleKit README/AGENTS rewritten for the typed model (step protocol, LaunchPlan combinators, value-carrying phase, scope convention, memoized retry, cancel-and-drain invariants); Where AGENTS + WhereUI README updated for the plan/gate wiring; root AGENTS + Project.swift double-link notes now name LifecycleKitUI. ./sync-agents run. - Full Stuff-iOS-Tests scheme green (197 suites) and ./swiftformat --lint clean. --- AGENTS.md | 2 +- Package.swift | 6 +- Project.swift | 2 +- Shared/LifecycleKit/AGENTS.md | 87 ++- Shared/LifecycleKit/README.md | 431 +++++++-------- .../Sources/LegacyLifecycleContainer.swift | 227 -------- .../Sources/LegacyLifecycleRunner.swift | 364 ------------- .../Sources/LegacyLifecycleStep.swift | 161 ------ .../Sources/LegacyLifecycleSteps.swift | 72 --- .../Sources/LifecycleFailure.swift | 11 + .../LifecycleKit/Sources/LifecyclePhase.swift | 88 ---- .../Sources/LifecycleReason.swift | 2 +- .../Sources/LifecycleStepUIBridge.swift | 110 ---- .../Tests/LegacyLifecycleContainerTests.swift | 269 ---------- .../LegacyLifecycleRunnerFuzzTests.swift | 142 ----- .../LegacyLifecycleRunnerResetTests.swift | 131 ----- .../Tests/LegacyLifecycleRunnerTests.swift | 497 ------------------ .../Tests/LegacyLifecycleStepTests.swift | 104 ---- .../Tests/LifecyclePhaseTests.swift | 47 -- .../Tests/LifecycleStepUIBridgeTests.swift | 73 --- .../Sources/LifecycleFailureView.swift | 1 + .../Sources/LifecycleSplash.swift | 0 .../Sources/Resources/Localizable.xcstrings | 0 Where/AGENTS.md | 5 +- Where/WhereUI/README.md | 28 +- 25 files changed, 264 insertions(+), 2596 deletions(-) delete mode 100644 Shared/LifecycleKit/Sources/LegacyLifecycleContainer.swift delete mode 100644 Shared/LifecycleKit/Sources/LegacyLifecycleRunner.swift delete mode 100644 Shared/LifecycleKit/Sources/LegacyLifecycleStep.swift delete mode 100644 Shared/LifecycleKit/Sources/LegacyLifecycleSteps.swift create mode 100644 Shared/LifecycleKit/Sources/LifecycleFailure.swift delete mode 100644 Shared/LifecycleKit/Sources/LifecyclePhase.swift delete mode 100644 Shared/LifecycleKit/Sources/LifecycleStepUIBridge.swift delete mode 100644 Shared/LifecycleKit/Tests/LegacyLifecycleContainerTests.swift delete mode 100644 Shared/LifecycleKit/Tests/LegacyLifecycleRunnerFuzzTests.swift delete mode 100644 Shared/LifecycleKit/Tests/LegacyLifecycleRunnerResetTests.swift delete mode 100644 Shared/LifecycleKit/Tests/LegacyLifecycleRunnerTests.swift delete mode 100644 Shared/LifecycleKit/Tests/LegacyLifecycleStepTests.swift delete mode 100644 Shared/LifecycleKit/Tests/LifecyclePhaseTests.swift delete mode 100644 Shared/LifecycleKit/Tests/LifecycleStepUIBridgeTests.swift rename Shared/{LifecycleKit => LifecycleKitUI}/Sources/LifecycleFailureView.swift (98%) rename Shared/{LifecycleKit => LifecycleKitUI}/Sources/LifecycleSplash.swift (100%) rename Shared/{LifecycleKit => LifecycleKitUI}/Sources/Resources/Localizable.xcstrings (100%) diff --git a/AGENTS.md b/AGENTS.md index ab6b584a..08053bd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ by `./sync-agents`. `AGENTS.md` says what it is and how it may be used. - Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). - **CI scheme**: CI runs the explicit shared **Stuff-iOS-Tests** scheme (all test bundles) rather than the autogenerated `Stuff-Workspace` scheme. New test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them. -- **Never double-link a package product into a test bundle that already gets it through a dynamic-framework dependency.** Xcode's default SPM integration (how `Package.local` is wired here) embeds a product's code into *every* image that links it. **WhereUI** is a dynamic framework that statically embeds its own dependencies (WhereCore, BroadwayCore/BroadwayUI, LifecycleKit, PeriscopeCore/PeriscopeUI/PeriscopeTools, SwiftDataInspector, …), so a `*Tests` bundle that depends on **WhereUI** *and* re-lists one of those in `extraPackageProducts` ends up with a **second copy** of it. When the shared **StuffTestHost** loads several `.xctest` bundles into one process, that leaves duplicate **type metadata** for the module, and any *type-keyed runtime lookup that crosses the WhereUI boundary* — SwiftUI `EnvironmentKey`s, `UITraitBridgedEnvironmentKey` bridging, the type-keyed `BTraits`/`BThemes`/`BStylesheets` containers — silently resolves against the wrong copy and returns the default (the writer stores under one copy's key *type*, the reader looks it up under another's). It only reproduces in the **full multi-bundle scheme** (not isolated `tuist test WhereUITests` runs) and is papered over by newer Xcode linkers, so it is brutal to diagnose (it cost ~2 hours once). **Depend on such products only transitively via `WhereUI`; keep them out of `extraPackageProducts`.** `WhereStylesheetTests.resolvesTraitAwareTokensFromTheBroadwayRoot` is the regression guard — it exercises a `\.stylesheet` (→ `\.bContext`) read across the boundary and fails if a duplicate copy returns. +- **Never double-link a package product into a test bundle that already gets it through a dynamic-framework dependency.** Xcode's default SPM integration (how `Package.local` is wired here) embeds a product's code into *every* image that links it. **WhereUI** is a dynamic framework that statically embeds its own dependencies (WhereCore, BroadwayCore/BroadwayUI, LifecycleKit/LifecycleKitUI, PeriscopeCore/PeriscopeUI/PeriscopeTools, SwiftDataInspector, …), so a `*Tests` bundle that depends on **WhereUI** *and* re-lists one of those in `extraPackageProducts` ends up with a **second copy** of it. When the shared **StuffTestHost** loads several `.xctest` bundles into one process, that leaves duplicate **type metadata** for the module, and any *type-keyed runtime lookup that crosses the WhereUI boundary* — SwiftUI `EnvironmentKey`s, `UITraitBridgedEnvironmentKey` bridging, the type-keyed `BTraits`/`BThemes`/`BStylesheets` containers — silently resolves against the wrong copy and returns the default (the writer stores under one copy's key *type*, the reader looks it up under another's). It only reproduces in the **full multi-bundle scheme** (not isolated `tuist test WhereUITests` runs) and is papered over by newer Xcode linkers, so it is brutal to diagnose (it cost ~2 hours once). **Depend on such products only transitively via `WhereUI`; keep them out of `extraPackageProducts`.** `WhereStylesheetTests.resolvesTraitAwareTokensFromTheBroadwayRoot` is the regression guard — it exercises a `\.stylesheet` (→ `\.bContext`) read across the boundary and fails if a duplicate copy returns. ## Deployment diff --git a/Package.swift b/Package.swift index 93fb6070..27dd74d1 100644 --- a/Package.swift +++ b/Package.swift @@ -35,9 +35,6 @@ let package = Package( .target( name: "LifecycleKit", path: "Shared/LifecycleKit/Sources", - resources: [ - .process("Resources"), - ], ), .target( name: "LifecycleKitUI", @@ -45,6 +42,9 @@ let package = Package( .target(name: "LifecycleKit"), ], path: "Shared/LifecycleKitUI/Sources", + resources: [ + .process("Resources"), + ], ), .target( name: "JournalKit", diff --git a/Project.swift b/Project.swift index 115e50d2..105bd14c 100644 --- a/Project.swift +++ b/Project.swift @@ -333,7 +333,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 the root AGENTS.md "Targets" note. diff --git a/Shared/LifecycleKit/AGENTS.md b/Shared/LifecycleKit/AGENTS.md index 3c57707d..bcc60707 100644 --- a/Shared/LifecycleKit/AGENTS.md +++ b/Shared/LifecycleKit/AGENTS.md @@ -1,60 +1,59 @@ # 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`. +- **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. - **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. -- **`.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 - 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. -- **Promotion resolves a not-yet-foreground launch and is idempotent.** - `enterForeground()` no-ops unless the reason is already `.userForeground` - (so `.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. + 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, per attempt.** Completed nodes' outputs are memoized; + `retry()` resumes the failed node with its original input and promotion + never repeats completed work. Skipped gates are deliberately *not* + memoized so they re-evaluate on promotion. Fresh attempts (first `run()`, + post-teardown relaunch) clear the memo. +- **Detached children are off the critical path by construction:** they never + block `.ready`, never fail the drive, and surface failures only on + `detachedFailures`. A successful teardown releases the retained teardown + plan + input (the input is typically the dead session). +- **Promotion is foreground-only and idempotent.** `enterForeground()` no-ops + for a foreground launch; consumers must only call it once the scene is + genuinely `.active` (see `RootView` in WhereUI for the `scenePhase` gating + pattern). ## 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 67c4efdc..fc2ed3b5 100644 --- a/Shared/LifecycleKit/README.md +++ b/Shared/LifecycleKit/README.md @@ -1,309 +1,244 @@ # 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 failure phase with retry; 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 + var id: AnyHashable { get } // a typed enum case, ideally + 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 + var id: AnyHashable { 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. Input is 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 + public func then(_ step: S) -> LaunchPlan where S.Input == Output + public func thenKeeping(_ step: S) -> Self where S.Input == Output, S.Output == Void + public func gate(_ gate: G) -> Self where G.Value == Output + public func detached(@DetachedChildrenBuilder _ children: ...) -> Self + public var nodeIDs: [AnyHashable] // 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) // failure UI + 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 retry() // resume from the failed node, same input + 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 | - -Surfaces crossfade by default (see `transition`/`animation` below). +`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. -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: AnyHashable = 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() } -``` - -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: AnyHashable = 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: AnyHashable = 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 `.failed` and does **not** +relaunch; `retry()` resumes the teardown from the failed node, then +relaunches. A teardown's detached children drain *before* the relaunch, so no +torn-down-world work overlaps the fresh launch. On success the retained plan +and input are released (the input is typically the dead session). + +## Correctness points designed in deliberately + +- **Retry resumes with the same input.** Every completed node's output is + memoized for the current attempt; `retry()` re-runs the failed node with + the value it originally got, and nodes that already completed never run + twice — across `retry()` *and* `enterForeground()` promotion. A fresh + attempt (first `run()`, the relaunch after a teardown) 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` / `retry` + / `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, retry +memoization, and teardown — plus seeded fuzz suites that drive randomized +plans against an independent model (200 seeds) and drain randomized flaky +failures through `retry()` (120 seeds). Because a real gate suspends, drive +the runner from a `Task` and poll `runner.phase` until it parks, then resolve +the handle. `@_spi(Testing) injectFailureForTesting(_:)` covers the +failed-with-no-resume-point path. diff --git a/Shared/LifecycleKit/Sources/LegacyLifecycleContainer.swift b/Shared/LifecycleKit/Sources/LegacyLifecycleContainer.swift deleted file mode 100644 index 26c27856..00000000 --- a/Shared/LifecycleKit/Sources/LegacyLifecycleContainer.swift +++ /dev/null @@ -1,227 +0,0 @@ -import SwiftUI - -extension EnvironmentValues { - /// A handle to the running `LegacyLifecycleRunner`, published by - /// `LegacyLifecycleContainer` 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 `LegacyLifecycleRunner?` 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 `LegacyLifecycleRunner`. -/// -/// `LegacyLifecycleContainer` 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 `LegacyLifecycleRunner` 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: LegacyLifecycleRunner? - - /// A disconnected proxy (no runner): the environment default, and what - /// previews get so reset/retry quietly do nothing. - public init() { - base = nil - } - - init(_ runner: LegacyLifecycleRunner) { - base = runner - } - - /// Resume a failed launch from the step that failed. - /// See `LegacyLifecycleRunner.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 `LegacyLifecycleRunner.teardown(_:)`. - @MainActor public func teardown( - _ sequence: LegacyLifecycleSteps, - file: StaticString = #fileID, - line: UInt = #line, - ) async { - await connected(file: file, line: line)?.teardown(sequence) - } - - /// Promote a headless background launch to the foreground. - /// See `LegacyLifecycleRunner.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) -> LegacyLifecycleRunner? { - if base == nil { - assertionFailure( - "No LegacyLifecycleRunner in the environment — is this view inside a LegacyLifecycleContainer?", - file: file, - line: line, - ) - } - return base - } -} - -/// The root view that renders a `LegacyLifecycleRunner`'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 LegacyLifecycleContainer: View { - private let runner: LegacyLifecycleRunner - private let transition: AnyTransition - private let animation: Animation? - private let splash: () -> Splash - private let failureView: (LifecycleFailure, @escaping () -> Void) -> Failure - private let content: () -> Content - - /// - Parameters: - /// - transition: how each surface enters/leaves. Defaults to a crossfade. - /// - animation: the animation driving `transition`. Pass `nil` to swap - /// surfaces instantly (no animation). - public init( - _ runner: LegacyLifecycleRunner, - transition: AnyTransition = .opacity, - animation: Animation? = .default, - @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.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: 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: - splash().transition(transition).zIndex(Self.launchSurfaceZIndex) - 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 { - splash().transition(transition).zIndex(Self.launchSurfaceZIndex) - } - case let .failed(failure): - failureView(failure) { runner.retry() } - .transition(transition) - .zIndex(Self.launchSurfaceZIndex) - case .ready: - content().transition(transition).zIndex(Self.contentZIndex) - } - } -} - -extension LegacyLifecycleContainer where Splash == LifecycleSplash, - Failure == LifecycleFailureView -{ - /// Convenience initializer using the built-in splash and failure views. - public init( - _ runner: LegacyLifecycleRunner, - @ViewBuilder content: @escaping () -> Content, - ) { - self.init( - runner, - splash: { LifecycleSplash() }, - failure: { LifecycleFailureView(failure: $0, retry: $1) }, - content: content, - ) - } -} - -extension LegacyLifecycleContainer where Failure == LifecycleFailureView { - /// Convenience initializer with a custom splash but the built-in failure - /// view. - public init( - _ runner: LegacyLifecycleRunner, - @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") { - LegacyLifecycleContainer( - LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("open") { _ in } - }), - ) { - Text("App content") - } - } -#endif diff --git a/Shared/LifecycleKit/Sources/LegacyLifecycleRunner.swift b/Shared/LifecycleKit/Sources/LegacyLifecycleRunner.swift deleted file mode 100644 index d995346c..00000000 --- a/Shared/LifecycleKit/Sources/LegacyLifecycleRunner.swift +++ /dev/null @@ -1,364 +0,0 @@ -import Observation -import SwiftUI - -/// Drives a `LegacyLifecycleSteps` 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. -/// -/// Lifecycle: -/// - 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. -/// - `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. -/// -/// 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. -@MainActor -@Observable -public final class LegacyLifecycleRunner { - /// The single value the host renders. - public private(set) var phase: LifecyclePhase = .launching - - /// 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. - private enum State { - /// Built; `run()` not yet called. Carries the reason it will launch with. - case notStarted(LifecycleReason) - /// `run()` (or a re-drive) has started. Carries the current reason and - /// the most recent drive task — which may already have completed, so - /// late `run()`/`teardown()`/`enterForeground()` callers can await it. - case running(reason: LifecycleReason, task: Task) - - /// The launch reason, which every case carries. Lives on the state so - /// callers read `state.reason` instead of re-switching at each use site. - var reason: LifecycleReason { - switch self { - case let .notStarted(reason): reason - case let .running(reason, _): reason - } - } - } - - 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. - public var reason: LifecycleReason { - state.reason - } - - @ObservationIgnored private let steps: [LegacyLifecycleStep] - /// 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: [LegacyLifecycleStep] = [] - - /// 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. - /// - /// 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 = [] - - public init( - reason: LifecycleReason, - initializePrerequisites: @MainActor () -> Void = {}, - sequence: LegacyLifecycleSteps, - ) { - state = .notStarted(reason) - steps = sequence.steps - initializePrerequisites() - } - - /// The in-flight (or most recently finished) drive task, if `run()` has - /// been called. - private var currentTask: Task? { - 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. - 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) - 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. - /// - /// 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 - /// 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) } - } - } - - /// 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. - /// - /// 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: LegacyLifecycleSteps) async { - teardownSteps = sequence.steps - await driveTeardown(fromTeardownIndex: 0) - } - - /// 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 { - let previous = currentTask - previous?.cancel() - let reason = reason - phase = .launching - 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 - } - } - 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 { - 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 - } - } - 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`. - 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: [LegacyLifecycleStep], - from startIndex: Int, - ) async -> DriveOutcome { - var index = startIndex - while index < steps.count { - if Task.isCancelled { return .cancelled } - - let step = steps[index] - - // 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 - } - - switch await runStep(step) { - case .completed: - completedStepIDs.insert(step.id) - index += 1 - case .failed: return .failed - case .cancelled: return .cancelled - } - } - return .completed - } - - /// 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: LegacyLifecycleStep) 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 - } - - await presentation.hold() - return .completed - } -} - -#if DEBUG - extension LegacyLifecycleRunner { - /// 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 - -/// 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: LegacyLifecycleStep, 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) - } - } - } - - /// 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 - } - - /// 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) - } - } - - /// Cancel the deferred timer (if any). Called on every step exit path. - func cancel() { - pendingTimer?.cancel() - pendingTimer = nil - } -} diff --git a/Shared/LifecycleKit/Sources/LegacyLifecycleStep.swift b/Shared/LifecycleKit/Sources/LegacyLifecycleStep.swift deleted file mode 100644 index 00904f60..00000000 --- a/Shared/LifecycleKit/Sources/LegacyLifecycleStep.swift +++ /dev/null @@ -1,161 +0,0 @@ -import SwiftUI - -/// One unit of launch work. -/// -/// 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. -/// -/// 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 LegacyLifecycleStep: 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 - - var allowedModes: LifecycleModeSet - var condition: @MainActor () async -> Bool - var perform: @MainActor (LifecycleStepUIBridge) async throws -> Void - var presentation: LifecycleStepPresentation? - - /// - 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 - } - - /// 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) - } - - 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 - } -} - -// MARK: - Declarative sugar - -extension LegacyLifecycleStep { - /// 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, - ) -> LegacyLifecycleStep { - LegacyLifecycleStep(id: id, modes: modes, condition: condition, perform: perform) - } - - /// 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, - ) -> LegacyLifecycleStep { - LegacyLifecycleStep(id: id, modes: modes, condition: condition, perform: perform) - .presenting(view) - } -} - -/// 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) - } - - 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 `[LegacyLifecycleStep]` can share one element type without leaking - /// each step's concrete view type into `LegacyLifecycleStep`/`LegacyLifecycleSteps`; 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/LegacyLifecycleSteps.swift b/Shared/LifecycleKit/Sources/LegacyLifecycleSteps.swift deleted file mode 100644 index e5ae7d37..00000000 --- a/Shared/LifecycleKit/Sources/LegacyLifecycleSteps.swift +++ /dev/null @@ -1,72 +0,0 @@ -/// Result builder that collects `LegacyLifecycleStep`s declared in a -/// `LegacyLifecycleSteps`, with `if`/`if-else`/`for` support so steps can be -/// included conditionally. -@resultBuilder -public enum LegacyLifecycleStepsBuilder { - public static func buildExpression(_ step: LegacyLifecycleStep) -> [LegacyLifecycleStep] { - [step] - } - - public static func buildExpression(_ steps: [LegacyLifecycleStep]) -> [LegacyLifecycleStep] { - steps - } - - public static func buildBlock(_ components: [LegacyLifecycleStep]...) -> [LegacyLifecycleStep] { - components.flatMap(\.self) - } - - public static func buildOptional(_ component: [LegacyLifecycleStep]?) -> [LegacyLifecycleStep] { - component ?? [] - } - - public static func buildEither(first component: [LegacyLifecycleStep]) - -> [LegacyLifecycleStep] - { - component - } - - public static func buildEither(second component: [LegacyLifecycleStep]) - -> [LegacyLifecycleStep] - { - component - } - - public static func buildArray(_ components: [[LegacyLifecycleStep]]) -> [LegacyLifecycleStep] { - components.flatMap(\.self) - } - - public static func buildLimitedAvailability(_ component: [LegacyLifecycleStep]) - -> [LegacyLifecycleStep] - { - component - } -} - -/// An ordered list of lifecycle steps. The engine walks these top to bottom; -/// the declaration order is the run order. -public struct LegacyLifecycleSteps { - public let steps: [LegacyLifecycleStep] - - public init(@LegacyLifecycleStepsBuilder _ steps: () -> [LegacyLifecycleStep]) { - let steps = steps() - Self.assertUniqueIDs(steps) - self.steps = steps - } - - public init(steps: [LegacyLifecycleStep]) { - 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: [LegacyLifecycleStep]) { - var seen = Set() - let duplicates = steps.map(\.id).filter { !seen.insert($0).inserted } - precondition( - duplicates.isEmpty, - "LegacyLifecycleSteps contains duplicate step IDs: \(duplicates)", - ) - } -} diff --git a/Shared/LifecycleKit/Sources/LifecycleFailure.swift b/Shared/LifecycleKit/Sources/LifecycleFailure.swift new file mode 100644 index 00000000..5387f47d --- /dev/null +++ b/Shared/LifecycleKit/Sources/LifecycleFailure.swift @@ -0,0 +1,11 @@ +/// Carries which node failed and why, so the failure UI can describe it and +/// the runner can retry from that node. +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/LifecyclePhase.swift b/Shared/LifecycleKit/Sources/LifecyclePhase.swift deleted file mode 100644 index 7e0a360a..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`; `LegacyLifecycleContainer` 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(LegacyLifecycleStep, 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* `LegacyLifecycleContainer` 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/LifecycleReason.swift b/Shared/LifecycleKit/Sources/LifecycleReason.swift index 15aaf11a..c2b8e508 100644 --- a/Shared/LifecycleKit/Sources/LifecycleReason.swift +++ b/Shared/LifecycleKit/Sources/LifecycleReason.swift @@ -13,7 +13,7 @@ public enum LifecycleReason: Sendable, Hashable { /// can't distinguish a user-tap launch from a headless wake at /// `didFinishLaunching` time — it reads `.background` for both. Behaves like /// a background launch (no window, background-safe steps only) until a scene - /// activates and `LegacyLifecycleRunner.enterForeground()` promotes it to + /// activates and `LifecycleRunner.enterForeground()` promotes it to /// `.userForeground`. If no scene ever connects (a genuine headless wake), /// it honestly stays `.undetermined` for the process's life — the /// background-safe steps that ran serviced the wake, and no fabricated cause 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/Tests/LegacyLifecycleContainerTests.swift b/Shared/LifecycleKit/Tests/LegacyLifecycleContainerTests.swift deleted file mode 100644 index 16ba23a1..00000000 --- a/Shared/LifecycleKit/Tests/LegacyLifecycleContainerTests.swift +++ /dev/null @@ -1,269 +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 LegacyLifecycleContainerTests { - @Test func readyShowsContent() async throws { - var content = false - var splash = false - let runner = LegacyLifecycleRunner( - reason: .userForeground, - sequence: LegacyLifecycleSteps {}, - ) - await runner.run() - #expect(runner.phase.isReady) - - let container = LegacyLifecycleContainer(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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in } - }) - // Not run yet, so the runner is still in .launching. - - let container = LegacyLifecycleContainer(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 = LegacyLifecycleRunner( - reason: .background(.location), - sequence: LegacyLifecycleSteps {}, - ) - await runner.run() - #expect(runner.phase.isReady) - - let container = LegacyLifecycleContainer(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 = LegacyLifecycleRunner( - reason: .background(.location), - sequence: LegacyLifecycleSteps {}, - ) - await runner.run() - #expect(runner.phase.isReady) - #expect(runner.reason.buildsNoViewTree) - - await runner.enterForeground() - #expect(!runner.reason.buildsNoViewTree) - #expect(runner.phase.isReady) - - let container = LegacyLifecycleContainer(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 = LegacyLifecycleRunner(reason: .undetermined, sequence: LegacyLifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - - let container = LegacyLifecycleContainer(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 = LegacyLifecycleRunner(reason: .undetermined, sequence: LegacyLifecycleSteps {}) - await runner.run() - #expect(runner.phase.isReady) - #expect(runner.reason.buildsNoViewTree) - - await runner.enterForeground() - #expect(!runner.reason.buildsNoViewTree) - #expect(runner.phase.isReady) - - let container = LegacyLifecycleContainer(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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.interactive("gate") { _ in ProbeView { presentation = true } } - }) - let task = Task { @MainActor in await runner.run() } - try await waitUntil { runner.phase.isRunning("gate") } - - let container = LegacyLifecycleContainer(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 failedShowsFailureView() async throws { - var failure = false - var content = false - var splash = false - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("boom") { _ in throw ProbeError() } - }) - await runner.run() - #expect(runner.phase.failed(at: "boom")) - - let container = LegacyLifecycleContainer( - 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 = LegacyLifecycleRunner( - reason: .userForeground, - sequence: LegacyLifecycleSteps {}, - ) - await runner.run() - #expect(runner.phase.isReady) - - let container = LegacyLifecycleContainer(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 = LegacyLifecycleRunner( - reason: .userForeground, - sequence: LegacyLifecycleSteps {}, - ) - await runner.run() - #expect(runner.phase.isReady) - - let container = LegacyLifecycleContainer( - 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 = LegacyLifecycleRunner( - reason: .userForeground, - sequence: LegacyLifecycleSteps {}, - ) - await runner.run() - #expect(runner.phase.isReady) - - await LifecycleRunnerProxy(runner).teardown(LegacyLifecycleSteps { - LegacyLifecycleStep.work("teardown") { _ in tornDown = true } - }) - #expect(tornDown) - #expect(runner.phase.isReady) - } -} diff --git a/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerFuzzTests.swift b/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerFuzzTests.swift deleted file mode 100644 index 2597e6af..00000000 --- a/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerFuzzTests.swift +++ /dev/null @@ -1,142 +0,0 @@ -@testable import LifecycleKit -import Testing - -private struct FuzzError: Error {} - -/// A small, *seedable* PRNG so each fuzz case is fully reproducible: a failure -/// reports the `seed`, and re-running that seed replays the exact sequence. -/// (`SystemRandomNumberGenerator` can't be seeded.) -private struct SplitMix64: RandomNumberGenerator { - private var state: UInt64 - - init(seed: UInt64) { - state = seed - } - - mutating func next() -> UInt64 { - state &+= 0x9E37_79B9_7F4A_7C15 - var z = state - z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 - z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB - return z ^ (z >> 31) - } -} - -/// A randomized step description, kept separate from the built `LegacyLifecycleStep` -/// so the test can predict the expected outcome from the same data the runner -/// drives. -private struct FuzzStep { - let id: String - let modes: LifecycleModeSet - let conditionPasses: Bool - let throwsError: Bool -} - -private func makeFuzzSteps(_ rng: inout SplitMix64) -> [FuzzStep] { - let modeChoices: [LifecycleModeSet] = [.all, .foreground, .background] - let count = Int.random(in: 1 ... 8, using: &rng) - return (0 ..< count).map { index in - FuzzStep( - id: "s\(index)", - 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. -@MainActor -struct LegacyLifecycleRunnerFuzzTests { - /// 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`. - @Test(arguments: 0 ..< 200) - func randomSequenceLandsWhereTheModelPredicts(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) - - var executed: [String] = [] - let runner = LegacyLifecycleRunner(reason: reason, sequence: LegacyLifecycleSteps { - for step in fuzz { - LegacyLifecycleStep - .work(step.id, modes: step.modes, condition: { step.conditionPasses }) { _ in - executed.append(step.id) - if step.throwsError { throw FuzzError() } - } - } - }) - await runner.run() - - // Independent model of what should have happened. - var expectedExecuted: [String] = [] - 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 - } - } - - #expect(executed == expectedExecuted) - 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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - for index in 0 ..< count { - LegacyLifecycleStep.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) - } - } -} diff --git a/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerResetTests.swift b/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerResetTests.swift deleted file mode 100644 index 9ec9b841..00000000 --- a/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerResetTests.swift +++ /dev/null @@ -1,131 +0,0 @@ -@testable import LifecycleKit -import SwiftUI -import Testing - -private struct ResetError: Error {} - -@MainActor -struct LegacyLifecycleRunnerResetTests { - @Test func resetRunsTeardownThenRelaunches() async { - var events: [String] = [] - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("launch") { _ in events.append("launch") } - }) - await runner.run() - #expect(events == ["launch"]) - #expect(runner.phase.isReady) - - await runner.teardown(LegacyLifecycleSteps { - LegacyLifecycleStep.work("teardown") { _ in events.append("teardown") } - }) - #expect(events == ["launch", "teardown", "launch"]) - #expect(runner.phase.isReady) - } - - @Test func resetRunsTeardownStepsInOrder() async { - var events: [String] = [] - let runner = LegacyLifecycleRunner( - reason: .userForeground, - sequence: LegacyLifecycleSteps {}, - ) - await runner.run() - - await runner.teardown(LegacyLifecycleSteps { - LegacyLifecycleStep.work("stop-gps") { _ in events.append("stop-gps") } - LegacyLifecycleStep.work("clear-store") { _ in events.append("clear-store") } - LegacyLifecycleStep.work("clear-widget") { _ in events.append("clear-widget") } - }) - #expect(events == ["stop-gps", "clear-store", "clear-widget"]) - } - - @Test func resetStepCanPresentTeardownUI() async throws { - let runner = LegacyLifecycleRunner( - reason: .userForeground, - sequence: LegacyLifecycleSteps {}, - ) - await runner.run() - - let task = Task { @MainActor in - await runner.teardown(LegacyLifecycleSteps { - LegacyLifecycleStep.interactive("signing-out") { _ in Text("Signing out") } - }) - } - try await waitUntil { runner.phase.isRunning("signing-out") } - #expect(runner.phase.runningBridge?.presentation != nil) - - runner.phase.runningBridge?.complete() - await task.value - #expect(runner.phase.isReady) - } - - @Test func failedTeardownParksInFailedAndSkipsRelaunch() async { - var relaunched = false - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("launch") { _ in relaunched = true } - }) - await runner.run() - relaunched = false - - await runner.teardown(LegacyLifecycleSteps { - LegacyLifecycleStep.work("teardown") { _ in throw ResetError() } - }) - #expect(runner.phase.failed(at: "teardown")) - #expect(!relaunched) - } - - @Test func retryAfterFailedTeardownReRunsTeardownThenRelaunches() async throws { - var events: [String] = [] - var shouldFailErase = true - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("launch") { _ in events.append("launch") } - }) - await runner.run() - events.removeAll() - - await runner.teardown(LegacyLifecycleSteps { - LegacyLifecycleStep.work("erase") { _ in - events.append("erase") - if shouldFailErase { throw ResetError() } - } - LegacyLifecycleStep.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"]) - } - - @Test func retryAfterFailedTeardownTailResumesWithoutReErasing() async throws { - var events: [String] = [] - var shouldFailPrefs = true - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("launch") { _ in events.append("launch") } - }) - await runner.run() - events.removeAll() - - await runner.teardown(LegacyLifecycleSteps { - LegacyLifecycleStep.work("erase") { _ in events.append("erase") } - LegacyLifecycleStep.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"]) - } -} diff --git a/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerTests.swift b/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerTests.swift deleted file mode 100644 index 9c2e8272..00000000 --- a/Shared/LifecycleKit/Tests/LegacyLifecycleRunnerTests.swift +++ /dev/null @@ -1,497 +0,0 @@ -@_spi(Testing) @testable import LifecycleKit -import SwiftUI -import Testing - -private struct StepError: Error {} - -@MainActor -struct LegacyLifecycleRunnerDriveTests { - @Test func runsStepsInDeclarationOrder() async { - var executed: [String] = [] - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in executed.append("a") } - LegacyLifecycleStep.work("b") { _ in executed.append("b") } - LegacyLifecycleStep.work("c") { _ in executed.append("c") } - }) - await runner.run() - #expect(executed == ["a", "b", "c"]) - #expect(runner.phase.isReady) - } - - @Test func skipsStepsWhoseConditionIsFalse() async { - var executed: [String] = [] - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in executed.append("a") } - LegacyLifecycleStep.work("skip", condition: { false }) { _ in executed.append("skip") } - LegacyLifecycleStep.work("c") { _ in executed.append("c") } - }) - await runner.run() - #expect(executed == ["a", "c"]) - } - - @Test func filtersStepsByLaunchReason() async { - var executed: [String] = [] - let runner = LegacyLifecycleRunner( - reason: .background(.location), - sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("always") { _ in executed.append("always") } - LegacyLifecycleStep.work("fg", modes: .foreground) { _ in executed.append("fg") } - LegacyLifecycleStep.work("bg", modes: .background) { _ in executed.append("bg") } - }, - ) - await runner.run() - #expect(executed == ["always", "bg"]) - } - - @Test func interactiveStepsAreSkippedInBackground() async { - var executed: [String] = [] - let runner = LegacyLifecycleRunner( - reason: .background(.location), - sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in executed.append("a") } - LegacyLifecycleStep - .interactive("onboarding", perform: { _ in - executed.append("onboarding") - }) { _ in - Text("onboarding") - } - LegacyLifecycleStep.work("c") { _ in executed.append("c") } - }, - ) - await runner.run() - #expect(executed == ["a", "c"]) - #expect(runner.phase.isReady) - } - - @Test func runIsIdempotent() async { - var count = 0 - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in count += 1 } - }) - await runner.run() - await runner.run() - #expect(count == 1) - } -} - -@MainActor -struct LegacyLifecycleRunnerForegroundPromotionTests { - @Test func enterForegroundReDrivesAndRunsForegroundOnlySteps() async { - var executed: [String] = [] - let runner = LegacyLifecycleRunner( - reason: .background(.location), - sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("store") { _ in executed.append("store") } - LegacyLifecycleStep - .work("onboarding", modes: .foreground) { _ in executed.append("onboarding") } - }, - ) - await runner.run() - // The headless background drive ran only the unrestricted step. - #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. - #expect(executed == ["store", "onboarding"]) - #expect(!runner.reason.buildsNoViewTree) - #expect(runner.phase.isReady) - } - - @Test func undeterminedLaunchRunsBackgroundStepsThenPromotesToForeground() async { - var executed: [String] = [] - let runner = LegacyLifecycleRunner(reason: .undetermined, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("store") { _ in executed.append("store") } - LegacyLifecycleStep - .work("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. - #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. - var executed: [String] = [] - var onboardingShouldFail = true - let runner = LegacyLifecycleRunner(reason: .undetermined, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("store") { _ in executed.append("store") } - LegacyLifecycleStep.work("onboarding", modes: .foreground) { _ in - executed.append("onboarding") - if onboardingShouldFail { throw StepError() } - } - LegacyLifecycleStep.work("widget") { _ in executed.append("widget") } - }) - - // Headless drive: the background-safe "store" and "widget" complete; the - // foreground-only "onboarding" (index 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. - 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"]) - } - - @Test func enterForegroundIsNoOpForAForegroundLaunch() async { - var count = 0 - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in count += 1 } - }) - await runner.run() - await runner.enterForeground() - #expect(count == 1) - #expect(runner.phase.isReady) - } - - @Test func backgroundWorkStepCallingWaitForResolutionDoesNotReachReadyUntilPromoted( - ) async throws { - let runner = LegacyLifecycleRunner( - reason: .background(.location), - sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.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 = LegacyLifecycleRunner( - reason: .background(.location), - sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("slow") { bridge in - starts += 1 - inFlight += 1 - defer { inFlight -= 1 } - maxInFlight = max(maxInFlight, inFlight) - try await bridge.waitForResolution() - } - }, - ) - 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. - let promote = Task { @MainActor in await runner.enterForeground() } - try await waitUntil { starts == 2 } - - runner.phase.runningBridge?.complete() - await runTask.value - await promote.value - - #expect(maxInFlight == 1) - #expect(!runner.reason.buildsNoViewTree) - #expect(runner.phase.isReady) - } - - @Test func supersededDriveThatThrowsDoesNotClobberThePromotedDrivesPhase() async throws { - // A superseded drive's in-flight step isn't required to be - // cancellation-responsive: it can keep working after the promotion - // cancels its drive and then throw a *real* error (the fresh-install - // 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) - var attempts = 0 - var conditionChecks = 0 - let runner = LegacyLifecycleRunner( - reason: .background(.location), - sequence: LegacyLifecycleSteps { - LegacyLifecycleStep( - 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 - attempts += 1 - if attempts == 1 { - // Park until released, then fail for real — after the - // promotion has already superseded this drive. - for await _ in blockedStep {} - throw StepError() - } - } - }, - ) - - 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 } - #expect(runner.phase.failure == nil) - - releaseGatedCondition.finish() - await promote.value - await runTask.value - #expect(attempts == 2) - #expect(runner.phase.isReady) - } -} - -@MainActor -struct LegacyLifecycleRunnerCancellationTests { - @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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - // After teardown clears the gate, the relaunch skips it and runs to - // completion. - LegacyLifecycleStep.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(LegacyLifecycleSteps { - LegacyLifecycleStep.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 LegacyLifecycleRunnerFailureTests { - @Test func thrownErrorParksInFailedAndStopsSubsequentSteps() async { - var executed: [String] = [] - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in executed.append("a") } - LegacyLifecycleStep.work("b") { _ in throw StepError() } - LegacyLifecycleStep.work("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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in executed.append("a") } - LegacyLifecycleStep.work("b") { _ in - if shouldFail { throw StepError() } - executed.append("b") - } - LegacyLifecycleStep.work("c") { _ in executed.append("c") } - }) - await runner.run() - #expect(runner.phase.failed(at: "b")) - #expect(executed == ["a"]) - - shouldFail = false - runner.retry() - try await waitUntil { runner.phase.isReady } - #expect(executed == ["a", "b", "c"]) - } - - @Test func retryIsNoOpWhenNotFailed() async { - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in } - }) - await runner.run() - runner.retry() - #expect(runner.phase.isReady) - } - - @Test func retryIsNoOpWhenFailedStepIDDoesNotMatchAnyStep() async { - var executed = 0 - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.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")) - } -} - -@MainActor -struct LegacyLifecycleRunnerInteractiveTests { - @Test func interactiveStepSuspendsUntilResolved() async throws { - var executed: [String] = [] - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in executed.append("a") } - LegacyLifecycleStep.interactive("gate") { _ in Text("gate") } - LegacyLifecycleStep.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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.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 - } - - @Test func minVisibleHoldsADeferredPresentationAfterTheStepFinishes() async throws { - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.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 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 = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.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 progressIsReadableWhileStepRuns() async throws { - let runner = LegacyLifecycleRunner(reason: .userForeground, sequence: LegacyLifecycleSteps { - LegacyLifecycleStep.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 - } -} diff --git a/Shared/LifecycleKit/Tests/LegacyLifecycleStepTests.swift b/Shared/LifecycleKit/Tests/LegacyLifecycleStepTests.swift deleted file mode 100644 index 918533da..00000000 --- a/Shared/LifecycleKit/Tests/LegacyLifecycleStepTests.swift +++ /dev/null @@ -1,104 +0,0 @@ -@testable import LifecycleKit -import SwiftUI -import Testing - -@MainActor -struct LifecycleStepsBuilderTests { - @Test func builderPreservesDeclarationOrder() { - let sequence = LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in } - LegacyLifecycleStep.work("b") { _ in } - LegacyLifecycleStep.work("c") { _ in } - } - #expect(sequence.steps.map(\.id) == ["a", "b", "c"] as [AnyHashable]) - } - - @Test func builderSupportsConditionalInclusion() { - func ids(includeMiddle: Bool) -> [AnyHashable] { - LegacyLifecycleSteps { - LegacyLifecycleStep.work("a") { _ in } - if includeMiddle { - LegacyLifecycleStep.work("b") { _ in } - } - LegacyLifecycleStep.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 = LegacyLifecycleSteps { - for name in ["x", "y", "z"] { - LegacyLifecycleStep.work(name) { _ in } - } - } - #expect(sequence.steps.map(\.id) == ["x", "y", "z"] as [AnyHashable]) - } -} - -@MainActor -struct LifecycleStepConfigurationTests { - @Test func defaultStepAppliesToEveryReason() { - let step = LegacyLifecycleStep.work("a") { _ in } - #expect(step.appliesTo(.userForeground)) - #expect(step.appliesTo(.background(.location))) - } - - @Test func foregroundOnlyStepSkipsBackground() { - let step = LegacyLifecycleStep.work("a", modes: .foreground) { _ in } - #expect(step.appliesTo(.userForeground)) - #expect(!step.appliesTo(.background(.location))) - } - - @Test func backgroundOnlyStepSkipsForeground() { - let step = LegacyLifecycleStep.work("a", modes: .background) { _ in } - #expect(!step.appliesTo(.userForeground)) - #expect(step.appliesTo(.background(.remoteNotification))) - } - - @Test func defaultConditionIsTrue() async { - let step = LegacyLifecycleStep.work("a") { _ in } - #expect(await step.condition()) - } - - @Test func workConditionGatesTheStep() async { - let flag = MutableFlag() - let step = LegacyLifecycleStep.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 = LegacyLifecycleStep(id: "a", condition: { flag.isOn }) { _ in } - #expect(await step.condition() == false) - flag.isOn = true - #expect(await step.condition() == true) - } - - @Test func plainWorkHasNoPresentation() { - #expect(LegacyLifecycleStep.work("a") { _ in }.presentation == nil) - } - - @Test func presentingAttachesPresentation() { - let step = LegacyLifecycleStep.work("a") { _ in }.presenting { _ in Text("hi") } - #expect(step.presentation != nil) - } - - @Test func interactiveStepPresentsItsView() { - let step = LegacyLifecycleStep.interactive("onboarding") { _ in Text("onboarding") } - #expect(step.presentation != nil) - } -} - -/// A mutable reference the `@MainActor` condition closures can flip *after* -/// they've been captured. `LegacyLifecycleStep.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/LifecycleKit/Tests/LifecyclePhaseTests.swift b/Shared/LifecycleKit/Tests/LifecyclePhaseTests.swift deleted file mode 100644 index cd39fb90..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( - LegacyLifecycleStep.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( - LegacyLifecycleStep.work("first") { _ in }, - LifecycleStepUIBridge(reason: .userForeground), - ) - let second = LifecyclePhase.running( - LegacyLifecycleStep.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/LifecycleStepUIBridgeTests.swift b/Shared/LifecycleKit/Tests/LifecycleStepUIBridgeTests.swift deleted file mode 100644 index 9fc05788..00000000 --- a/Shared/LifecycleKit/Tests/LifecycleStepUIBridgeTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -@testable import LifecycleKit -import Testing - -private struct Boom: Error {} - -@MainActor -struct LifecycleStepUIBridgeTests { - @Test func completeResumesWaiter() async throws { - let bridge = LifecycleStepUIBridge(reason: .userForeground) - let waiter = Task { @MainActor in - try await bridge.waitForResolution() - return true - } - await Task.yield() - bridge.complete() - #expect(try await waiter.value) - } - - @Test func failThrowsFromWaiter() async { - let bridge = LifecycleStepUIBridge(reason: .userForeground) - let waiter = Task { @MainActor in - do { - try await bridge.waitForResolution() - return false - } catch is Boom { - return true - } catch { - return false - } - } - await Task.yield() - bridge.fail(Boom()) - #expect(await waiter.value) - } - - @Test func resolvingBeforeWaitingStillDelivers() async throws { - let bridge = LifecycleStepUIBridge(reason: .userForeground) - bridge.complete() - try await bridge.waitForResolution() - } - - @Test func failingBeforeWaitingStillThrows() async { - let bridge = LifecycleStepUIBridge(reason: .userForeground) - bridge.fail(Boom()) - await #expect(throws: Boom.self) { - try await bridge.waitForResolution() - } - } - - @Test func secondResolutionIsIgnored() async throws { - let bridge = LifecycleStepUIBridge(reason: .userForeground) - bridge.complete() - bridge.fail(Boom()) - try await bridge.waitForResolution() - } - - @Test func cancellingTheWaiterThrowsCancellationError() async { - let bridge = LifecycleStepUIBridge(reason: .userForeground) - let waiter = Task { @MainActor in - do { - try await bridge.waitForResolution() - return "resolved" - } catch is CancellationError { - return "cancelled" - } catch { - return "other" - } - } - await Task.yield() - waiter.cancel() - #expect(await waiter.value == "cancelled") - } -} diff --git a/Shared/LifecycleKit/Sources/LifecycleFailureView.swift b/Shared/LifecycleKitUI/Sources/LifecycleFailureView.swift similarity index 98% rename from Shared/LifecycleKit/Sources/LifecycleFailureView.swift rename to Shared/LifecycleKitUI/Sources/LifecycleFailureView.swift index fb5468af..44cf332a 100644 --- a/Shared/LifecycleKit/Sources/LifecycleFailureView.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleFailureView.swift @@ -1,3 +1,4 @@ +import LifecycleKit import SwiftUI /// The UI shown when a launch step throws. Describes the failure and offers a 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 100% rename from Shared/LifecycleKit/Sources/Resources/Localizable.xcstrings rename to Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings diff --git a/Where/AGENTS.md b/Where/AGENTS.md index ef683bbf..fe929edf 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 the `WhereLog` facade — a `"Where"` root `Log` scope with grouping scopes (`location`, `reminders`, `backup`, `widgets`, `session`, `evidence`, diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 94d53e3a..7bd65bc8 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -18,12 +18,14 @@ 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 the Liquid - Glass tab bar over the four top-level screens (Primary, Elsewhere, Resolve, - Settings). 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. +- **`RootView`** — the app root: the typed launch plan (via + [`LifecycleKit`](../../Shared/LifecycleKit), rendered by + [`LifecycleKitUI`](../../Shared/LifecycleKitUI)'s container) gated in front + of the Liquid Glass tab bar over the four top-level screens (Primary, + Elsewhere, Resolve, Settings); the tab bar 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()`). @@ -39,12 +41,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 From 8988ab28b3fd30a64a5421fdcd1eb1bd0c52d868 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 19:35:22 -0700 Subject: [PATCH 06/17] Fail fast when a teardown plan reuses launch node IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: the run-once memo is keyed by node ID across both the launch and teardown walks, so a teardown node that collides with a completed launch node was silently skipped — an erase that never runs — and, worse than the legacy engine, adopted the launch node's memoized output as the teardown trunk value, which a later teardown node's typed input cast would trap on. teardownErased() now preconditions that teardown node IDs are disjoint from the launch plan's before anything is torn down, matching the programmer-error convention (LaunchPlan already preconditions uniqueness within each plan; this closes the cross-plan gap). Where is unaffected: LaunchStepID keeps both sets disjoint by construction. Untestable as a death test on iOS (no exit tests); documented in the module README + AGENTS instead. --- Shared/LifecycleKit/AGENTS.md | 4 +++- Shared/LifecycleKit/README.md | 6 ++++++ .../Sources/LifecycleRunner.swift | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Shared/LifecycleKit/AGENTS.md b/Shared/LifecycleKit/AGENTS.md index bcc60707..88f0a63f 100644 --- a/Shared/LifecycleKit/AGENTS.md +++ b/Shared/LifecycleKit/AGENTS.md @@ -40,7 +40,9 @@ system, formatting, and global conventions. Read that first. `retry()` resumes the failed node with its original input and promotion never repeats completed work. Skipped gates are deliberately *not* memoized so they re-evaluate on promotion. Fresh attempts (first `run()`, - post-teardown relaunch) clear the memo. + post-teardown relaunch) clear the memo. The memo is keyed by node ID across + both walks, so teardown plans must not reuse launch node IDs — the runner + `precondition`s disjointness when a teardown is requested. - **Detached children are off the critical path by construction:** they never block `.ready`, never fail the drive, and surface failures only on `detachedFailures`. A successful teardown releases the retained teardown diff --git a/Shared/LifecycleKit/README.md b/Shared/LifecycleKit/README.md index fc2ed3b5..0f7b0627 100644 --- a/Shared/LifecycleKit/README.md +++ b/Shared/LifecycleKit/README.md @@ -201,6 +201,12 @@ relaunches. A teardown's detached children drain *before* the relaunch, so no torn-down-world work overlaps the fresh launch. On success the retained plan and input are released (the input is typically the dead session). +Teardown node IDs must not reuse launch node IDs — the run-once memo is keyed +by ID across both walks, so a collision would silently skip the teardown node +and corrupt the typed trunk value. The runner `precondition`s disjointness +when a teardown is requested (use one ID enum for both plans, as Where's +`LaunchStepID` does, and the collision is impossible to write twice). + ## Correctness points designed in deliberately - **Retry resumes with the same input.** Every completed node's output is diff --git a/Shared/LifecycleKit/Sources/LifecycleRunner.swift b/Shared/LifecycleKit/Sources/LifecycleRunner.swift index f39dabff..1126af2a 100644 --- a/Shared/LifecycleKit/Sources/LifecycleRunner.swift +++ b/Shared/LifecycleKit/Sources/LifecycleRunner.swift @@ -215,10 +215,29 @@ public final class LifecycleRunner { /// `teardown(_:input:)` after erasure — the `LifecycleDriving` seam the /// UI proxy forwards through (see `LifecycleDriving`). package func teardownErased(nodes: [LaunchPlanNode], input: any Sendable) async { + assertDisjointFromLaunchPlan(nodes) teardown = RetainedTeardown(nodes: nodes, input: input) await driveTeardown(from: 0, input: input) } + /// Teardown node IDs must not collide with the launch plan's: the + /// run-once memo is keyed by ID across both walks, so a colliding + /// teardown node would be skipped as "already done" — an erase that never + /// runs — *and* would adopt the launch node's memoized output as the + /// teardown trunk value, breaking the typed data flow (a later teardown + /// node's input cast would trap). A collision is a programmer error — + /// fail fast the first time the teardown is requested, before anything + /// is torn down. (`LaunchPlan` already preconditions uniqueness *within* + /// each plan; this closes the cross-plan gap.) + private func assertDisjointFromLaunchPlan(_ nodes: [LaunchPlanNode]) { + let launchIDs = Set(launchNodes.flatMap(\.ids)) + let collisions = nodes.flatMap(\.ids).filter(launchIDs.contains) + precondition( + collisions.isEmpty, + "Teardown plan reuses launch node IDs: \(collisions)", + ) + } + /// 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(_:input:)` and a From 51e29f9dabfd6377ce8d58b78e800f31dfad81ec Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 19:38:23 -0700 Subject: [PATCH 07/17] Mirror detached-step failures into WhereLog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: the runner surfaces detached-step failures only on its observable detachedFailures array, which nothing in Where renders or logs — so the first *throwing* detached step would fail with no trace in logs, violating the never-silently-swallow rule's 'logs, state, or both'. (Today's detached steps all wrap non-throwing WhereSession methods; this is the seam that keeps a future one honest.) - New DetachedFailureReporter (WhereUI/Launch): observes a runner's detachedFailures via withObservationTracking — UI-independent, so headless background drives are covered — and logs each new entry exactly once at warning level (degraded-but-handled), tolerating the runner's per-attempt resets. Kept alive by its own observation chain; holds the runner weakly. - WhereLaunchLog gains the detachedStepFailed(stepID:description:) event; WhereLaunch.makeLauncher installs the reporter on every runner it builds. - Tests: exactly-once and reset semantics on report(_:), plus an end-to-end observation test driving a real runner whose detached fan throws (no view tree involved). --- .../Launch/DetachedFailureReporter.swift | 77 +++++++++++++++ .../WhereUI/Sources/Launch/WhereLaunch.swift | 8 +- .../Sources/Logging/WhereLaunchLog.swift | 13 ++- .../Tests/DetachedFailureReporterTests.swift | 99 +++++++++++++++++++ 4 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 Where/WhereUI/Sources/Launch/DetachedFailureReporter.swift create mode 100644 Where/WhereUI/Tests/DetachedFailureReporterTests.swift 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 db3eaa0a..25e2212e 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -155,7 +155,7 @@ public enum WhereLaunch { ) -> LifecycleRunner { let bootstrap = WhereBootstrap() logger { .runnerCreated(reason: String(describing: reason)) } - return LifecycleRunner( + let runner = LifecycleRunner( reason: reason, initializePrerequisites: { bootstrap.prepareLocation() @@ -167,6 +167,12 @@ public enum WhereLaunch { 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 typed launch plan. The trunk mirrors the imperative 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/Tests/DetachedFailureReporterTests.swift b/Where/WhereUI/Tests/DetachedFailureReporterTests.swift new file mode 100644 index 00000000..5a7558f4 --- /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: AnyHashable = "root" + + func run(_: Void, _: LifecycleStepContext) async throws -> String { + "value" + } +} + +/// A detached child that always throws, landing on `detachedFailures`. +private struct ThrowingChildStep: LifecycleStep { + let id: AnyHashable + + 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 } + } +} From ae3b29567d39db6e185d4ab590f426a8fbe4bfe5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 22 Jul 2026 10:39:39 -0700 Subject: [PATCH 08/17] Fail an unregistered parked gate onto the failure surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding 5: a parked gate whose type had no GateView registration degraded to the splash in release — an indefinite spinner that reads as progress, with no retry and no trace. The realistic exposure is a conditionally-needed future gate (e.g. migration consent) that never parks during development and ships unregistered. The container now logs the misconfiguration (os, subsystem com.stuff.lifecyclekitui — the kit deliberately has no app logging facade) and fails the gate's handle with the new MissingGateViewError (localized description naming the gate), so the drive parks in .failed at the gate and the normal failure surface shows: visible, retryable, diagnosable from a screenshot. The debug assertionFailure is dropped so debug matches production — which also makes the path testable. The handle is failed from the fallback view's onAppear, not during body: resolving it resumes the drive's parked continuation, whose phase write must land after the render commits. Tests: new container test drives a parked, unregistered gate through the hosted fallback and asserts the .failed(at: gate) phase carries MissingGateViewError. Full Stuff-iOS-Tests scheme green (one rerun after a simulator 'failed preflight checks' launch flake, unrelated to the change); ./swiftformat --lint clean. --- Shared/LifecycleKitUI/AGENTS.md | 7 ++-- Shared/LifecycleKitUI/README.md | 5 +-- Shared/LifecycleKitUI/Sources/GateView.swift | 19 ++++++++++ .../Sources/LifecycleContainer.swift | 29 +++++++++++---- .../Sources/Resources/Localizable.xcstrings | 11 ++++++ .../Tests/LifecycleContainerTests.swift | 36 +++++++++++++++++++ 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/Shared/LifecycleKitUI/AGENTS.md b/Shared/LifecycleKitUI/AGENTS.md index a921907b..53496bd5 100644 --- a/Shared/LifecycleKitUI/AGENTS.md +++ b/Shared/LifecycleKitUI/AGENTS.md @@ -27,8 +27,11 @@ build system, formatting, and global conventions. Read that first. 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 (debug assert, splash - fallback in release). + 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 + retryable, identical in debug and release) rather than an indefinite + splash. ## Testing diff --git a/Shared/LifecycleKitUI/README.md b/Shared/LifecycleKitUI/README.md index fc020a5e..11f372be 100644 --- a/Shared/LifecycleKitUI/README.md +++ b/Shared/LifecycleKitUI/README.md @@ -46,8 +46,9 @@ LifecycleContainer( - **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 asserts in debug and falls back to the splash in - release. + with no registration is logged and failed with `MissingGateViewError`, so + the launch lands on the failure surface — visible and retryable — 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 diff --git a/Shared/LifecycleKitUI/Sources/GateView.swift b/Shared/LifecycleKitUI/Sources/GateView.swift index 18d7d270..e3fc1f48 100644 --- a/Shared/LifecycleKitUI/Sources/GateView.swift +++ b/Shared/LifecycleKitUI/Sources/GateView.swift @@ -42,6 +42,25 @@ public struct GateRegistration { 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, retryable, named) 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: "failure.gate.unregistered \(String(describing: gateID))", + bundle: .module, + ) + } +} + /// Result builder for `LifecycleContainer`'s `gates:` parameter, with /// `if`/`if-else`/`for` support so registrations can be included /// conditionally. diff --git a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift index 3ad6046b..d6e3f645 100644 --- a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift @@ -1,4 +1,5 @@ import LifecycleKit +import os import SwiftUI /// The root view that renders a `LifecycleRunner`'s `phase`. @@ -133,8 +134,15 @@ public struct LifecycleContainer< /// 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); debug-assert and fall back to the splash so a - /// release build degrades to a wait rather than crashing. + /// 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, retryable, and named. 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, @@ -143,12 +151,21 @@ public struct LifecycleContainer< { registration.build(handle, value) } else { - let _ = assertionFailure( - "No gate view registered for gate '\(handle.id)' — add a GateView(for:) entry.", - ) - splash(nil) + 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 { diff --git a/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings b/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings index c6799027..86c31028 100644 --- a/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings +++ b/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings @@ -1,6 +1,17 @@ { "sourceLanguage" : "en", "strings" : { + "failure.gate.unregistered %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This step's screen isn't available. (%@)" + } + } + } + }, "failure.launch.retry" : { "extractionState" : "manual", "localizations" : { diff --git a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift index c2d23329..9ff4751d 100644 --- a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift +++ b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift @@ -197,6 +197,42 @@ struct LifecycleContainerTests { #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, retryable 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 From 286590a0ba384b826a07217dd90a9de33d722589 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 24 Jul 2026 16:14:35 -0700 Subject: [PATCH 09/17] Prototype D: #116's declarative engine with retry removed (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Prototype D: remove retry from the combinator engine, keep the declarative API Explores #116 (the typed LaunchPlan) minus retry, keeping the declarative surface that is the mark of that PR — LaunchPlan, the then/thenKeeping/gate/detached combinators, the LifecycleStep/ LifecycleGate protocols, nodeIDs, and the producing-step modes precondition are all untouched. Retry was the only thing that resumed a drive, so removing it deletes a cluster of runner machinery, not just a method: - Deleted the FailedResume struct + failedResume var (its only job was picking the resume site/index), the from-startIndex parameter threaded through drive/runLaunchPlan/walk (a re-drive now always walks from 0), the RetainedTeardown struct + retention (teardown runs inline once), and the injectFailureForTesting SPI. - The walk's site param goes too — it only tagged FailedResume; failures now just park .failed at the node's id. - The finding-1 cross-plan disjointness precondition (assertDisjointFromLaunchPlan) is gone: teardown clears the memo at its start (the launch attempt is over), so a teardown node may freely reuse a launch id — no shared-live-memo collision, because there is no retry re-walk to consult it. The single memo stays; it's promotion's requirement. - .failed is terminal: LifecycleFailureView loses its button (and the failure.launch.retry string), the container's failure closure drops its retry action, and LifecycleProxy + LifecycleDriving drop retry(). - Teardown-failure safety is preserved without retry: a thrown erase never reaches the session drop, so relaunching returns to the working app rather than a half-erased one. Tests: dropped the retry/resume unit tests, the injected-failure test, and the 120-seed retry-drain fuzz; kept the 200-seed model-parity fuzz. Added a terminal-failure test (a second run() doesn't re-drive), a teardown-reuses-launch-ids test (proving the precondition is unnecessary now), and reshaped the promotion run-once test off retry. UI/proxy tests updated for the terminal surface + enterForeground forwarding. Full Stuff-iOS-Tests scheme green; swiftformat --lint clean. * Prototype D docs: launch failure is terminal, no retry LifecycleKit README/AGENTS: retry removed from the API and lifecycle; the memo is reframed as promotion's requirement; failure (launch and teardown) is terminal; teardown starts from an empty run-once set so it may reuse launch node IDs (the finding-1 disjointness precondition is gone). LifecycleKitUI README/AGENTS: terminal failure surface, LifecycleFailureView loses its button, the proxy forwards only enterForeground/teardown. ./sync-agents run. --- Shared/LifecycleKit/AGENTS.md | 21 +- Shared/LifecycleKit/README.md | 55 ++--- .../Sources/LifecycleDriving.swift | 3 - .../Sources/LifecycleFailure.swift | 4 +- .../Sources/LifecycleRunner.swift | 206 +++++------------- .../Tests/LifecycleRunnerFuzzTests.swift | 49 ----- .../Tests/LifecycleRunnerResetTests.swift | 63 +----- .../Tests/LifecycleRunnerTests.swift | 84 ++----- Shared/LifecycleKitUI/AGENTS.md | 5 +- Shared/LifecycleKitUI/README.md | 14 +- Shared/LifecycleKitUI/Sources/GateView.swift | 2 +- .../Sources/LifecycleContainer.swift | 20 +- .../Sources/LifecycleFailureView.swift | 13 +- .../Sources/LifecycleProxy.swift | 11 +- .../Sources/Resources/Localizable.xcstrings | 11 - .../Tests/LifecycleContainerTests.swift | 6 +- .../Tests/LifecycleProxyTests.swift | 22 +- Where/WhereUI/Sources/RootView.swift | 2 +- 18 files changed, 171 insertions(+), 420 deletions(-) diff --git a/Shared/LifecycleKit/AGENTS.md b/Shared/LifecycleKit/AGENTS.md index 88f0a63f..f1097e69 100644 --- a/Shared/LifecycleKit/AGENTS.md +++ b/Shared/LifecycleKit/AGENTS.md @@ -30,23 +30,26 @@ system, formatting, and global conventions. Read that first. 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 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, per attempt.** Completed nodes' outputs are memoized; - `retry()` resumes the failed node with its original input and promotion - never repeats completed work. Skipped gates are deliberately *not* - memoized so they re-evaluate on promotion. Fresh attempts (first `run()`, - post-teardown relaunch) clear the memo. The memo is keyed by node ID across - both walks, so teardown plans must not reuse launch node IDs — the runner - `precondition`s disjointness when a teardown is requested. +- **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`. A successful teardown releases the retained teardown - plan + input (the input is typically the dead session). + `detachedFailures`. - **Promotion is foreground-only and idempotent.** `enterForeground()` no-ops for a foreground launch; consumers must only call it once the scene is genuinely `.active` (see `RootView` in WhereUI for the `scenePhase` gating diff --git a/Shared/LifecycleKit/README.md b/Shared/LifecycleKit/README.md index 0f7b0627..05e58a57 100644 --- a/Shared/LifecycleKit/README.md +++ b/Shared/LifecycleKit/README.md @@ -10,8 +10,9 @@ 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 failure phase with retry; logout/erase is the same -machinery run over a teardown plan. +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). @@ -94,7 +95,7 @@ Add it to a target's dependencies in [`Package.swift`](../../Package.swift): case launching // splash case running(LifecycleStepContext) // splash + caption/progress case awaitingGate(LifecycleGateHandle) // the gate's registered view - case failed(LifecycleFailure) // failure UI + retry + case failed(LifecycleFailure) // terminal failure UI (no retry) case ready(Launch) // the app, handed the launch's output } public private(set) var phase: Phase @@ -103,7 +104,6 @@ Add it to a target's dependencies in [`Package.swift`](../../Package.swift): initializePrerequisites: @MainActor () -> Void = {}, plan: LaunchPlan) public func run() async // walk the plan; idempotent - public func retry() // resume from the failed node, same input public func enterForeground() async // promote a background/undetermined launch public func teardown(_ plan: LaunchPlan, input: In) async } @@ -195,25 +195,27 @@ await runner.teardown( ) ``` -If a teardown node throws, the runner parks in `.failed` and does **not** -relaunch; `retry()` resumes the teardown from the failed node, then -relaunches. A teardown's detached children drain *before* the relaunch, so no -torn-down-world work overlaps the fresh launch. On success the retained plan -and input are released (the input is typically the dead session). +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 node IDs must not reuse launch node IDs — the run-once memo is keyed -by ID across both walks, so a collision would silently skip the teardown node -and corrupt the typed trunk value. The runner `precondition`s disjointness -when a teardown is requested (use one ID enum for both plans, as Where's -`LaunchStepID` does, and the collision is impossible to write twice). +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 -- **Retry resumes with the same input.** Every completed node's output is - memoized for the current attempt; `retry()` re-runs the failed node with - the value it originally got, and nodes that already completed never run - twice — across `retry()` *and* `enterForeground()` promotion. A fresh - attempt (first `run()`, the relaunch after a teardown) clears the memo. +- **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 @@ -222,8 +224,8 @@ when a teardown is requested (use one ID enum for both plans, as Where's - **`.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` / `retry` - / `teardown`) serialize through a single internal task; a new drive cancels +- **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 @@ -241,10 +243,9 @@ when a teardown is requested (use one ID enum for both plans, as Where's ## Testing The engine is exercised with Swift Testing: targeted suites for ordering, -value threading, mode gating, gates, detached isolation, promotion, retry -memoization, and teardown — plus seeded fuzz suites that drive randomized -plans against an independent model (200 seeds) and drain randomized flaky -failures through `retry()` (120 seeds). Because a real gate suspends, drive +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. `@_spi(Testing) injectFailureForTesting(_:)` covers the -failed-with-no-resume-point path. +the handle. diff --git a/Shared/LifecycleKit/Sources/LifecycleDriving.swift b/Shared/LifecycleKit/Sources/LifecycleDriving.swift index 20d10936..1ec6c087 100644 --- a/Shared/LifecycleKit/Sources/LifecycleDriving.swift +++ b/Shared/LifecycleKit/Sources/LifecycleDriving.swift @@ -7,9 +7,6 @@ /// them) and forwards through this seam. @MainActor package protocol LifecycleDriving: AnyObject, Sendable { - /// See `LifecycleRunner.retry()`. - func retry() - /// See `LifecycleRunner.enterForeground()`. func enterForeground() async diff --git a/Shared/LifecycleKit/Sources/LifecycleFailure.swift b/Shared/LifecycleKit/Sources/LifecycleFailure.swift index 5387f47d..c8874dda 100644 --- a/Shared/LifecycleKit/Sources/LifecycleFailure.swift +++ b/Shared/LifecycleKit/Sources/LifecycleFailure.swift @@ -1,5 +1,5 @@ -/// Carries which node failed and why, so the failure UI can describe it and -/// the runner can retry from that node. +/// 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 diff --git a/Shared/LifecycleKit/Sources/LifecycleRunner.swift b/Shared/LifecycleKit/Sources/LifecycleRunner.swift index 1126af2a..b1c56941 100644 --- a/Shared/LifecycleKit/Sources/LifecycleRunner.swift +++ b/Shared/LifecycleKit/Sources/LifecycleRunner.swift @@ -12,19 +12,21 @@ import Observation /// 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`; `retry()` -/// resumes from the node that failed, feeding it the same memoized input. +/// 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` or `.undetermined`) once a window actually /// appears, re-driving the plan so the now-applicable foreground-only -/// nodes (gates, foreground work) run. +/// 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 gate's `waitForResolution()` throws +/// 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 @@ -40,7 +42,8 @@ public final class LifecycleRunner { case running(LifecycleStepContext) /// A gate parked the trunk; the UI resolves the handle to continue. case awaitingGate(LifecycleGateHandle) - /// A trunk node threw. The host shows an error UI offering retry. + /// 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. @@ -88,46 +91,19 @@ public final class LifecycleRunner { @ObservationIgnored private let launchNodes: [LaunchPlanNode] - /// The most recent teardown plan (erased) and its root input, retained so - /// a `retry()` after a thrown teardown node resumes the teardown (and the - /// relaunch that follows) rather than re-driving the launch plan over - /// un-torn-down state. Nil until the first `teardown(_:input:)`. - private struct RetainedTeardown { - var nodes: [LaunchPlanNode] - var input: any Sendable - } - - @ObservationIgnored private var teardown: RetainedTeardown? - /// The output of every node that ran to completion during the current - /// launch attempt, keyed by node ID — both the run-once set (a re-drive - /// doesn't repeat completed work) and the value store that lets a retry - /// resume mid-trunk with the input its node originally got. + /// 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 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()`, and the relaunch after a teardown) so a - /// reset genuinely re-runs everything; preserved across - /// `enterForeground()` and `retry()`, which continue the same attempt. + /// 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] = [:] - /// Where a trunk failure parked, so `retry()` can resume the same walk - /// with the same input. One value: a resume point always carries the - /// site it belongs to and the input its node needs. - private struct FailedResume { - enum Site { - case launch - case teardown - } - - var site: Site - var index: Int - var input: any Sendable - } - - @ObservationIgnored private var failedResume: FailedResume? - public init( reason: LifecycleReason, initializePrerequisites: @MainActor () -> Void = {}, @@ -155,7 +131,7 @@ public final class LifecycleRunner { // A fresh attempt starts with a clean slate; nothing has run yet. memo.removeAll() detachedFailures.removeAll() - await drive(reason: reason, from: 0, input: ()) + await drive(reason: reason) case let .running(_, task): await task.value } @@ -173,26 +149,7 @@ public final class LifecycleRunner { /// isn't run a second time. public func enterForeground() async { guard reason != .userForeground else { return } - await drive(reason: .userForeground, from: 0, input: ()) - } - - /// Resume from the node that failed, feeding it the same input it - /// originally got. No-op unless the runner is currently in `.failed`. - /// - /// If the failure was in a teardown node (from `teardown(_:input:)`), the - /// teardown is resumed from that node 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 plan is resumed from - /// the failed node. - public func retry() { - guard case .failed = phase, let resume = failedResume else { return } - switch resume.site { - case .launch: - let reason = reason - Task { await drive(reason: reason, from: resume.index, input: resume.input) } - case .teardown: - Task { await driveTeardown(from: resume.index, input: resume.input) } - } + await drive(reason: .userForeground) } /// Run a teardown `plan` rooted at `input` (logout / erase), then @@ -200,11 +157,12 @@ public final class LifecycleRunner { /// first-run onboarding shows again once the teardown clears the "has /// onboarded" flag. /// - /// The plan and input are retained so a `retry()` after a thrown teardown - /// node resumes the teardown (then the relaunch). If a teardown node - /// throws, the runner parks in `.failed` and does not relaunch. The - /// teardown's detached children (if any) are drained *before* the - /// relaunch begins, so no torn-down-world work overlaps the fresh launch. + /// 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, @@ -213,90 +171,51 @@ public final class LifecycleRunner { } /// `teardown(_:input:)` after erasure — the `LifecycleDriving` seam the - /// UI proxy forwards through (see `LifecycleDriving`). + /// 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 { - assertDisjointFromLaunchPlan(nodes) - teardown = RetainedTeardown(nodes: nodes, input: input) - await driveTeardown(from: 0, input: input) - } - - /// Teardown node IDs must not collide with the launch plan's: the - /// run-once memo is keyed by ID across both walks, so a colliding - /// teardown node would be skipped as "already done" — an erase that never - /// runs — *and* would adopt the launch node's memoized output as the - /// teardown trunk value, breaking the typed data flow (a later teardown - /// node's input cast would trap). A collision is a programmer error — - /// fail fast the first time the teardown is requested, before anything - /// is torn down. (`LaunchPlan` already preconditions uniqueness *within* - /// each plan; this closes the cross-plan gap.) - private func assertDisjointFromLaunchPlan(_ nodes: [LaunchPlanNode]) { - let launchIDs = Set(launchNodes.flatMap(\.ids)) - let collisions = nodes.flatMap(\.ids).filter(launchIDs.contains) - precondition( - collisions.isEmpty, - "Teardown plan reuses launch node IDs: \(collisions)", - ) - } - - /// 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(_:input:)` and a - /// `retry()` resuming a failed teardown node, so the two stay in lockstep. - private func driveTeardown(from startIndex: Int, input: any Sendable) async { - guard let teardown else { return } let previous = currentTask previous?.cancel() let reason = reason phase = .launching - failedResume = nil let task = Task { [weak self] in guard let self else { return } await previous?.value + // 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( - teardown.nodes, - from: startIndex, - input: input, - site: .teardown, - group: &group, - ) + 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 teardown succeeded, so no retry will need it again: release - // the retained plan and — importantly — its input, which is - // typically the very object being torn down (retaining a dead - // session past its teardown would leak it for the process's life). - self.teardown = nil - // The relaunch is a fresh attempt: clear the memo so every launch - // node re-runs over the torn-down state rather than being skipped - // as "already done" from before the teardown. + // 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(from: 0, input: ()) + await runLaunchPlan() } state = .running(reason: reason, task: task) await task.value } - /// Cancel any in-flight drive, then drive the launch plan from - /// `startIndex` 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, - from startIndex: Int, - input: any Sendable, - ) 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 - failedResume = nil let task = Task { [weak self] in guard let self else { return } await previous?.value - await runLaunchPlan(from: startIndex, input: input) + await runLaunchPlan() } state = .running(reason: newReason, task: task) await task.value @@ -306,45 +225,37 @@ public final class LifecycleRunner { /// 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(from startIndex: Int, input: any Sendable) async { + private func runLaunchPlan() async { await withDiscardingTaskGroup { group in - let outcome = await self.walk( - self.launchNodes, - from: startIndex, - input: input, - site: .launch, - group: &group, - ) + 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 from some node onward. + /// 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` - /// and `failedResume` points at the node. + /// (terminally). case failed /// The drive was superseded (cancelled); `phase` is left for the /// drive that cancelled it to set. case cancelled } - /// Walk `nodes` from `startIndex`, threading the trunk value through - /// steps and gates, spawning detached children into `group`, and honoring - /// the memoized run-once set. + /// 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], - from startIndex: Int, input: any Sendable, - site: FailedResume.Site, group: inout DiscardingTaskGroup, ) async -> WalkOutcome { var value = input - var index = startIndex + var index = 0 while index < nodes.count { if Task.isCancelled { return .cancelled } @@ -375,10 +286,8 @@ public final class LifecycleRunner { // 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. + // state with `.failed`. guard !Task.isCancelled else { return .cancelled } - failedResume = FailedResume(site: site, index: index, input: value) phase = .failed(LifecycleFailure(stepID: node.id, error: error)) return .failed } @@ -400,7 +309,6 @@ public final class LifecycleRunner { return .cancelled } catch { guard !Task.isCancelled else { return .cancelled } - failedResume = FailedResume(site: site, index: index, input: value) phase = .failed(LifecycleFailure(stepID: node.id, error: error)) return .failed } @@ -450,16 +358,6 @@ public final class LifecycleRunner { } } -#if DEBUG - extension LifecycleRunner { - /// Injects a failure for SPI-enabled tests. Carries no resume point, - /// so a subsequent `retry()` is a no-op. - @_spi(Testing) public func injectFailureForTesting(_ failure: LifecycleFailure) { - phase = .failed(failure) - } - } -#endif - // MARK: - Phase inspection @MainActor diff --git a/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift b/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift index b1312751..48990a7f 100644 --- a/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift +++ b/Shared/LifecycleKit/Tests/LifecycleRunnerFuzzTests.swift @@ -121,53 +121,4 @@ struct LifecycleRunnerFuzzTests { #expect(runner.phase.readyValue == "value") } } - - /// Trunk steps that throw their first N attempts then succeed: driving - /// `retry()` resumes from the failed node each time (with its memoized - /// input — earlier nodes never re-run) and, after exactly the number of - /// injected failures, drains to `.ready` with every node 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] = [] - var plan = LaunchPlan(FixtureStep("root") { _, _ in "value" }) - for index in 0 ..< count { - plan = plan.thenKeeping(FixtureStep(ids[index]) { _, _ in - attempts[index] += 1 - if attempts[index] <= failuresBeforeSuccess[index] { throw FuzzError() } - succeeded.append(ids[index]) - }) - } - let runner = LifecycleRunner(reason: .userForeground, plan: plan) - - 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) - } - } } diff --git a/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift b/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift index 81d8513c..ed722044 100644 --- a/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift +++ b/Shared/LifecycleKit/Tests/LifecycleRunnerResetTests.swift @@ -117,67 +117,28 @@ struct LifecycleRunnerResetTests { #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, - plan: LaunchPlan(FixtureStep("launch") { _, _ in events.append("launch") }), - ) - await runner.run() - events.removeAll() - - await runner.teardown( - LaunchPlan(FixtureStep("erase") { _, _ in - events.append("erase") - if shouldFailErase { throw ResetError() } - }) - .thenKeeping(FixtureStep("clear-prefs") { _, _ in - events.append("clear-prefs") + plan: LaunchPlan(FixtureStep("shared-id") { _, _ in + events.append("launch-side") }), - input: (), - ) - #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 plan over un-torn-down state. - shouldFailErase = false - events.removeAll() - runner.retry() - try await waitUntil { runner.phase.isReady } - #expect(events == ["erase", "clear-prefs", "launch"]) - } - - @Test func retryAfterFailedTeardownTailResumesWithoutReErasing() async throws { - var events: [String] = [] - var shouldFailPrefs = true - 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") }) - .thenKeeping(FixtureStep("clear-prefs") { _, _ in - events.append("clear-prefs") - if shouldFailPrefs { throw ResetError() } - }), + LaunchPlan(FixtureStep("shared-id") { _, _ in + events.append("teardown-side") + }), input: (), ) - #expect(runner.phase.failed(at: "clear-prefs")) - #expect(events == ["erase", "clear-prefs"]) - - // The earlier teardown node ("erase") already succeeded, so retry - // resumes from the failed node rather than re-running it, then - // relaunches. - shouldFailPrefs = false - events.removeAll() - runner.retry() - try await waitUntil { runner.phase.isReady } - #expect(events == ["clear-prefs", "launch"]) + #expect(events == ["launch-side", "teardown-side", "launch-side"]) + #expect(runner.phase.isReady) } @Test func teardownCancelsAParkedGateInsteadOfHanging() async throws { diff --git a/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift b/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift index 3e020b5f..24c1a109 100644 --- a/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift +++ b/Shared/LifecycleKit/Tests/LifecycleRunnerTests.swift @@ -308,13 +308,12 @@ struct LifecycleRunnerForegroundPromotionTests { #expect(runner.phase.isReady) } - @Test func retryAfterPromotionSkipsANodeAlreadyCompletedInTheHeadlessDrive() async throws { - // Run-once spans a promotion + a `retry()` within the same attempt: a - // later node that already completed during the headless drive must not - // re-run when `retry()` resumes from an *earlier* foreground-only node - // that failed on promotion. + @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, plan: LaunchPlan(FixtureStep("store") { _, _ in @@ -323,7 +322,6 @@ struct LifecycleRunnerForegroundPromotionTests { }) .thenKeeping(FixtureStep("onboarding", modes: .foreground) { _, _ in executed.append("onboarding") - if onboardingShouldFail { throw StepError() } }) .thenKeeping(FixtureStep("widget") { _, _ in executed.append("widget") @@ -336,19 +334,11 @@ struct LifecycleRunnerForegroundPromotionTests { #expect(executed == ["store", "widget"]) #expect(runner.phase.isReady) - // Promotion re-drives from the top: "store" is skipped (memoized), 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 { @@ -462,58 +452,26 @@ struct LifecycleRunnerFailureTests { #expect(runner.phase.failure?.error is StepError) } - @Test func retryResumesFromTheFailedNodeWithItsMemoizedInput() async throws { - var rootRuns = 0 - var received: [Int] = [] - var shouldFail = true + @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("root") { _, _ in - rootRuns += 1 - return 42 - }) - .then(FixtureStep("flaky") { value, _ in - received.append(value) - if shouldFail { throw StepError() } - return value + 1 + plan: LaunchPlan(FixtureStep("boom") { _, _ in + attempts += 1 + throw StepError() }), ) await runner.run() - #expect(runner.phase.failed(at: "flaky")) - - shouldFail = false - runner.retry() - try await waitUntil { runner.phase.isReady } - // The root ran once; the retried node got the same memoized input. - #expect(rootRuns == 1) - #expect(received == [42, 42]) - #expect(runner.phase.readyValue == 43) - } + #expect(runner.phase.failed(at: "boom")) + #expect(attempts == 1) - @Test func retryIsNoOpWhenNotFailed() async { - let runner = LifecycleRunner( - reason: .userForeground, - plan: LaunchPlan(FixtureStep("a") { _, _ in }), - ) await runner.run() - runner.retry() - #expect(runner.phase.isReady) - } - - @Test func retryIsNoOpForAnInjectedFailureWithNoResumePoint() async { - var executed = 0 - let runner = LifecycleRunner( - reason: .userForeground, - plan: LaunchPlan(FixtureStep("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) + #expect(runner.phase.failed(at: "boom")) + #expect(attempts == 1) } } diff --git a/Shared/LifecycleKitUI/AGENTS.md b/Shared/LifecycleKitUI/AGENTS.md index 53496bd5..6cf10d5e 100644 --- a/Shared/LifecycleKitUI/AGENTS.md +++ b/Shared/LifecycleKitUI/AGENTS.md @@ -4,7 +4,8 @@ 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 -`retry()`/`teardown(_:input:)`. See [`README.md`](README.md) for the full +`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 @@ -30,7 +31,7 @@ build system, formatting, and global conventions. Read that first. 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 - retryable, identical in debug and release) rather than an indefinite + named (though terminal), identical in debug and release) rather than an indefinite splash. ## Testing diff --git a/Shared/LifecycleKitUI/README.md b/Shared/LifecycleKitUI/README.md index 11f372be..cdbdfb57 100644 --- a/Shared/LifecycleKitUI/README.md +++ b/Shared/LifecycleKitUI/README.md @@ -17,8 +17,8 @@ LifecycleContainer( splash: { context in LaunchSplashView(caption: context?.message) }, - failure: { failure, retry in - LifecycleFailureView(failure: failure, retry: retry) + failure: { failure in + LifecycleFailureView(failure: failure) // terminal — no retry }, gates: { GateView(for: OnboardingGate.self) { handle, session in @@ -37,7 +37,7 @@ LifecycleContainer( | `.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, retry)` | +| `.failed(failure)` | `failure(failure)` — terminal, no retry | | `.ready(value)` | `content(value)` | - **`content` receives the launch's output.** `.ready` carries the trunk's @@ -47,8 +47,8 @@ LifecycleContainer( 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 failure surface — visible and retryable — instead - of an indefinite splash; debug and release behave identically. + 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 @@ -64,7 +64,7 @@ LifecycleContainer( `LifecycleContainer` publishes a `LifecycleProxy` under `@Environment(\.lifecycle)`. The proxy is non-generic (environment values -must be), forwards `retry()` / `enterForeground()` / `teardown(_:input:)`, +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. @@ -77,5 +77,5 @@ await lifecycle.teardown(WhereLaunch.resetPlan(for: model), input: session) ## Defaults `LifecycleSplash` (a plain centered spinner) and `LifecycleFailureView` (a -`ContentUnavailableView` with a retry button) are used by the convenience +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 index e3fc1f48..70c17feb 100644 --- a/Shared/LifecycleKitUI/Sources/GateView.swift +++ b/Shared/LifecycleKitUI/Sources/GateView.swift @@ -45,7 +45,7 @@ public struct GateRegistration { /// 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, retryable, named) instead of an indefinite +/// 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 { diff --git a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift index d6e3f645..52c4a57b 100644 --- a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift @@ -18,7 +18,8 @@ import SwiftUI /// 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 `retry()`/`teardown(_:input:)`. +/// 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 @@ -44,7 +45,7 @@ public struct LifecycleContainer< private let transition: AnyTransition private let animation: Animation? private let splash: (LifecycleStepContext?) -> Splash - private let failureView: (LifecycleFailure, @escaping () -> Void) -> Failure + private let failureView: (LifecycleFailure) -> Failure private let gates: [GateRegistration] private let content: (Launch) -> Content @@ -54,7 +55,8 @@ public struct LifecycleContainer< /// surfaces instantly (no animation). /// - splash: the waiting surface; receives the running step's context /// (nil between steps) so it can show a caption/progress. - /// - failure: the error surface, given the failure and a retry action. + /// - 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( @@ -62,7 +64,7 @@ public struct LifecycleContainer< transition: AnyTransition = .opacity, animation: Animation? = .default, @ViewBuilder splash: @escaping (LifecycleStepContext?) -> Splash, - @ViewBuilder failure: @escaping (LifecycleFailure, @escaping () -> Void) -> Failure, + @ViewBuilder failure: @escaping (LifecycleFailure) -> Failure, @GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] }, @ViewBuilder content: @escaping (Launch) -> Content, ) { @@ -124,7 +126,7 @@ public struct LifecycleContainer< case let .awaitingGate(handle): gateView(for: handle).transition(transition).zIndex(Self.launchSurfaceZIndex) case let .failed(failure): - failureView(failure) { runner.retry() } + failureView(failure) .transition(transition) .zIndex(Self.launchSurfaceZIndex) case let .ready(value): @@ -136,8 +138,8 @@ public struct LifecycleContainer< /// 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, retryable, and named. Identical in - /// debug and release, which also keeps the path testable. + /// 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 @@ -178,7 +180,7 @@ extension LifecycleContainer where Splash == LifecycleSplash, Failure == Lifecyc self.init( runner, splash: { _ in LifecycleSplash() }, - failure: { LifecycleFailureView(failure: $0, retry: $1) }, + failure: { LifecycleFailureView(failure: $0) }, gates: gates, content: content, ) @@ -197,7 +199,7 @@ extension LifecycleContainer where Failure == LifecycleFailureView { self.init( runner, splash: splash, - failure: { LifecycleFailureView(failure: $0, retry: $1) }, + failure: { LifecycleFailureView(failure: $0) }, gates: gates, content: content, ) diff --git a/Shared/LifecycleKitUI/Sources/LifecycleFailureView.swift b/Shared/LifecycleKitUI/Sources/LifecycleFailureView.swift index 44cf332a..d146fa6d 100644 --- a/Shared/LifecycleKitUI/Sources/LifecycleFailureView.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleFailureView.swift @@ -1,15 +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 { @@ -20,9 +18,6 @@ public struct LifecycleFailureView: View { ) } description: { Text(failure.error.localizedDescription) - } actions: { - Button(String(localized: "failure.launch.retry", bundle: .module), action: retry) - .buttonStyle(.borderedProminent) } } } @@ -34,6 +29,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 index ea8b302c..c98b6e21 100644 --- a/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift @@ -3,9 +3,8 @@ 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(_:input:)` - /// without prop-drilling. + /// `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 @@ -43,12 +42,6 @@ public struct LifecycleProxy: Sendable { base = runner } - /// Resume a failed launch from the node that failed. - /// See `LifecycleRunner.retry()`. - @MainActor public func retry(file: StaticString = #fileID, line: UInt = #line) { - connected(file: file, line: line)?.retry() - } - /// Promote a headless launch to the foreground. /// See `LifecycleRunner.enterForeground()`. @MainActor public func enterForeground(file: StaticString = #fileID, line: UInt = #line) async { diff --git a/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings b/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings index 86c31028..e20ead6a 100644 --- a/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings +++ b/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings @@ -12,17 +12,6 @@ } } }, - "failure.launch.retry" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Try Again" - } - } - } - }, "failure.launch.title" : { "extractionState" : "manual", "localizations" : { diff --git a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift index 9ff4751d..9a934d04 100644 --- a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift +++ b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift @@ -200,7 +200,7 @@ struct LifecycleContainerTests { @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, retryable failure surface (rendering of `.failed` is + // 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 @@ -247,7 +247,7 @@ struct LifecycleContainerTests { let container = LifecycleContainer( runner, splash: { _ in ProbeView { splash = true } }, - failure: { _, _ in ProbeView { failure = true } }, + failure: { _ in ProbeView { failure = true } }, ) { _ in ProbeView { content = true } } @@ -284,7 +284,7 @@ struct LifecycleContainerTests { transition: .scale.combined(with: .opacity), animation: .easeInOut, splash: { _ in ProbeView { splash = true } }, - failure: { _, _ in EmptyView() }, + failure: { _ in EmptyView() }, ) { _ in ProbeView { content = true } } diff --git a/Shared/LifecycleKitUI/Tests/LifecycleProxyTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleProxyTests.swift index 49590065..75c6fb61 100644 --- a/Shared/LifecycleKitUI/Tests/LifecycleProxyTests.swift +++ b/Shared/LifecycleKitUI/Tests/LifecycleProxyTests.swift @@ -31,21 +31,23 @@ struct LifecycleProxyTests { #expect(runner.phase.isReady) } - @Test func connectedProxyForwardsRetryToTheRunner() async throws { - struct Boom: Error {} - var shouldFail = true + @Test func connectedProxyForwardsEnterForegroundToTheRunner() async { + var executed: [String] = [] let runner = LifecycleRunner( - reason: .userForeground, - plan: LaunchPlan(FixtureStep("open") { _, _ in - if shouldFail { throw Boom() } + 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(runner.phase.failed(at: "open")) + #expect(executed == ["store"]) - shouldFail = false - LifecycleProxy(runner).retry() - try await waitUntil { runner.phase.isReady } + await LifecycleProxy(runner).enterForeground() + #expect(executed == ["store", "foreground-only"]) + #expect(runner.phase.isReady) } } diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index c2824df7..e7a33b06 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -62,7 +62,7 @@ public struct RootView: View { transition: revealTransition, animation: revealAnimation, splash: { _ in LaunchSplashView() }, - failure: { LifecycleFailureView(failure: $0, retry: $1) }, + failure: { LifecycleFailureView(failure: $0) }, gates: { // The gate passes the trunk's session through, so // onboarding is handed the session it commits regions From 5f1722256e9830a4823de82672b006e403c7c76d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 24 Jul 2026 16:58:33 -0700 Subject: [PATCH 10/17] Simplify the merge resolution: collapse the splash-hold state, dedup helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up review of the hand-written merge resolution (three parallel read-only reviews). Behavior-preserving cleanups only: - LifecycleContainer: the splash hold was two @State values encoding one state machine (`splashAppearedAt: Instant?` + `minimumSplashElapsed`, with `(nil, true)` representable and meaningless) plus a three-clause `canRevealReady`. Collapsed to one `splashHoldUntil: Instant?` deadline — the single fact the feature is about — so `canRevealReady` is a nil check and the remaining-time arithmetic becomes `Task.sleep(until:clock:)`, which also stops skipping the cancellation check on the already-elapsed path. - Removed the private `isReadyPhase`, which reimplemented the engine's public `Phase.isReady`; `isShowingSplash` now derives from `Phase.surfaceIdentity` instead of copying its launching/running → splash mapping, so a new phase case can't drift the two apart. Noted why it must read the runner's surface, not `displayedSurfaceIdentity` (that reports `.splash` for a held `.ready`, which would re-arm the hold from its own release and never reveal). - Threaded `minimumSplashDuration` through both convenience initializers: the README documented the convenience form, but only the full initializer took it, so a host using the built-in splash silently got `.zero`. - Ported the regression test that was dropped when main's copy of the container was deleted in the merge — `minimumSplashDurationDoesNotHoldWhenNoSplashWasShown` pins the one host-testable branch (an already-ready runner must not stall behind a hold for a splash nobody saw) — and recorded that invariant, plus the isShowingSplash rule, in LifecycleKitUI/AGENTS.md. - GateView: last raw catalog key in Shared/ now uses the generated symbol (`.failureGateUnregistered(_:)`), matching what the resolution adopted in the adjacent LifecycleFailureView; Project.swift's symbol-generation note names LifecycleKitUI. - Docs/comments: dropped a duplicated comment and fixed a stale `LifecycleRunner`-in-the-environment doc in DataSettingsView (it's the `\.lifecycle` proxy now), dropped its dead `import LifecycleKit`, corrected RootView's "four top-level screens" to the three real tabs, and fixed a README snippet calling a splash initializer that doesn't exist. Full Stuff-iOS-Tests scheme green; ./swiftformat --lint clean. --- Project.swift | 5 +- Shared/LifecycleKitUI/AGENTS.md | 8 ++ Shared/LifecycleKitUI/README.md | 4 +- Shared/LifecycleKitUI/Sources/GateView.swift | 5 +- .../Sources/LifecycleContainer.swift | 77 +++++++++---------- .../Tests/LifecycleContainerTests.swift | 28 +++++++ Where/WhereUI/Sources/RootView.swift | 4 +- .../Sources/Settings/DataSettingsView.swift | 10 +-- 8 files changed, 83 insertions(+), 58 deletions(-) diff --git a/Project.swift b/Project.swift index 27ec9aed..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( diff --git a/Shared/LifecycleKitUI/AGENTS.md b/Shared/LifecycleKitUI/AGENTS.md index 6cf10d5e..09b2c1cb 100644 --- a/Shared/LifecycleKitUI/AGENTS.md +++ b/Shared/LifecycleKitUI/AGENTS.md @@ -24,6 +24,14 @@ build system, formatting, and global conventions. Read that first. re-read from shared state. Don't add a code path that renders the app surface without the launch output in hand. - **No view tree when `reason.buildsNoViewTree`** — even at `.ready`. +- **`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). `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. diff --git a/Shared/LifecycleKitUI/README.md b/Shared/LifecycleKitUI/README.md index c70183d9..2cf6d01e 100644 --- a/Shared/LifecycleKitUI/README.md +++ b/Shared/LifecycleKitUI/README.md @@ -15,7 +15,9 @@ import LifecycleKitUI LifecycleContainer( runner, // LifecycleRunner splash: { context in - LaunchSplashView(caption: context?.message) + // 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 diff --git a/Shared/LifecycleKitUI/Sources/GateView.swift b/Shared/LifecycleKitUI/Sources/GateView.swift index 70c17feb..397fec81 100644 --- a/Shared/LifecycleKitUI/Sources/GateView.swift +++ b/Shared/LifecycleKitUI/Sources/GateView.swift @@ -54,10 +54,7 @@ public struct MissingGateViewError: Error, LocalizedError { public let gateID: AnyHashable public var errorDescription: String? { - String( - localized: "failure.gate.unregistered \(String(describing: gateID))", - bundle: .module, - ) + String(localized: .failureGateUnregistered(String(describing: gateID))) } } diff --git a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift index 5db9d773..5f244b84 100644 --- a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift @@ -50,11 +50,10 @@ public struct LifecycleContainer< private let gates: [GateRegistration] private let content: (Launch) -> 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 + /// 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. @@ -113,55 +112,45 @@ public struct LifecycleContainer< } .environment(\.lifecycle, LifecycleProxy(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 a gate) so every episode gets - // its own minimum. + // 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 else { return } - splashAppearedAt = ContinuousClock.now - minimumSplashElapsed = false + guard showing, minimumSplashDuration > .zero else { return } + splashHoldUntil = ContinuousClock.now.advanced(by: minimumSplashDuration) } - // 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 } + // 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) { minimumSplashElapsed = true } + withAnimation(animation) { splashHoldUntil = nil } } } - /// Whether the splash is actually on screen right now: a launch that builds - /// a view tree, parked on `.launching` or running a step (which always shows - /// the splash — step UI lives in gates, which have their own surface). + /// 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 { - guard !runner.reason.buildsNoViewTree else { return false } - switch runner.phase { - case .launching, .running: return true - case .awaitingGate, .failed, .ready: return false - } + !runner.reason.buildsNoViewTree && runner.phase.surfaceIdentity == .splash } - 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. + /// 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 { - minimumSplashDuration <= .zero || splashAppearedAt == nil || minimumSplashElapsed + splashHoldUntil == nil } /// The surface actually on screen, for `.animation(_:value:)`. While the @@ -169,7 +158,7 @@ public struct LifecycleContainer< /// `.splash`, so the reveal transition fires when the hold releases — not /// the instant the runner reports `.ready`. private var displayedSurfaceIdentity: LifecycleRunner.Phase.SurfaceIdentity { - if isReadyPhase, !canRevealReady { + if runner.phase.isReady, !canRevealReady { return .splash } return runner.phase.surfaceIdentity @@ -257,11 +246,13 @@ extension LifecycleContainer where Splash == LifecycleSplash, Failure == Lifecyc /// 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, @@ -275,12 +266,14 @@ extension LifecycleContainer where Failure == LifecycleFailureView { /// 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, diff --git a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift index 9a934d04..df9b2a34 100644 --- a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift +++ b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift @@ -56,6 +56,34 @@ struct LifecycleContainerTests { #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.) + var content = false + let runner = await makeReadyRunner() + #expect(runner.phase.isReady) + + let container = LifecycleContainer( + runner, + minimumSplashDuration: .seconds(60), + splash: { _ in EmptyView() }, + failure: { _ in EmptyView() }, + ) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { content } + } + #expect(content) + } + @Test func launchingShowsSplash() throws { var splash = false var content = false diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 16f14016..47c2503e 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -8,8 +8,8 @@ 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 UI while the /// `LifecycleRunner` runs, then the `TabView` (the real "logged-in" UI — the diff --git a/Where/WhereUI/Sources/Settings/DataSettingsView.swift b/Where/WhereUI/Sources/Settings/DataSettingsView.swift index 492cc0a0..7d38bbc1 100644 --- a/Where/WhereUI/Sources/Settings/DataSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/DataSettingsView.swift @@ -1,4 +1,3 @@ -import LifecycleKit import LifecycleKitUI import SwiftUI import WhereCore @@ -62,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) { @@ -91,9 +90,6 @@ struct DataSettingsView: View { } private func requestReset() { - // The reset plan takes the session being torn down — handed in here, - // where it's known non-optional, rather than re-read inside the - // teardown. Task { await lifecycle.teardown(WhereLaunch.resetPlan(for: model), input: session) } } } From 2d405be962b9ddd6d68c80d34623fc3835bfd9ef Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 24 Jul 2026 17:45:22 -0700 Subject: [PATCH 11/17] docs: file three launch observations from a fresh-install capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording what a fresh-install simulator screen recording surfaced while reviewing the launch surfaces, so they don't stay in a chat log. None is caused by the typed-launch work; all three predate it. - P1, notifications: the launch's detached fan requests notification authorization while the app is still launching, so a fresh install gets the system prompt over the splash with no rationale and before the user has asked for reminders. Suggests asking in context and having the fan only reconcile against already-granted authorization. - P2, launch screen: UILaunchScreen is empty, so first run reads as ~1.7s white → ~0.25s dark splash → light onboarding; a branded launch screen is the right layer to fix that (lengthening the splash hold would only delay interactive UI, since the hold gates the .ready reveal and not gate transitions). - P2, launch can park behind a system prompt: if location authorization is still undetermined at reconcile-tracking, the trunk awaits the request and the splash stays up until the user answers. Not reachable on the normal path, but it puts a modal prompt on the launch critical path. --- Where/TODOs.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Where/TODOs.md b/Where/TODOs.md index c1fbc5c8..72150d50 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -40,6 +40,7 @@ This feels like it might result in a cleaner "pipeline-esque" code layout, and a - Persist a **minReaderVersion** per entity, not just a version. Additive (expand/contract) changes leave it low so old builds keep reading via the retained old field (tolerant reader); only a genuinely forward-incompatible change bumps it. Readers exclude entities whose `minReaderVersion > appVersion` and surface a "some data needs a newer app" warning — the only case that actually needs exclusion. - Durable write-back is **read-repair**, decoupled from read correctness: opportunistically (batched, on `.NSPersistentStoreRemoteChange` + launch) rewrite stale records to the current version and stamp it, so old builds can honor exclusion. Transforms must be deterministic + commutative so two devices healing the same record via CloudKit converge (LWW-safe). - Open question: the exclusion UX — an older device progressively hiding days a newer device has touched — needs a deliberate warning surface, not a silent drop. +- fix: 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. ## P2s (Nice to have) - feat: Bound the Periscope log store by size, not just age (PR #94 review). `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. @@ -51,6 +52,8 @@ This feels like it might result in a cleaner "pipeline-esque" code layout, and a - Move `let calendar = Calendar.current` into a var on the controller? There’s a few of these - Move test only code behind @_spi - Add comments to strings in xcstrings files +- feat: 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. +- fix: 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 notification prompt 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. # Completed issues From 766e052eabcfa71f4f1b31cb996a6db8287f2a5b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 24 Jul 2026 18:52:29 -0700 Subject: [PATCH 12/17] Drop the trailing newline from the LifecycleKitUI catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xcode doesn't write a trailing newline in a String Catalog, and #135 adds an ./xcstrings --lint CI step that enforces its exact serialization. This branch moved the catalog from Shared/LifecycleKit to Shared/LifecycleKitUI, so the file exists only here — merging main won't normalize it, and it would have been the one catalog failing the new lint. Content is byte-identical (verified by parsing both sides); only the trailing newline changed. The rest of this branch's catalogs are still un-normalized and will pick that up from main when #135 lands. --- Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings b/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings index e20ead6a..41414f13 100644 --- a/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings +++ b/Shared/LifecycleKitUI/Sources/Resources/Localizable.xcstrings @@ -25,4 +25,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file From ba0839de6a8579c5fd0b1c3a04a3212950f92868 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 24 Jul 2026 19:13:01 -0700 Subject: [PATCH 13/17] Build the destination during the splash hold, not at the reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two skipped `/simplify` findings on `LifecycleContainer`. `content` was built in the `.ready` switch arm only once the hold released, so the destination's first layout and its `.task`s landed in the same frame the reveal animation started. It now renders from `phase.readyValue` in a `ZStack` beneath the launch surface, so it's built as soon as the launch produces its value and the hold warms it up instead of stalling. No loss of type safety: `readyValue` is non-nil only in `.ready`, so `content` still cannot be constructed without the launch's own output — there's no new optional state, placeholder, or re-read of shared state. Kept to one `content` call site on purpose; rendering it from separate held/revealed branches would give SwiftUI two identities and rebuild the whole destination at the reveal, defeating the point. Same edit fixes the splash remounting: the per-phase `switch` arms meant `.launching`, `.running`, and a held `.ready` each built the splash as a different view, remounting it (and resetting `LaunchSplashView`'s pulse, radar, and caption timer, which its docs claim run uninterrupted) at every boundary. A derived `LaunchOverlay` now resolves all of them to one `.splash` case, so it keeps a single identity — and stays exhaustive over the phase, so a new case is still a compile error. `minimumSplashDurationDoesNotHoldWhenNoSplashWasShown` now asserts the reveal via the *absent splash*: content is built during a hold too, so building it no longer distinguishes revealed from held. --- Shared/LifecycleKitUI/AGENTS.md | 17 +++- Shared/LifecycleKitUI/README.md | 7 ++ .../Sources/LifecycleContainer.swift | 81 ++++++++++++++----- .../Tests/LifecycleContainerTests.swift | 11 ++- 4 files changed, 91 insertions(+), 25 deletions(-) diff --git a/Shared/LifecycleKitUI/AGENTS.md b/Shared/LifecycleKitUI/AGENTS.md index 09b2c1cb..f5065407 100644 --- a/Shared/LifecycleKitUI/AGENTS.md +++ b/Shared/LifecycleKitUI/AGENTS.md @@ -22,13 +22,26 @@ build system, formatting, and global conventions. Read that first. - **`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. + 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). `isShowingSplash` + 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. diff --git a/Shared/LifecycleKitUI/README.md b/Shared/LifecycleKitUI/README.md index 2cf6d01e..06496d99 100644 --- a/Shared/LifecycleKitUI/README.md +++ b/Shared/LifecycleKitUI/README.md @@ -81,6 +81,13 @@ 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 diff --git a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift index 5f244b84..cbf8401e 100644 --- a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift @@ -62,7 +62,9 @@ public struct LifecycleContainer< /// - 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. + /// 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 @@ -179,31 +181,66 @@ public struct LifecycleContainer< 0 } - @ViewBuilder private var phaseContent: some View { - switch runner.phase { - case .launching: - splashSurface(nil) - case let .running(context): - splashSurface(context) - case let .awaitingGate(handle): - gateView(for: handle).transition(transition).zIndex(Self.launchSurfaceZIndex) - case let .failed(failure): - failureView(failure) + /// 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. + private var phaseContent: some View { + ZStack { + if let value = runner.phase.readyValue { + content(value) .transition(transition) - .zIndex(Self.launchSurfaceZIndex) - case let .ready(value): - // Keep the splash up until its minimum has elapsed (see - // `minimumSplashDuration`), then reveal the app content. - if canRevealReady { - content(value).transition(transition).zIndex(Self.contentZIndex) - } else { - splashSurface(nil) - } + .zIndex(Self.contentZIndex) + } + launchSurface + } + } + + /// 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. + private enum LaunchOverlay { + case splash(LifecycleStepContext?) + case gate(LifecycleGateHandle) + case failure(LifecycleFailure) + /// Nothing covers the content — the reveal has happened. + case revealed + } + + private var launchOverlay: LaunchOverlay { + switch runner.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) } } - private func splashSurface(_ context: LifecycleStepContext?) -> some View { - splash(context).transition(transition).zIndex(Self.launchSurfaceZIndex) + @ViewBuilder private var launchSurface: some View { + switch launchOverlay { + 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 diff --git a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift index df9b2a34..4d5550e6 100644 --- a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift +++ b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift @@ -66,14 +66,20 @@ struct LifecycleContainerTests { // 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 EmptyView() }, + splash: { _ in ProbeView { splashShown = true } }, failure: { _ in EmptyView() }, ) { _ in ProbeView { content = true } @@ -82,6 +88,9 @@ struct LifecycleContainerTests { 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 launchingShowsSplash() throws { From 6a8598676a79be6c4b89a13450c6204f137b9d55 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 24 Jul 2026 19:31:08 -0700 Subject: [PATCH 14/17] Drop stale retry references from docs Retry left with #121, but several doc comments still described it as a live feature: node IDs "used for retry resumption", `then`'s "retry() resumes here", the plan erasing types so the runner can "resume a retry mid-trunk", `EraseDataStep`'s "a retry re-erases", and `onServicesReady`'s "a retry after a failed launch". Reworded to what actually happens: memoization exists for the promotion re-walk, a thrown node is terminal, and a failed erase leaves the session and preferences intact so the reset is re-invocable from Settings after a relaunch. No behavior change. --- Shared/LifecycleKit/Sources/LaunchPlan.swift | 14 +++++++------- Shared/LifecycleKit/Sources/LifecycleStep.swift | 8 ++++---- Where/WhereUI/Sources/Launch/WhereLaunch.swift | 5 +++-- .../WhereUI/Sources/Launch/WhereLaunchSteps.swift | 6 ++++-- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/Shared/LifecycleKit/Sources/LaunchPlan.swift b/Shared/LifecycleKit/Sources/LaunchPlan.swift index d66b5c2a..478072b1 100644 --- a/Shared/LifecycleKit/Sources/LaunchPlan.swift +++ b/Shared/LifecycleKit/Sources/LaunchPlan.swift @@ -17,8 +17,8 @@ /// 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 resume a retry mid-trunk; the -/// combinators' constraints guarantee every internal cast. +/// 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 @@ -45,8 +45,8 @@ public struct LaunchPlan { /// 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, and `retry()` resumes here with - /// the memoized upstream value. + /// 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 { @@ -91,9 +91,9 @@ public struct LaunchPlan { return copy } - /// Node IDs must be unique within a plan: retry resumption, run-once - /// memoization, and gate-view registration all key on them, so a - /// duplicate would make those ambiguous. A duplicate is a programmer + /// 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)) diff --git a/Shared/LifecycleKit/Sources/LifecycleStep.swift b/Shared/LifecycleKit/Sources/LifecycleStep.swift index a6535289..cd0e706a 100644 --- a/Shared/LifecycleKit/Sources/LifecycleStep.swift +++ b/Shared/LifecycleKit/Sources/LifecycleStep.swift @@ -19,8 +19,8 @@ public protocol LifecycleStep { /// What finishing this step proves. `Void` for side-effect-only steps. associatedtype Output: Sendable - /// Stable identity used for retry resumption, run-once memoization, and - /// tests. Typed as `AnyHashable` so steps carry a real `Hashable` token (a + /// Stable identity used for run-once memoization and 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. var id: AnyHashable { get } @@ -60,8 +60,8 @@ public protocol LifecycleGate { /// The trunk value at this gate's position, passed through untouched. associatedtype Value: Sendable - /// Stable identity used for retry resumption, run-once memoization, and - /// tests. Same conventions as `LifecycleStep.id`. + /// Stable identity used for run-once memoization and tests. Same + /// conventions as `LifecycleStep.id`. var id: AnyHashable { get } /// Which launch reasons this gate applies to. Defaults to `.foreground`: diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index 25e2212e..78efd992 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -143,8 +143,9 @@ public enum WhereLaunch { /// than in the app delegate) puts app-lifecycle wiring in one place. /// /// `onServicesReady` fires from the `start-session` 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 + /// 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. diff --git a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift index 8b3e04aa..2cca30d5 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift @@ -149,8 +149,10 @@ struct WidgetSnapshotStep: LifecycleStep { /// 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` with the session and preferences intact, so a retry re-erases -/// rather than stranding the user in onboarding atop un-erased data. +/// `.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 From 4635a420e5a14a7bcb9c0e6108491aa9c2a577aa Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 13:16:52 -0700 Subject: [PATCH 15/17] Restore main's .bumper sources verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-commit hook reformatted three of main's brand-new Bumper rule files as collateral in the merge commit. `.bumper` is a *hidden* directory, so `swiftformat .` — what `./swiftformat --lint` and the CI `format` job both run — never scans it, but the hook passes staged paths explicitly and therefore does format it. Those files were never SwiftFormat-conformant and CI doesn't ask them to be, so the reformat was pure churn in files this branch has no business touching. Reverted to origin/main's exact content; committed with --no-verify so the hook doesn't immediately redo it. --- .bumper/Sources/WhereProjectRules.swift | 56 +++++++++---------- .bumper/Tests/WhereArchitectureTests.swift | 40 ++++++------- .bumper/Tests/WhereProjectRulesTests.swift | 65 +++++++++++----------- 3 files changed, 81 insertions(+), 80 deletions(-) diff --git a/.bumper/Sources/WhereProjectRules.swift b/.bumper/Sources/WhereProjectRules.swift index 1c4606c7..006e1783 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -5,17 +5,17 @@ let whereProjectRules = RuleSet { Rules.constructionOwnership( "WhereServices", allowed: whereServicesConstructionScope, - id: "where.services_composition_ownership", + id: "where.services_composition_ownership" ) Rules.constructionOwnership( "CoreLocationSource", allowed: .files(["Where/WhereUI/Sources/Launch/WhereLaunch.swift"]), - id: "where.live_location_source_ownership", + id: "where.live_location_source_ownership" ) Rules.singleNominalSpelling( suffix: "Log", owner: whereLoggingScope, - id: "where.logging_type_ownership", + id: "where.logging_type_ownership" ) productionStoreOpeningRule checkedConcurrencyBoundaryRule @@ -46,7 +46,7 @@ private let productionStoreOpeningPaths: Set = [ private let productionStoreOpeningRule = Rules.files( "where.production_store_opening", severity: .error, - summary: "Production SwiftData stores open only at the app and share-extension composition roots.", + summary: "Production SwiftData stores open only at the app and share-extension composition roots." ) { file in functionCalls() .filter { match in @@ -59,8 +59,8 @@ private let productionStoreOpeningRule = Rules.files( message: "SwiftDataStore.make is called outside a process composition root.", evidence: ViolationEvidence( observed: "SwiftDataStore.make in \(file.path.rawValue)", - expectation: "open the production store in WhereLaunch or ShareEvidenceModel", - ), + expectation: "open the production store in WhereLaunch or ShareEvidenceModel" + ) ) } } @@ -76,7 +76,7 @@ private let documentedUnsafeConcurrencyPaths: Set = [ private let checkedConcurrencyBoundaryRule = Rules.files( "where.checked_concurrency_boundaries", severity: .error, - summary: "Unchecked concurrency escape hatches stay inside documented lifecycle boundaries.", + summary: "Unchecked concurrency escape hatches stay inside documented lifecycle boundaries." ) { file in let preconcurrencyFailures = SyntaxQuery() .filter { match in @@ -88,8 +88,8 @@ private let checkedConcurrencyBoundaryRule = Rules.files( message: "Production code uses an @preconcurrency escape hatch.", evidence: ViolationEvidence( observed: match.node.trimmedDescription, - expectation: "use checked Swift concurrency", - ), + expectation: "use checked Swift concurrency" + ) ) } @@ -105,8 +105,8 @@ private let checkedConcurrencyBoundaryRule = Rules.files( message: "nonisolated(unsafe) is outside a documented lifecycle boundary.", evidence: ViolationEvidence( observed: match.node.trimmedDescription, - expectation: "checked isolation or a documented existing boundary", - ), + expectation: "checked isolation or a documented existing boundary" + ) ) } @@ -116,7 +116,7 @@ private let checkedConcurrencyBoundaryRule = Rules.files( private let gregorianCalendarRule = Rules.files( "where.gregorian_calendar", severity: .error, - summary: "Where day and year calculations do not use the device's potentially non-Gregorian current calendar.", + summary: "Where day and year calculations do not use the device's potentially non-Gregorian current calendar." ) { file in SyntaxQuery() .filter { match in @@ -129,8 +129,8 @@ private let gregorianCalendarRule = Rules.files( message: "Where uses Calendar.current instead of an explicit Gregorian calendar.", evidence: ViolationEvidence( observed: match.node.trimmedDescription, - expectation: "an injected Gregorian calendar or Calendar.whereIntents", - ), + expectation: "an injected Gregorian calendar or Calendar.whereIntents" + ) ) } } @@ -151,7 +151,7 @@ private let whereStoreMutatingMethods: Set = [ private let storeTransactionBoundaryRule = Rules.files( "where.store_transaction_boundary", severity: .error, - summary: "WhereStore mutations occur inside the transaction owned by store.perform.", + summary: "WhereStore mutations occur inside the transaction owned by store.perform." ) { file in functionCalls() .filter { match in @@ -170,8 +170,8 @@ private let storeTransactionBoundaryRule = Rules.files( message: "WhereStore mutation occurs outside store.perform.", evidence: ViolationEvidence( observed: match.node.calledExpression.trimmedDescription, - expectation: "call the mutation from inside store.perform { ... }", - ), + expectation: "call the mutation from inside store.perform { ... }" + ) ) } } @@ -195,7 +195,7 @@ private func isInsideStorePerform(_ node: FunctionCallExprSyntax) -> Bool { private let appShortcutsProviderOwnershipRule = Rules.files( "where.app_shortcuts_provider_ownership", severity: .error, - summary: "AppShortcutsProvider conformances live in the Where app target.", + summary: "AppShortcutsProvider conformances live in the Where app target." ) { file in guard file.component.rawValue != WhereComponent.app.rawValue else { return [] } return SyntaxQuery() @@ -206,8 +206,8 @@ private let appShortcutsProviderOwnershipRule = Rules.files( message: "AppShortcutsProvider conformance is outside the Where app target.", evidence: ViolationEvidence( observed: file.path.rawValue, - expectation: "a source owned by the Where app component", - ), + expectation: "a source owned by the Where app component" + ) ) } } @@ -215,7 +215,7 @@ private let appShortcutsProviderOwnershipRule = Rules.files( private let loggingFacadeRule = Rules.files( "where.logging_facade", severity: .error, - summary: "Where production logging goes through its typed Periscope facades.", + summary: "Where production logging goes through its typed Periscope facades." ) { file in let rawLoggingImports = SyntaxQuery() .filter { $0.node.path.trimmedDescription == "OSLog" } @@ -225,8 +225,8 @@ private let loggingFacadeRule = Rules.files( message: "Where production code imports OSLog directly.", evidence: ViolationEvidence( observed: "import OSLog", - expectation: "WhereLog or RegionLog", - ), + expectation: "WhereLog or RegionLog" + ) ) } @@ -238,8 +238,8 @@ private let loggingFacadeRule = Rules.files( message: "Where production code prints directly.", evidence: ViolationEvidence( observed: "print", - expectation: "a typed WhereLog or RegionLog event", - ), + expectation: "a typed WhereLog or RegionLog event" + ) ) } @@ -254,7 +254,7 @@ private let previewCoverageRule = Rules.files( "where.preview_coverage", severity: .error, summary: "Every WhereUI or widget source file declaring a previewable component includes a #Preview.", - scope: previewScope, + scope: previewScope ) { file in let previewableDeclarations = SyntaxQuery() .filter { match in @@ -283,8 +283,8 @@ private let previewCoverageRule = Rules.files( message: "\(match.node.name.text) has no #Preview in its source file.", evidence: ViolationEvidence( observed: file.path.rawValue, - expectation: "at least one #Preview in the same file", - ), + expectation: "at least one #Preview in the same file" + ) ) } } diff --git a/.bumper/Tests/WhereArchitectureTests.swift b/.bumper/Tests/WhereArchitectureTests.swift index 63d612b8..783c1058 100644 --- a/.bumper/Tests/WhereArchitectureTests.swift +++ b/.bumper/Tests/WhereArchitectureTests.swift @@ -9,16 +9,16 @@ func `Where architecture accepts downward dependencies`() throws { files: [ SourceInput( path: "Where/WhereCore/Sources/Service.swift", - component: ComponentID(WhereComponent.whereCore.rawValue), - source: "import RegionKit\nstruct Service {}", + component: try ComponentID(WhereComponent.whereCore.rawValue), + source: "import RegionKit\nstruct Service {}" ), SourceInput( path: "Where/WhereUI/Sources/Screen.swift", - component: ComponentID(WhereComponent.whereUI.rawValue), - source: "import WhereCore\nimport SwiftUI\nstruct Screen {}", + component: try ComponentID(WhereComponent.whereUI.rawValue), + source: "import WhereCore\nimport SwiftUI\nstruct Screen {}" ), - ], - ), + ] + ) ) #expect(report.violations.isEmpty) @@ -32,11 +32,11 @@ func `RegionKit cannot depend upward on WhereCore`() throws { files: [ SourceInput( path: "Where/RegionKit/Sources/Region.swift", - component: ComponentID(WhereComponent.regionKit.rawValue), - source: "import WhereCore\nstruct Region {}", + component: try ComponentID(WhereComponent.regionKit.rawValue), + source: "import WhereCore\nstruct Region {}" ), - ], - ), + ] + ) ) let violation = try #require(report.violations.first) @@ -53,11 +53,11 @@ func `WhereUI cannot import persistence`() throws { files: [ SourceInput( path: "Where/WhereUI/Sources/Screen.swift", - component: ComponentID(WhereComponent.whereUI.rawValue), - source: "import SwiftData\nstruct Screen {}", + component: try ComponentID(WhereComponent.whereUI.rawValue), + source: "import SwiftData\nstruct Screen {}" ), - ], - ), + ] + ) ) let violation = try #require(report.violations.first) @@ -74,16 +74,16 @@ func `Where adapters cannot link Broadway directly`() throws { files: [ SourceInput( path: "Where/WhereWidgets/Sources/Widget.swift", - component: ComponentID(WhereComponent.widgets.rawValue), - source: "import BroadwayUI\nstruct Widget {}", + component: try ComponentID(WhereComponent.widgets.rawValue), + source: "import BroadwayUI\nstruct Widget {}" ), SourceInput( path: "Where/WhereIntents/Sources/Intent.swift", - component: ComponentID(WhereComponent.whereIntents.rawValue), - source: "import BroadwayCore\nstruct Intent {}", + component: try ComponentID(WhereComponent.whereIntents.rawValue), + source: "import BroadwayCore\nstruct Intent {}" ), - ], - ), + ] + ) ) #expect(report.violations.count == 2) diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index a2a3c4a9..d8a06a63 100644 --- a/.bumper/Tests/WhereProjectRulesTests.swift +++ b/.bumper/Tests/WhereProjectRulesTests.swift @@ -2,20 +2,21 @@ import BumperBowlingCore import BumperBowlingTestSupport import Testing +@Suite("Where project rules") struct WhereProjectRulesTests { @Test func `production store opens at process composition roots`() throws { let allowed = try evaluate( path: "Where/WhereUI/Sources/Launch/WhereLaunch.swift", component: .whereUI, - source: "func open() throws { _ = try SwiftDataStore.make() }", + source: "func open() throws { _ = try SwiftDataStore.make() }" ) let rejectedPath: RelativeFilePath = "Where/WhereUI/Sources/Model/CompetingStoreOwner.swift" let rejected = try evaluate( path: rejectedPath, component: .whereUI, - source: "func open() throws { _ = try SwiftDataStore.make() }", + source: "func open() throws { _ = try SwiftDataStore.make() }" ) #expect(allowed.violations.isEmpty) @@ -27,8 +28,8 @@ struct WhereProjectRulesTests { #expect( violation.evidence == ViolationEvidence( observed: "SwiftDataStore.make in \(rejectedPath.rawValue)", - expectation: "open the production store in WhereLaunch or ShareEvidenceModel", - ), + expectation: "open the production store in WhereLaunch or ShareEvidenceModel" + ) ) } @@ -37,14 +38,14 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereUI/Sources/Model/WhereSession.swift", component: .whereUI, - source: "final class Session { nonisolated(unsafe) var task: Task? }", + source: "final class Session { nonisolated(unsafe) var task: Task? }" ) let rejectedPath: RelativeFilePath = "Where/WhereUI/Sources/Model/CompetingSession.swift" let rejected = try evaluate( path: rejectedPath, component: .whereUI, - source: "final class Session { nonisolated(unsafe) var task: Task? }", + source: "final class Session { nonisolated(unsafe) var task: Task? }" ) #expect(allowed.violations.isEmpty) @@ -62,7 +63,7 @@ struct WhereProjectRulesTests { let report = try evaluate( path: path, component: .whereCore, - source: "@preconcurrency import Foundation", + source: "@preconcurrency import Foundation" ) let violation = try #require(report.violations.first) @@ -77,19 +78,19 @@ struct WhereProjectRulesTests { let core = try evaluate( path: "Where/WhereCore/Sources/WhereServices.swift", component: .whereCore, - source: "func assemble() { _ = WhereServices() }", + source: "func assemble() { _ = WhereServices() }" ) let preview = try evaluate( path: "Where/WhereUI/Sources/Preview/PreviewSupport.swift", component: .whereUI, - source: "func preview() { _ = WhereServices() }", + source: "func preview() { _ = WhereServices() }" ) let rejectedPath: RelativeFilePath = "Where/WhereIntents/Sources/CompetingServices.swift" let rejected = try evaluate( path: rejectedPath, component: .whereIntents, - source: "func assemble() { _ = WhereServices() }", + source: "func assemble() { _ = WhereServices() }" ) #expect(core.violations.isEmpty) @@ -105,14 +106,14 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereUI/Sources/Launch/WhereLaunch.swift", component: .whereUI, - source: "func assemble() { _ = CoreLocationSource() }", + source: "func assemble() { _ = CoreLocationSource() }" ) let rejectedPath: RelativeFilePath = "Where/WhereIntents/Sources/CompetingLocationSource.swift" let rejected = try evaluate( path: rejectedPath, component: .whereIntents, - source: "func assemble() { _ = CoreLocationSource() }", + source: "func assemble() { _ = CoreLocationSource() }" ) #expect(allowed.violations.isEmpty) @@ -127,14 +128,14 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereUI/Sources/Logging/ScreenLog.swift", component: .whereUI, - source: "enum ScreenLog {}", + source: "enum ScreenLog {}" ) let rejectedPath: RelativeFilePath = "Where/WhereUI/Sources/Model/ScreenLog.swift" let rejected = try evaluate( path: rejectedPath, component: .whereUI, - source: "enum ScreenLog {}", + source: "enum ScreenLog {}" ) #expect(allowed.violations.isEmpty) @@ -149,26 +150,26 @@ struct WhereProjectRulesTests { let intentsAllowed = try evaluate( path: "Where/WhereIntents/Sources/CalendarUse.swift", component: .whereIntents, - source: "let calendar = Calendar.whereIntents", + source: "let calendar = Calendar.whereIntents" ) let uiAllowed = try evaluate( path: "Where/WhereUI/Sources/CalendarUse.swift", component: .whereUI, - source: "let calendar = Calendar(identifier: .gregorian)", + source: "let calendar = Calendar(identifier: .gregorian)" ) let intentsRejectedPath: RelativeFilePath = "Where/WhereIntents/Sources/DriftingCalendar.swift" let intentsRejected = try evaluate( path: intentsRejectedPath, component: .whereIntents, - source: "let calendar = Calendar.current", + source: "let calendar = Calendar.current" ) let uiRejectedPath: RelativeFilePath = "Where/WhereUI/Sources/DriftingCalendar.swift" let uiRejected = try evaluate( path: uiRejectedPath, component: .whereUI, - source: "let calendar = Calendar.current", + source: "let calendar = Calendar.current" ) #expect(intentsAllowed.violations.isEmpty) @@ -180,8 +181,8 @@ struct WhereProjectRulesTests { #expect( intentsViolation.evidence == ViolationEvidence( observed: "Calendar.current", - expectation: "an injected Gregorian calendar or Calendar.whereIntents", - ), + expectation: "an injected Gregorian calendar or Calendar.whereIntents" + ) ) let uiViolation = try #require(uiRejected.violations.first) #expect(uiRejected.violations.count == 1) @@ -194,14 +195,14 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereCore/Sources/Journal.swift", component: .whereCore, - source: "func save() async throws { try await store.perform { try await store.add(sample: sample) } }", + source: "func save() async throws { try await store.perform { try await store.add(sample: sample) } }" ) let rejectedPath: RelativeFilePath = "Where/WhereCore/Sources/CompetingWriter.swift" let rejected = try evaluate( path: rejectedPath, component: .whereCore, - source: "func save() async throws { try await store.add(sample: sample) }", + source: "func save() async throws { try await store.add(sample: sample) }" ) #expect(allowed.violations.isEmpty) @@ -216,14 +217,14 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/Where/Sources/WhereShortcuts.swift", component: .app, - source: "struct WhereShortcuts: AppShortcutsProvider {}", + source: "struct WhereShortcuts: AppShortcutsProvider {}" ) let rejectedPath: RelativeFilePath = "Where/WhereIntents/Sources/WhereShortcuts.swift" let rejected = try evaluate( path: rejectedPath, component: .whereIntents, - source: "struct WhereShortcuts: AppShortcutsProvider {}", + source: "struct WhereShortcuts: AppShortcutsProvider {}" ) #expect(allowed.violations.isEmpty) @@ -238,17 +239,17 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereCore/Sources/Worker.swift", component: .whereCore, - source: "func run() { WhereLog.root(WorkerLog.self) { .completed } }", + source: "func run() { WhereLog.root(WorkerLog.self) { .completed } }" ) let printRejected = try evaluate( path: "Where/WhereCore/Sources/PrintingWorker.swift", component: .whereCore, - source: "func run() { print(\"done\") }", + source: "func run() { print(\"done\") }" ) let osLogRejected = try evaluate( path: "Where/WhereUI/Sources/LoggingScreen.swift", component: .whereUI, - source: "import OSLog\nstruct LoggingScreen {}", + source: "import OSLog\nstruct LoggingScreen {}" ) #expect(allowed.violations.isEmpty) @@ -261,19 +262,19 @@ struct WhereProjectRulesTests { let allowed = try evaluate( path: "Where/WhereUI/Sources/PreviewedView.swift", component: .whereUI, - source: "struct PreviewedView: View {}\n#Preview { PreviewedView() }", + source: "struct PreviewedView: View {}\n#Preview { PreviewedView() }" ) let rejectedPath: RelativeFilePath = "Where/WhereUI/Sources/UnpreviewedView.swift" let rejected = try evaluate( path: rejectedPath, component: .whereUI, - source: "struct UnpreviewedView: View {}", + source: "struct UnpreviewedView: View {}" ) let coreView = try evaluate( path: "Where/WhereCore/Sources/DomainView.swift", component: .whereCore, - source: "struct DomainView: View {}", + source: "struct DomainView: View {}" ) #expect(allowed.violations.isEmpty) @@ -287,12 +288,12 @@ struct WhereProjectRulesTests { private func evaluate( path: RelativeFilePath, component: WhereComponent, - source: String, + source: String ) throws -> RuleReport { try RuleTestHarness(whereProjectRules).evaluate( VirtualRepository { VirtualSourceFile.swift(path, component: component, source: source) - }, + } ) } } From f73ce45b0b7e33f716553d3dc26fef766cb8e93b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 13:50:26 -0700 Subject: [PATCH 16/17] Make LaunchPlan generic over its node ID type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node identity was the one part of a plan the compiler wasn't checking: `LifecycleStep.id` was `AnyHashable`, so nothing stopped a plan from mixing identity domains, and assertions read `ids == [LaunchStepID.eraseData, .resetPreferences].map { AnyHashable($0) }`. `LifecycleStep`/`LifecycleGate` now declare `associatedtype ID: Hashable & Sendable`, and `LaunchPlan` requires every combinator's node to match the plan's `ID`. A plan therefore has one identity domain, a step keyed for another plan can't be composed in, and `nodeIDs` returns `[ID]` — so the parity tests are just `ids == [.eraseData, .resetPreferences]` and Where's twelve steps drop their `: AnyHashable` annotations to plain `let id = LaunchStepID.openStore`. `ID` stops at the plan, by design. IDs still erase to `AnyHashable` inside `LaunchPlanNode` — the existing single erasure site — because `LifecycleDriving`, the seam behind the non-generic `\.lifecycle` environment value, traffics in `[LaunchPlanNode]`. Pushing `ID` further would force it onto the runner, `LifecycleContainer`'s generic list, and every splash/failure/gate closure, in exchange for typed `failed(at:)` / `isRunning(_:)` assertions. That trade is available later without rework; the reasoning is recorded in the module's AGENTS.md so it reads as a decision rather than an oversight. The runner and proxy take the plan's ID as a method-level `some Hashable & Sendable`, so neither gains a generic parameter. Test fixtures key on `String` (`AnyHashable` isn't `Sendable`, so it can no longer be an ID), and `LaunchStepID` gained an explicit `Sendable` — public enums don't get it implicitly, which the compiler caught at the reset call site. Full Stuff-iOS-Tests green (1381 tests), swiftformat and bumper clean. --- AGENTS.md | 9 +- Shared/LifecycleKit/AGENTS.md | 13 +++ Shared/LifecycleKit/README.md | 39 +++++---- Shared/LifecycleKit/Sources/LaunchPlan.swift | 86 ++++++++++++------- .../Sources/LifecycleRunner.swift | 4 +- .../LifecycleKit/Sources/LifecycleStep.swift | 17 ++-- .../LifecycleKit/Tests/LaunchPlanTests.swift | 2 +- .../Tests/LifecycleStepFixtures.swift | 8 +- .../Sources/LifecycleContainer.swift | 2 +- .../Sources/LifecycleProxy.swift | 2 +- .../Tests/LifecycleKitUITestSupport.swift | 8 +- .../WhereUI/Sources/Launch/WhereLaunch.swift | 8 +- .../Sources/Launch/WhereLaunchSteps.swift | 24 +++--- .../Tests/DetachedFailureReporterTests.swift | 4 +- Where/WhereUI/Tests/WhereLaunchTests.swift | 4 +- Where/WhereUI/Tests/WhereResetTests.swift | 6 +- 16 files changed, 144 insertions(+), 92 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d0ca4f9c..e74d623c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/Shared/LifecycleKit/AGENTS.md b/Shared/LifecycleKit/AGENTS.md index e4c65a30..8aa67648 100644 --- a/Shared/LifecycleKit/AGENTS.md +++ b/Shared/LifecycleKit/AGENTS.md @@ -26,6 +26,19 @@ system, formatting, and global conventions. Read that first. 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 diff --git a/Shared/LifecycleKit/README.md b/Shared/LifecycleKit/README.md index 05e58a57..8dff3f59 100644 --- a/Shared/LifecycleKit/README.md +++ b/Shared/LifecycleKit/README.md @@ -64,7 +64,8 @@ Add it to a target's dependencies in [`Package.swift`](../../Package.swift): @MainActor public protocol LifecycleStep { associatedtype Input: Sendable associatedtype Output: Sendable - var id: AnyHashable { get } // a typed enum case, ideally + 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 } @@ -73,20 +74,25 @@ Add it to a target's dependencies in [`Package.swift`](../../Package.swift): // Pass-through by construction; foreground-only by default. @MainActor public protocol LifecycleGate { associatedtype Value: Sendable - var id: AnyHashable { get } + associatedtype ID: Hashable & Sendable + var id: ID { get } var modes: LifecycleModeSet { get } // defaults to .foreground func isNeeded(_ value: Value) async -> Bool } -// The typed tree. Input is 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 - public func then(_ step: S) -> LaunchPlan where S.Input == Output - public func thenKeeping(_ step: S) -> Self where S.Input == Output, S.Output == Void - public func gate(_ gate: G) -> Self where G.Value == Output - public func detached(@DetachedChildrenBuilder _ children: ...) -> Self - public var nodeIDs: [AnyHashable] // introspection for tests/tools +// 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 } // The engine, generic over the launch's output. @@ -102,10 +108,11 @@ Add it to a target's dependencies in [`Package.swift`](../../Package.swift): public private(set) var detachedFailures: [LifecycleFailure] // off-phase diagnostics public init(reason: LifecycleReason, initializePrerequisites: @MainActor () -> Void = {}, - plan: LaunchPlan) + 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 + public func teardown(_ plan: LaunchPlan, + input: In) async } // The engine-minted token for one parked gate — the only way to resume it. @@ -131,14 +138,14 @@ exists) and drive it: ```swift struct OpenStoreStep: LifecycleStep { let deps: Dependencies - let id: AnyHashable = StepID.openStore + let id = StepID.openStore func run(_: Void, _: LifecycleStepContext) async throws -> Services { try await deps.openStore() // the process's ONE store open } } struct StartSessionStep: LifecycleStep { - let id: AnyHashable = StepID.startSession + let id = StepID.startSession func run(_ services: Services, _: LifecycleStepContext) async throws -> Session { Session(services: services) // scope grows by embedding } @@ -146,7 +153,7 @@ struct StartSessionStep: LifecycleStep { struct OnboardingGate: LifecycleGate { let deps: Dependencies - let id: AnyHashable = StepID.onboarding + let id = StepID.onboarding func isNeeded(_: Session) async -> Bool { !deps.hasOnboarded } } diff --git a/Shared/LifecycleKit/Sources/LaunchPlan.swift b/Shared/LifecycleKit/Sources/LaunchPlan.swift index 478072b1..ddee4b72 100644 --- a/Shared/LifecycleKit/Sources/LaunchPlan.swift +++ b/Shared/LifecycleKit/Sources/LaunchPlan.swift @@ -4,11 +4,16 @@ /// `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`. +/// 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 @@ -20,25 +25,31 @@ /// runner can memoize heterogeneous outputs and re-walk the trunk on a /// promotion; the combinators' constraints guarantee every internal cast. @MainActor -public struct LaunchPlan { +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. - public var nodeIDs: [AnyHashable] { - nodes.flatMap(\.ids) + /// + /// 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 `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 { + /// 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))) } @@ -47,10 +58,10 @@ public struct LaunchPlan { /// 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 + public func then(_ step: S) -> LaunchPlan + where S.Input == Output, S.ID == ID { - LaunchPlan(nodes: nodes) + LaunchPlan(nodes: nodes) .appending(.step(StepNode(producing: step))) } @@ -58,8 +69,8 @@ public struct LaunchPlan { /// 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 + public func thenKeeping(_ step: S) -> LaunchPlan + where S.Input == Output, S.Output == Void, S.ID == ID { appending(.step(StepNode(keeping: step))) } @@ -68,8 +79,8 @@ public struct LaunchPlan { /// (`isNeeded`), awaiting external resolution through a /// `LifecycleGateHandle`. Pass-through by construction — see /// `LifecycleGate`. - public func gate(_ gate: G) -> LaunchPlan - where G.Value == Output + public func gate(_ gate: G) -> LaunchPlan + where G.Value == Output, G.ID == ID { appending(.gate(GateNode(erasing: gate))) } @@ -80,8 +91,9 @@ public struct LaunchPlan { /// never blocks `.ready`). The trunk continues immediately with its value /// unchanged. public func detached( - @DetachedChildrenBuilder _ children: @MainActor () -> [DetachedChild], - ) -> LaunchPlan { + @DetachedChildrenBuilder _ children: @MainActor () + -> [DetachedChild], + ) -> LaunchPlan { appending(.detached(children().map(\.node))) } @@ -112,10 +124,12 @@ public struct LaunchPlan { /// value and whose `Output` is `Void`, so a detached step that produces a /// value anyone could depend on is unspellable. @MainActor -public struct DetachedChild { +public struct DetachedChild { let node: DetachedNode - public init(_ step: S) where S.Input == Value, S.Output == Void { + public init(_ step: S) + where S.Input == Value, S.Output == Void, S.ID == ID + { node = DetachedNode( id: step.id, modes: step.modes, @@ -132,46 +146,52 @@ public struct DetachedChild { /// constraints live. @resultBuilder @MainActor -public enum DetachedChildrenBuilder { - public static func buildExpression(_ step: S) -> [DetachedChild] - where S.Input == Value, S.Output == Void +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] { + public static func buildExpression(_ child: DetachedChild) + -> [DetachedChild] + { [child] } - public static func buildBlock(_ children: [DetachedChild]...) -> [DetachedChild] { + public static func buildBlock(_ children: [DetachedChild]...) + -> [DetachedChild] + { children.flatMap(\.self) } - public static func buildOptional(_ children: [DetachedChild]?) - -> [DetachedChild] + public static func buildOptional(_ children: [DetachedChild]?) + -> [DetachedChild] { children ?? [] } - public static func buildEither(first children: [DetachedChild]) - -> [DetachedChild] + public static func buildEither(first children: [DetachedChild]) + -> [DetachedChild] { children } - public static func buildEither(second children: [DetachedChild]) - -> [DetachedChild] + public static func buildEither(second children: [DetachedChild]) + -> [DetachedChild] { children } - public static func buildArray(_ children: [[DetachedChild]]) -> [DetachedChild] { + public static func buildArray(_ children: [[DetachedChild]]) + -> [DetachedChild] + { children.flatMap(\.self) } public static func buildLimitedAvailability( - _ children: [DetachedChild], - ) -> [DetachedChild] { + _ children: [DetachedChild], + ) -> [DetachedChild] { children } } diff --git a/Shared/LifecycleKit/Sources/LifecycleRunner.swift b/Shared/LifecycleKit/Sources/LifecycleRunner.swift index b1c56941..a16df78d 100644 --- a/Shared/LifecycleKit/Sources/LifecycleRunner.swift +++ b/Shared/LifecycleKit/Sources/LifecycleRunner.swift @@ -107,7 +107,7 @@ public final class LifecycleRunner { public init( reason: LifecycleReason, initializePrerequisites: @MainActor () -> Void = {}, - plan: LaunchPlan, + plan: LaunchPlan, ) { state = .notStarted(reason) launchNodes = plan.nodes @@ -164,7 +164,7 @@ public final class LifecycleRunner { /// 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, + _ plan: LaunchPlan, input: Input, ) async { await teardownErased(nodes: plan.nodes, input: input) diff --git a/Shared/LifecycleKit/Sources/LifecycleStep.swift b/Shared/LifecycleKit/Sources/LifecycleStep.swift index cd0e706a..bd2b43d6 100644 --- a/Shared/LifecycleKit/Sources/LifecycleStep.swift +++ b/Shared/LifecycleKit/Sources/LifecycleStep.swift @@ -18,12 +18,15 @@ public protocol LifecycleStep { 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 - /// Stable identity used for run-once memoization and 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. - var id: AnyHashable { get } + /// Stable identity used for run-once memoization and tests. Unique within + /// its plan, which `LaunchPlan` `precondition`s at construction. + var id: ID { get } /// Which launch reasons this step runs under. Read once, when the plan is /// built. Only pass-through positions may gate: `LaunchPlan.thenKeeping` @@ -59,10 +62,12 @@ extension LifecycleStep { 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 /// Stable identity used for run-once memoization and tests. Same /// conventions as `LifecycleStep.id`. - var id: AnyHashable { get } + var id: ID { get } /// 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 diff --git a/Shared/LifecycleKit/Tests/LaunchPlanTests.swift b/Shared/LifecycleKit/Tests/LaunchPlanTests.swift index d24952af..f7138cc7 100644 --- a/Shared/LifecycleKit/Tests/LaunchPlanTests.swift +++ b/Shared/LifecycleKit/Tests/LaunchPlanTests.swift @@ -92,7 +92,7 @@ struct LaunchPlanTests { } @Test func detachedBuilderSupportsConditionalsAndLoops() { - func makePlan(includeExtra: Bool) -> LaunchPlan { + func makePlan(includeExtra: Bool) -> LaunchPlan { LaunchPlan(FixtureStep("open") { _, _ in "session" }) .detached { FixtureStep("always") { _, _ in } diff --git a/Shared/LifecycleKit/Tests/LifecycleStepFixtures.swift b/Shared/LifecycleKit/Tests/LifecycleStepFixtures.swift index 2deb767a..232a04b9 100644 --- a/Shared/LifecycleKit/Tests/LifecycleStepFixtures.swift +++ b/Shared/LifecycleKit/Tests/LifecycleStepFixtures.swift @@ -4,12 +4,12 @@ /// 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: AnyHashable + let id: String var modes: LifecycleModeSet = .all let body: @MainActor (Input, LifecycleStepContext) async throws -> Output init( - _ id: AnyHashable, + _ id: String, modes: LifecycleModeSet = .all, body: @escaping @MainActor (Input, LifecycleStepContext) async throws -> Output, ) { @@ -25,12 +25,12 @@ struct FixtureStep: LifecycleStep { /// A configurable gate for engine/plan tests, mirroring `FixtureStep`. struct FixtureGate: LifecycleGate { - let id: AnyHashable + let id: String var modes: LifecycleModeSet = .foreground var needed: @MainActor (Value) async -> Bool init( - _ id: AnyHashable, + _ id: String, modes: LifecycleModeSet = .foreground, needed: @escaping @MainActor (Value) async -> Bool = { _ in true }, ) { diff --git a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift index cbf8401e..79be9673 100644 --- a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift @@ -321,7 +321,7 @@ extension LifecycleContainer where Failure == LifecycleFailureView { #if DEBUG private struct PreviewStep: LifecycleStep { - let id: AnyHashable = "open" + let id = "open" func run(_: Void, _: LifecycleStepContext) async throws -> String { "session" diff --git a/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift b/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift index c98b6e21..d747b5fe 100644 --- a/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleProxy.swift @@ -53,7 +53,7 @@ public struct LifecycleProxy: Sendable { /// type-checked here, at the call site, and erased only to cross the /// non-generic environment seam. @MainActor public func teardown( - _ plan: LaunchPlan, + _ plan: LaunchPlan, input: Input, file: StaticString = #fileID, line: UInt = #line, diff --git a/Shared/LifecycleKitUI/Tests/LifecycleKitUITestSupport.swift b/Shared/LifecycleKitUI/Tests/LifecycleKitUITestSupport.swift index e66664a4..2c0db86e 100644 --- a/Shared/LifecycleKitUI/Tests/LifecycleKitUITestSupport.swift +++ b/Shared/LifecycleKitUI/Tests/LifecycleKitUITestSupport.swift @@ -36,12 +36,12 @@ struct ProbeView: View { /// 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: AnyHashable + let id: String var modes: LifecycleModeSet = .all let body: @MainActor (Input, LifecycleStepContext) async throws -> Output init( - _ id: AnyHashable, + _ id: String, modes: LifecycleModeSet = .all, body: @escaping @MainActor (Input, LifecycleStepContext) async throws -> Output, ) { @@ -57,12 +57,12 @@ struct FixtureStep: LifecycleStep { /// A configurable gate for container tests, mirroring `FixtureStep`. struct FixtureGate: LifecycleGate { - let id: AnyHashable + let id: String var modes: LifecycleModeSet = .foreground var needed: @MainActor (Value) async -> Bool init( - _ id: AnyHashable, + _ id: String, modes: LifecycleModeSet = .foreground, needed: @escaping @MainActor (Value) async -> Bool = { _ in true }, ) { diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index 78efd992..054bafc9 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -8,7 +8,7 @@ import WhereCore /// `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 { +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" @@ -191,7 +191,7 @@ public enum WhereLaunch { for model: WhereModel, bootstrap: WhereBootstrap = WhereBootstrap(), onServicesReady: @escaping @MainActor (WhereServices) async -> Void = { _ in }, - ) -> LaunchPlan { + ) -> LaunchPlan { LaunchPlan(OpenStoreStep(model: model, bootstrap: bootstrap)) .then(StartSessionStep(model: model, onServicesReady: onServicesReady)) .gate(OnboardingGate(model: model)) @@ -212,7 +212,9 @@ public enum WhereLaunch { /// .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 { + 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 index 2cca30d5..c92083c4 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift @@ -23,7 +23,7 @@ struct OpenStoreStep: LifecycleStep { let model: WhereModel let bootstrap: WhereBootstrap - let id: AnyHashable = LaunchStepID.openStore + let id = LaunchStepID.openStore func run(_: Void, _: LifecycleStepContext) async throws -> WhereServices { if let services = model.services { return services } @@ -43,7 +43,7 @@ struct StartSessionStep: LifecycleStep { let model: WhereModel let onServicesReady: @MainActor (WhereServices) async -> Void - let id: AnyHashable = LaunchStepID.startSession + let id = LaunchStepID.startSession func run(_ services: WhereServices, _: LifecycleStepContext) async throws -> WhereSession { let session = model.startSession(services: services) @@ -60,7 +60,7 @@ struct StartSessionStep: LifecycleStep { struct OnboardingGate: LifecycleGate { let model: WhereModel - let id: AnyHashable = LaunchStepID.onboarding + let id = LaunchStepID.onboarding func isNeeded(_: WhereSession) async -> Bool { !model.hasOnboarded @@ -71,7 +71,7 @@ struct OnboardingGate: LifecycleGate { /// authorization + region-style changes. Stays on the trunk: the /// reconcile-tracking step must see the synced authorization. struct SyncAuthStep: LifecycleStep { - let id: AnyHashable = LaunchStepID.syncAuth + let id = LaunchStepID.syncAuth func run(_ session: WhereSession, _: LifecycleStepContext) async throws { await session.syncAuthorization() @@ -85,7 +85,7 @@ struct SyncAuthStep: LifecycleStep { /// Stays on the trunk, after `SyncAuthStep`, so it reconciles against the /// authorization that step just synced. struct ReconcileTrackingStep: LifecycleStep { - let id: AnyHashable = LaunchStepID.reconcileTracking + let id = LaunchStepID.reconcileTracking func run(_ session: WhereSession, _: LifecycleStepContext) async throws { await session.reconcileTracking() @@ -97,7 +97,7 @@ struct ReconcileTrackingStep: LifecycleStep { /// 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: AnyHashable = LaunchStepID.captureToday + let id = LaunchStepID.captureToday let modes: LifecycleModeSet = .foreground func run(_ session: WhereSession, _: LifecycleStepContext) async throws { @@ -108,7 +108,7 @@ struct CaptureTodayStep: LifecycleStep { /// Push the logging-reminder schedule + badge (backlog + issue count) to the /// reconciler. struct RemindersStep: LifecycleStep { - let id: AnyHashable = LaunchStepID.reminders + let id = LaunchStepID.reminders func run(_ session: WhereSession, _: LifecycleStepContext) async throws { await session.applyReminderConfiguration() @@ -117,7 +117,7 @@ struct RemindersStep: LifecycleStep { /// Push the daily-summary recap to the reconciler. struct SummaryStep: LifecycleStep { - let id: AnyHashable = LaunchStepID.summary + let id = LaunchStepID.summary func run(_ session: WhereSession, _: LifecycleStepContext) async throws { await session.applySummaryConfiguration() @@ -126,7 +126,7 @@ struct SummaryStep: LifecycleStep { /// Push the "issues to resolve" notification intent to its reconciler. struct IssueAlertsStep: LifecycleStep { - let id: AnyHashable = LaunchStepID.issueAlerts + let id = LaunchStepID.issueAlerts func run(_ session: WhereSession, _: LifecycleStepContext) async throws { await session.applyIssueAlertConfiguration() @@ -137,7 +137,7 @@ struct IssueAlertsStep: LifecycleStep { /// launch with no writes this session doesn't leave the widget blank or /// showing the previous day's "today". struct WidgetSnapshotStep: LifecycleStep { - let id: AnyHashable = LaunchStepID.widgetSnapshot + let id = LaunchStepID.widgetSnapshot func run(_ session: WhereSession, _: LifecycleStepContext) async throws { await session.refreshWidgetSnapshot() @@ -156,7 +156,7 @@ struct WidgetSnapshotStep: LifecycleStep { struct EraseDataStep: LifecycleStep { let model: WhereModel - let id: AnyHashable = LaunchStepID.eraseData + let id = LaunchStepID.eraseData func run(_ session: WhereSession, _: LifecycleStepContext) async throws { try await session.eraseSession() @@ -172,7 +172,7 @@ struct EraseDataStep: LifecycleStep { struct ResetPreferencesStep: LifecycleStep { let model: WhereModel - let id: AnyHashable = LaunchStepID.resetPreferences + let id = LaunchStepID.resetPreferences func run(_: Void, _: LifecycleStepContext) async throws { model.resetPreferences() diff --git a/Where/WhereUI/Tests/DetachedFailureReporterTests.swift b/Where/WhereUI/Tests/DetachedFailureReporterTests.swift index 5a7558f4..5aa9f8db 100644 --- a/Where/WhereUI/Tests/DetachedFailureReporterTests.swift +++ b/Where/WhereUI/Tests/DetachedFailureReporterTests.swift @@ -24,7 +24,7 @@ private func waitUntil( /// A minimal typed root so a reporter test can drive a real runner without /// Where's services. private struct RootStep: LifecycleStep { - let id: AnyHashable = "root" + let id = "root" func run(_: Void, _: LifecycleStepContext) async throws -> String { "value" @@ -33,7 +33,7 @@ private struct RootStep: LifecycleStep { /// A detached child that always throws, landing on `detachedFailures`. private struct ThrowingChildStep: LifecycleStep { - let id: AnyHashable + let id: String func run(_: String, _: LifecycleStepContext) async throws { throw FanError() diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index a1327cb5..f827d196 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -101,7 +101,7 @@ struct WhereLaunchTests { let model = try makeModel(preferences: makePreferences()) let ids = WhereLaunch.plan(for: model).nodeIDs #expect(ids == [ - LaunchStepID.openStore, + .openStore, .startSession, .onboarding, .syncAuth, @@ -111,7 +111,7 @@ struct WhereLaunchTests { .summary, .issueAlerts, .widgetSnapshot, - ].map { AnyHashable($0) }) + ]) } @Test func coldForegroundLaunchReachesReadyAndReconcilesTracking() async throws { diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index efab3f58..a667ca16 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -75,7 +75,7 @@ struct WhereResetTests { @Test func resetPlanErasesThenClearsPreferences() throws { let model = try makeModel(preferences: makePreferences()) let ids = WhereLaunch.resetPlan(for: model).nodeIDs - #expect(ids == [LaunchStepID.eraseData, .resetPreferences].map { AnyHashable($0) }) + #expect(ids == [.eraseData, .resetPreferences]) } @Test func resetPreferencesRestoresFirstInstallDefaults() throws { @@ -289,7 +289,7 @@ struct WhereResetTests { /// 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: AnyHashable = LaunchStepID.eraseData + let id = LaunchStepID.eraseData func run(_: WhereSession, _: LifecycleStepContext) async throws { throw CocoaError(.fileWriteUnknown) @@ -301,7 +301,7 @@ private struct FailingEraseStep: LifecycleStep { private struct ResetPreferencesProbeStep: LifecycleStep { let model: WhereModel - let id: AnyHashable = LaunchStepID.resetPreferences + let id = LaunchStepID.resetPreferences func run(_: Void, _: LifecycleStepContext) async throws { model.resetPreferences() From 98ec3dbfd0f2e461eccf5c47a5723ad6bcdbc1b8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 15:23:00 -0700 Subject: [PATCH 17/17] Hide the warmed-up content from VoiceOver while a surface covers it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building `content` during the splash hold put the app UI in the tree behind an opaque splash — visually hidden, but still reachable. The splash's own `.accessibilityElement(children: .ignore)` collapses only its subtree, not siblings beneath it in the ZStack, so for the length of the hold (800 ms in Where) VoiceOver could focus MainTabs elements while the screen showed the splash. Found in review; the exposure was introduced by the warm-up change, since before it there was nothing behind the splash to focus. `LaunchOverlay.coversContent` now drives `.accessibilityHidden` and `.allowsHitTesting` on the content, so one definition decides "covered" for sight, touch, and assistive technology alike, and a new overlay case is a compile error there. Fixed in the container rather than in Where — any host layering a surface over its content has the same hole. The surface decision also moved into a pure `overlay(for:canRevealReady:)`. That makes the regressing case testable without hosting: held `.ready` depends on view `@State` that `show`'s synchronous closure can't drive, which is why the hold's behavior was previously device-only. The new test pins all six cases, including `.ready` + `canRevealReady: false` — content built, splash still on top. Full Stuff-iOS-Tests green (1383 tests), swiftformat clean. --- .../Sources/LifecycleContainer.swift | 44 ++++++++++++++++--- .../Tests/LifecycleContainerTests.swift | 21 +++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift index 79be9673..1534cdc0 100644 --- a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift @@ -194,14 +194,26 @@ public struct LifecycleContainer< /// (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 { - ZStack { + 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 + launchSurface(overlay) } } @@ -210,16 +222,36 @@ public struct LifecycleContainer< /// 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. - private enum LaunchOverlay { + 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 { - switch runner.phase { + 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) @@ -230,8 +262,8 @@ public struct LifecycleContainer< } } - @ViewBuilder private var launchSurface: some View { - switch launchOverlay { + @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): diff --git a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift index 4d5550e6..7c3ef130 100644 --- a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift +++ b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift @@ -93,6 +93,27 @@ struct LifecycleContainerTests { #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