What will you build?
The Kotlin module you wrote in Tutorial 1 becomes one static framework,FoyerKit, built by Gradle and linked by two Swift packages: a test-only package that replays the recordings across the boundary, and the app’s consumer package that holds the bridge and the shell. The iOS app is a scene delegate, a host object and two SwiftUI views; the Android app is an Activity, the same host in Kotlin and two composables. Neither app contains a line of feature logic. The splash reveals the app’s name over 1.6 seconds, sends CeremonyFinished when the reveal ends, and the host swaps in a placeholder that names which path completed it.
This is the most setup-heavy tutorial in the series. The framework assembly is the setup the Modaal wizard automates; here you write it by hand, in three files and one script. Expect about two hours, most of it in the first four steps.
You will have at the end:
src-kmp/apple-umbrella: the framework module and the replay boundary it exports, assembled byscripts/assemble_kit.sh.- Six passing tests in
src-kmp/apple-umbrella/swift-consumer: the four recordings replayed across the boundary, plus two on the error channel. src-ios/Libraries/FoyerKit: theFoyerBridgetarget with the store mirror, theSplashShelltarget with the shell, the builder and the view, and three passing shell tests.src-ios/App: the iOS app, built from an XcodeGen spec.src-kmp/app: the Android app, with two passing host tests on the JVM.- A green
parity/scripts/apple-boundary-lane.sh, andtools/duet doctorreporting two target declarations.
Where do you start?
Opentutorial2-start from the duet-tutorials repository. It is Tutorial 1’s finished tree plus one failing test, the closing exercise, and it resolves Duet 0.7.0, duet-tools 0.24.0 and duet-services 0.11.1. You need Xcode 26.6, a JDK 25, XcodeGen (brew install xcodegen) and the Android SDK with platform 36; the series index lists them. Run the checks once before you edit anything:
Tutorial2ExerciseMountBracketTest; that is the exercise, and everything else is green. TUTORIAL_SKIP_STUBS=1 tools/duet verify leaves the stub out and ends with duet verify: PASS.
The steps
Declare the Apple framework module
src-kmp/apple-umbrella. It applies the multiplatform plugin and SKIE, the compiler plugin that projects sealed hierarchies as Swift enums and StateFlow as an async sequence. Add the SKIE version and plugin to the version catalog:apply false, and include the module in the settings file:export is what puts a dependency’s declarations in the framework’s headers; a module that is linked but not exported compiles, and Swift cannot name a single type from it.(cd src-kmp && ./gradlew projects -q); the hierarchy now lists Project ':apple-umbrella'.Export the replay boundary
commonMain produces no XCFramework at all, so the module needs a source file, and there is one thing that is per app rather than per feature: the replay registry the Swift side drives. It names the same four declarations the Kotlin replay runner names, one entry per feature, and exposes two calls: canonicalize any fixture JSON through the core’s own writer, and open a replay session over a registered feature.@Throws annotations are a contract, not a style: a Kotlin exception that crosses Kotlin/Native without one terminates the process. Build the framework once with (cd src-kmp && ./gradlew :apple-umbrella:assembleFoyerKitDebugXCFramework). The first build downloads the Kotlin/Native toolchain and compiles three targets; allow ten minutes. It ends with BUILD SUCCESSFUL, and src-kmp/apple-umbrella/build/XCFrameworks/debug/FoyerKit.xcframework exists.Assemble the framework with one script
swift test and the app build always link what was assembled last, and nothing links Gradle’s per-flavor output directories directly.JAVA_HOME when it is unset, because Xcode runs script phases with a minimal environment. Run scripts/assemble_kit.sh debug; its last line is assemble_kit: debug → src-kmp/apple-umbrella/build/XCFrameworks/app/FoyerKit.xcframework.Replay the recordings across the boundary
parity/fixtures/, opens a session, and sends each step’s action through it. The expected state and effects go through the core’s canonicalize before the comparison, so both sides of every assertion come from the same writer.BoundaryErrorChannelTests.swift, pins that an unregistered feature name arrives in Swift as a catchable error carrying the Kotlin message, and that canonicalize orders keys. Run (cd src-kmp/apple-umbrella/swift-consumer && swift test); the summary line reads Executed 6 tests, with 0 failures.Write the consumer package and the store mirror
src-ios/Libraries/FoyerKit. It has a target for the bridge, one shell target per feature, and one test target per shell, and it pins the Duet Swift package exactly at 0.7.0, the same release the Kotlin side resolves.Store is the runtime; BridgedStore is its Swift face: a @Published mirror of the bridged StateFlow, a synchronous send, and a cancel() that stops the Kotlin runtime. The class exists for one reason. A shell must observe a reduce before send returns, and a bridged StateFlow delivers asynchronously, so the mirror re-reads the Kotlin state in the same call.Write the Swift shell
ViewShell from the Duet shells package. Its bind() runs at activate() and adopts two things into the shell’s StoreHost: the store mirror, and a StateTransitions observation that projects each state into the view state. Adopting the mirror first means it unwinds last, after the projection, when deactivate() runs. The two intents are the two things the view reports.NSObject subclass implements it: the clock is the Kotlin LiveClock object, and notifyHost forwards to a closure the host supplies. The builder wires a mount in four lines: a main-immediate scope, the Kotlin store from makeSplashStore, the mirror over splashStateFlow, and the shell.deactivate() reach the Kotlin side: it cancels the store’s effects and then the scope they ran in.Write the SwiftUI view
onAppear reports appeared(), and the animation’s completion reports ceremonyFinished(). The progress line appears when the state says the safety net is armed.(cd src-ios/Libraries/FoyerKit && swift build); it ends with Build complete!.Pin the crossing in the shells lane
appeared() projects back before it returns. The second builds the runtime by hand with a spy environment, so the test can see the Kotlin effect loop call back into Swift with the ceremony path. The third deactivates the shell and shows that a further action still reduces but runs no effect, because the scope is gone.parity/scripts/apple-boundary-lane.sh; it prints boundary replay: 6 test(s) executed, shells: 3 test(s) executed and ends with apple-boundary-lane: PASS.Build the iOS app
onEnum(of:) is SKIE’s projection of the Kotlin sealed interface as a Swift enum. The screen renders the splash while the phase is .splash and the placeholder after:sceneDidDisconnect calls teardown(), because dropping the reference alone cancels nothing on the Kotlin side. The project itself is generated from an XcodeGen spec that names the consumer package’s two products and runs the assemble script as the scheme’s build pre-action:(cd src-ios/App && xcodegen generate --spec xcodegen.yml && xcodebuild build -project Foyer.xcodeproj -scheme Foyer -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 | tail -1); the last line is ** BUILD SUCCEEDED **. ARCHS=arm64 matters: the framework has no x86_64 slice, and a generic simulator build otherwise compiles for both.Build the Compose app
src-kmp/app, on the same plane as the feature modules. It depends on the splash module directly, on the kernel, and on the Duet shells-compose artifact for StoreHost and RetainedRoot.makeSplashStore.StoreHost and tears it down on the first completion:Appeared, plays the reveal, then sends CeremonyFinished. After a rotation the effect runs again; the reducer’s arming guard makes the second Appeared inert, and the host ignores a second completion. The rules you recorded in Tutorial 1 are what make rotation safe here.getOrCreate hands back the same host with its running store; finishing the Activity is the one teardown.AppRoot collects the phase and shows SplashScreen or the placeholder. Include the module in the settings file (include(":app")), then build with (cd src-kmp && ./gradlew :app:assembleDebug); it ends with BUILD SUCCESSFUL.Test the host on the JVM
(cd src-kmp && ./gradlew :app:testDebugUnitTest); it ends with BUILD SUCCESSFUL and the report under src-kmp/app/build/reports/tests/testDebugUnitTest/ lists two tests.Declare the targets and run both apps
tools/duet doctor reads the tree’s declaration file and cross-checks it against what is on disk. Declare the two app targets, paired under one name:tools/duet doctor; it ends with duet doctor: PASS — 2 target declaration(s), 10 Swift source file(s) scanned. Then run the apps. On iOS, open src-ios/App/Foyer.xcodeproj and run the Foyer scheme on an iPhone simulator; the pre-action assembles the core first. On Android, start an emulator and run (cd src-kmp && ./gradlew :app:installDebug), then open Foyer from the launcher. Both apps reveal the name over 1.6 seconds and then show the placeholder with the line Splash completed by the ceremony.
The splash mid-reveal on both platforms: an iPhone 17 simulator on the left and a Pixel 8 emulator at API 36 on the right, from tutorial2-complete at Duet 0.7.0 and duet-tools 0.24.0.
Splash completed by the safety net.What you now have
- One framework,
FoyerKit, assembled from the Kotlin core byscripts/assemble_kit.shand linked by two Swift packages. - The four recordings replayed across the Swift boundary, plus two error-channel tests, in
src-kmp/apple-umbrella/swift-consumer. - A Swift shell with its builder, view and three tests in
src-ios/Libraries/FoyerKit, and a Compose screen with its builder, host and two tests insrc-kmp/app. - Two apps that play the splash from the same store and land on the same placeholder.
- A green
parity/scripts/apple-boundary-lane.sh, a greentools/duet verify, and a doctor report with two targets.
Exercise: finish the mount-bracket test
tutorial2-start carries Tutorial2ExerciseMountBracketTest in the splash module’s jvmTest. Its given is written: one test store, armed, then torn down. Finish it so it pins that a second store over the same environment arms a fresh net, and that after advancing the clock past the duration the environment records exactly one Completed(SafetyNet), the second store’s. The first store’s net was cancelled at teardown, and first.finish() fails on any action that arrived there. Run tools/duet verify; the Kotlin lane reports 10 tests with no failure and the run ends with duet verify: PASS. tutorial2-complete carries the finished test as SplashMountBracketTest.
Common questions
Why a mirror class instead of collecting the StateFlow in the view?
Why a mirror class instead of collecting the StateFlow in the view?
send returns, and every delivery of a bridged StateFlow crosses a continuation hop. BridgedStore.send re-reads the Kotlin state synchronously after forwarding the action, which is the ordering StateTransitions and the other shell helpers assume. Effect-fed actions, such as the safety net’s tick, still arrive through the collector one hop later.Why one framework for the whole app rather than one per feature?
Why one framework for the whole app rather than one per feature?
Why does the shell adopt the store before the projection?
Why does the shell adopt the store before the projection?
StoreHost tears down in reverse registration order. Adopting the store first means the projection stops before the store’s cancel() ends its effects, so no observation fires into a shell that is half torn down. On Android the same registry is StoreHost in AppHost, and RetainedRoot calls its teardownAll on logical destruction.Why is the reveal duration in the view but the safety net in the reducer?
Why is the reveal duration in the view but the safety net in the reducer?
CeremonyFinished arrives.Why do the Swift shell tests run on real time?
Why do the Swift shell tests run on real time?
Where does the Android host state live across rotation?
Where does the Android host state live across rotation?
AppHost that RetainedRoot keeps in the Activity’s InstanceKeeper. Rotation recreates the Activity and the composition, and getOrCreate hands the same host back with its store still running; the composable’s effect sends Appeared again, which the arming guard makes inert. Tutorial 5 adds process-death restore, which this mechanism does not cover.Sources and further reading
- The Duet framework repository — the
DuetShellspackage this page’s shell subclasses (ViewShell,StoreHost,StateTransitions), the Kotlinshells-composeartifact (StoreHost,RetainedRoot), and the kernel’smainImmediateStoreScoperuntime seam. - The duet-tutorials repository —
tutorial2-startandtutorial2-complete, and the checks CI runs on them. - SKIE — the Swift projection of the Kotlin/Native framework: sealed hierarchies as enums,
onEnum(of:), andStateFlowas an async sequence. - Kotlin Multiplatform: build final native binaries —
binaries.framework,export, andXCFramework. - Essenty —
InstanceKeeper, the retained carrier underRetainedRoot. - XcodeGen — the project spec format
src-ios/App/xcodegen.ymluses. - The Duet glossary — shell, host, mount, Store and the Swift and Kotlin lanes.