Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e2c1b68
Add typed core model to LifecycleKit (step protocol + LaunchPlan)
kyleve Jul 20, 2026
d4b464e
Add the typed LifecycleRunner<Launch> that walks a LaunchPlan
kyleve Jul 20, 2026
f06a76c
Add the LifecycleKitUI target: container, gate-view registry, proxy
kyleve Jul 20, 2026
8bb45b1
Migrate the Where launch and reset onto the typed LaunchPlan
kyleve Jul 20, 2026
0379d5b
Delete the legacy linear API; core drops SwiftUI; docs refreshed
kyleve Jul 20, 2026
8988ab2
Fail fast when a teardown plan reuses launch node IDs
kyleve Jul 20, 2026
51e29f9
Mirror detached-step failures into WhereLog
kyleve Jul 20, 2026
ae3b295
Fail an unregistered parked gate onto the failure surface
kyleve Jul 22, 2026
286590a
Prototype D: #116's declarative engine with retry removed (#121)
kyleve Jul 24, 2026
f6baa70
Merge main into typed-lifecyclekit
kyleve Jul 24, 2026
5f17222
Simplify the merge resolution: collapse the splash-hold state, dedup …
kyleve Jul 24, 2026
2d405be
docs: file three launch observations from a fresh-install capture
kyleve Jul 25, 2026
766e052
Drop the trailing newline from the LifecycleKitUI catalog
kyleve Jul 25, 2026
5227d48
Merge main into typed-lifecyclekit (String Catalog normalization, #135)
kyleve Jul 25, 2026
ba0839d
Build the destination during the splash hold, not at the reveal
kyleve Jul 25, 2026
6a85986
Drop stale retry references from docs
kyleve Jul 25, 2026
f70abfd
Merge main into typed-lifecyclekit
kyleve Jul 26, 2026
4635a42
Restore main's .bumper sources verbatim
kyleve Jul 26, 2026
f73ce45
Make LaunchPlan generic over its node ID type
kyleve Jul 26, 2026
98ec3db
Hide the warmed-up content from VoiceOver while a surface covers it
kyleve Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,11 @@ that nothing in this repo depends on a skill having been loaded.
### Never double-link a product a dynamic framework already carries

A target that depends on **WhereUI** must not also list any of WhereUI's own
dependencies (WhereCore, Broadway, LifecycleKit, Periscope, SwiftDataInspector,
…) in `extraPackageProducts` — reach them transitively. A second copy splits the
module's type metadata across the WhereUI boundary and every type-keyed lookup
(SwiftUI `EnvironmentKey`s, Broadway's `BTraits`/`BThemes`/`BStylesheets`)
silently resolves against the wrong one.
dependencies (WhereCore, Broadway, LifecycleKit/LifecycleKitUI, Periscope,
SwiftDataInspector, …) in `extraPackageProducts` — reach them transitively. A
second copy splits the module's type metadata across the WhereUI boundary and
every type-keyed lookup (SwiftUI `EnvironmentKey`s, Broadway's
`BTraits`/`BThemes`/`BStylesheets`) silently resolves against the wrong one.

Worth knowing rather than rediscovering: it reproduces only in the full
multi-bundle scheme, never in an isolated `tuist test WhereUITests`. The
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down Expand Up @@ -38,6 +39,13 @@ let package = Package(
.target(
name: "LifecycleKit",
path: "Shared/LifecycleKit/Sources",
),
.target(
name: "LifecycleKitUI",
dependencies: [
.target(name: "LifecycleKit"),
],
path: "Shared/LifecycleKitUI/Sources",
resources: [
.process("Resources"),
],
Expand Down Expand Up @@ -107,6 +115,7 @@ let package = Package(
.target(name: "BroadwayCore"),
.target(name: "BroadwayUI"),
.target(name: "LifecycleKit"),
.target(name: "LifecycleKitUI"),
.target(name: "PeriscopeCore"),
.target(name: "PeriscopeTools"),
.target(name: "PeriscopeUI"),
Expand Down
17 changes: 14 additions & 3 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -281,6 +282,13 @@ let project = Project(
productDependency: "LifecycleKit",
sources: ["Shared/LifecycleKit/Tests/**"],
),
unitTests(
name: "LifecycleKitUITests",
bundleIdSuffix: "lifecyclekitui",
productDependency: "LifecycleKitUI",
sources: ["Shared/LifecycleKitUI/Tests/**"],
extraPackageProducts: ["LifecycleKit"],
),
unitTests(
name: "JournalKitTests",
bundleIdSuffix: "journalkit",
Expand Down Expand Up @@ -340,7 +348,7 @@ let project = Project(
// BTraits/BThemes/BStylesheets containers) then silently resolves against
// the wrong copy — the writer stores under one copy's key type, the
// reader looks it up under another's. Everything the tests need
// (BroadwayCore/BroadwayUI, LifecycleKit, PeriscopeCore/UI/Tools,
// (BroadwayCore/BroadwayUI, LifecycleKit/LifecycleKitUI, PeriscopeCore/UI/Tools,
// SwiftDataInspector, RegionKit + its GeoJSON bundle) is reached
// transitively through WhereUI.
// See "Never double-link a product a dynamic framework already
Expand Down Expand Up @@ -435,6 +443,7 @@ let project = Project(
"StuffTestHost",
"StuffCoreTests",
"LifecycleKitTests",
"LifecycleKitUITests",
"JournalKitTests",
"PeriscopeCoreTests",
"PeriscopeUITests",
Expand All @@ -453,6 +462,7 @@ let project = Project(
testAction: .targets([
"StuffCoreTests",
"LifecycleKitTests",
"LifecycleKitUITests",
"JournalKitTests",
"PeriscopeCoreTests",
"PeriscopeUITests",
Expand All @@ -470,6 +480,7 @@ let project = Project(
),
testScheme(name: "StuffCoreTests"),
testScheme(name: "LifecycleKitTests"),
testScheme(name: "LifecycleKitUITests"),
testScheme(name: "JournalKitTests"),
testScheme(name: "PeriscopeCoreTests"),
testScheme(name: "PeriscopeUITests"),
Expand Down
90 changes: 58 additions & 32 deletions Shared/LifecycleKit/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,73 @@
# LifecycleKit – Module Shape

LifecycleKit is an app-agnostic SwiftUI microframework that models app startup
(and its reverse, teardown) as an ordered, conditional, launch-reason-aware
sequence of async steps: a `@MainActor @Observable` `LifecycleRunner` walks a
`LifecycleSteps` sequence and publishes one `phase`; `LifecycleContainer`
renders it. See [`README.md`](README.md) for the full narrative and API.
LifecycleKit is an app-agnostic engine that models app startup (and its
reverse, teardown) as a **typed plan**: steps are types with concrete
`Input`/`Output`, a `LaunchPlan` composes them into a sequential trunk plus
concurrent detached fan-outs with the data flow checked at compile time, and
a `@MainActor @Observable` `LifecycleRunner<Launch>` walks the plan and
publishes one value-carrying `phase`. Rendering lives in
[LifecycleKitUI](../LifecycleKitUI/AGENTS.md). See [`README.md`](README.md)
for the full narrative and API.

This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns build
system, formatting, and global conventions. Read that first.

## Scope & dependencies

- Pure **SwiftUI + Foundation + Observation**. It must **not** import
WhereCore, UIKit, or any app code — app-specific launch logic lives in the
consumer (e.g. `WhereUI/Sources/Launch/`).
- Steps and the engine are `@MainActor`; heavy work hops to an actor *inside*
a step's `perform`, never by loosening isolation on the step.
- Pure **Foundation + Observation**. It must **not** import SwiftUI, UIKit,
WhereCore, or any app code — views belong in LifecycleKitUI; app-specific
launch logic lives in the consumer (e.g. `WhereUI/Sources/Launch/`).
- Steps, gates, and the engine are `@MainActor`; heavy work hops to an actor
*inside* a step's `run`, never by loosening isolation on the step.

## Invariants

- **The type erasure has exactly one home.** `LaunchPlan`'s combinators erase
steps into `LaunchPlanNode` (package-visible for the runner and the UI
proxy seam); their generic constraints guarantee every internal cast. Never
add a second erasure site or a public API that traffics in `Any`.
- **One identity domain per plan.** `LaunchPlan` is generic over
`ID: Hashable & Sendable` and every combinator requires the node's
`ID` to match, so a plan can't mix domains and a node keyed for another
plan can't be composed in; `nodeIDs` gives back `[ID]`, not erased keys.
IDs erase to `AnyHashable` *inside* `LaunchPlanNode` and stay erased from
there on — the runner's memo, `LifecycleFailure.stepID`,
`LifecycleStepContext.stepID`, and `LifecycleGateHandle.id` are all
`AnyHashable`, deliberately: `LifecycleDriving` (the seam behind the
non-generic `\.lifecycle` environment value) traffics in `[LaunchPlanNode]`,
so pushing `ID` past the plan would force it onto the runner, the container's
generic list, and every splash/failure/gate closure. If you want typed
`failed(at:)` / `isRunning(_:)` assertions, that's the (deliberate) cost to
price in — it isn't an oversight.
- **Only pass-through positions may skip.** Value-producing (`init`/`then`)
steps must keep `modes == .all` (plan-construction `precondition`) — a
skipped producer would leave a hole in the data flow. Don't add a skip path
for them.
- **Failure is terminal.** A thrown node parks `.failed` with no retry — the
recovery is relaunching the app. A failed teardown likewise parks and does
not relaunch (a thrown erase leaves state intact). Don't reintroduce a
resume/retry path; if a node is genuinely flaky, retry inside it at the
layer that understands the failure.
- **All drives funnel through a single in-flight task** (cancel-and-drain):
two drives never overlap, and `teardown()`/`enterForeground()` can interrupt
a launch parked on an interactive step. A cancelled drive is distinct from a
thrown step (`.failed`). Don't add a drive path that bypasses that
serialization.
- **Launches with no window build no view tree.** `LifecycleContainer` returns
`EmptyView()` whenever `runner.reason.buildsNoViewTree` (a `.background`
relaunch, or an `.undetermined` one not yet promoted) — even at `.ready` — so
`content()` is never constructed for a launch nobody sees.
two drives never overlap, and `teardown()`/`enterForeground()` can
interrupt a launch parked on a gate. A cancelled drive is distinct from a
thrown node (`.failed`), a superseded drive never writes the phase the new
drive owns, and a superseded drive's gate handle resolves to a no-op.
Don't add a drive path that bypasses that serialization.
- **Memoized run-once, for promotion.** Completed nodes' outputs are
memoized so an `enterForeground()` promotion's re-walk skips completed
work; skipped gates are deliberately *not* memoized so they re-evaluate on
promotion. Fresh attempts (first `run()`, the start of a teardown, the
post-teardown relaunch) clear the memo — so teardown plans may freely reuse
launch node IDs (no live shared memo, since there is no retry re-walk).
- **Detached children are off the critical path by construction:** they never
block `.ready`, never fail the drive, and surface failures only on
`detachedFailures`.
- **`.undetermined` is the honest UIScene launch reason.** Under the UIScene
lifecycle `UIApplication.applicationState` reads `.background` at
`didFinishLaunching` even for a user tap, so a consumer that can't yet tell a
headless wake from a user launch should launch `.undetermined` rather than
fabricate a `.background(cause)`. It gates to the background-safe steps and
fabricate a `.background(cause)`. It gates to the background-safe nodes and
builds no view tree until `enterForeground()` promotes it; if no scene ever
connects it honestly stays `.undetermined`, never claiming a cause it didn't
observe.
Expand All @@ -41,20 +76,11 @@ system, formatting, and global conventions. Read that first.
repeat call costs nothing while `.background` and `.undetermined` both
promote; consumers must only call it once the scene is genuinely `.active`
(see `RootView` in WhereUI for the `scenePhase` gating pattern).
- **Each step runs at most once per launch attempt.** A completed step is
recorded in `completedStepIDs`, and a re-drive (`enterForeground()` promotion)
skips it — so a work step that already serviced the windowless drive isn't
repeated when foreground-only steps get their turn. Only steps that actually
ran to completion are recorded (a mode/condition-skipped or cancelled step
isn't), so promotion still runs the newly-applicable steps and re-evaluates
conditions. The set resets on a *fresh* attempt (first `run()`, and the
relaunch after `teardown()`), so a reset re-runs everything; it's preserved
across `retry()`, which resumes from the failed step.

## Testing

Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost`. Engine tests
build a `LifecycleSteps` and assert on `phase`; view tests host
`LifecycleContainer` and assert which branch renders; seeded fuzz tests
(`LifecycleRunnerFuzzTests`) replay failures exactly. Keep tests
deterministic — gate async steps on test-controlled continuations, not timing.
build a `LaunchPlan` from the shared `FixtureStep`/`FixtureGate` fixtures and
assert on `phase`; seeded fuzz tests (`LifecycleRunnerFuzzTests`) replay
failures exactly against an independent model. Keep tests deterministic —
park async steps on test-controlled streams/handles, not timing.
Loading
Loading