28 KiB
Purpose: Phase 1 proves the DI + logging wiring is correct from day 1 so Phase 2 (Auth) can add authModule, Phase 4 can add syncModule, etc. without revisiting the bootstrap mechanics. PITFALL #4 (double-init on iOS) is neutralized by concentrating all startup into one initKoin() helper with a single call site per platform.
Output: 9 files created or modified (6 new Kotlin files, 1 manifest edit, 2 existing entry-point rewrites, 1 Swift rewrite). No ViewModels yet — Phase 1 has no screens beyond the template.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md @.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md @.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md @.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md @composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt @composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt @composeApp/src/androidMain/AndroidManifest.xml @composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt @composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt @composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt @iosApp/iosApp/iOSApp.swift @CLAUDE.mdFrom io.insert-koin:koin-core:
fun startKoin(appDeclaration: KoinAppDeclaration): KoinApplication // top-level
interface KoinApplication
typealias KoinAppDeclaration = KoinApplication.() -> Unit
From io.insert-koin:koin-dsl:
fun module(createdAtStart: Boolean = false, moduleDeclaration: ModuleDeclaration): Module
From io.insert-koin:koin-android (androidMain only):
// package org.koin.android.ext.koin
fun KoinApplication.androidContext(context: Context): KoinApplication
From co.touchlab:kermit:
object Logger {
fun setTag(tag: String)
// plus .i { }, .d { }, .e { }, .w { } methods on Logger companion
}
From existing composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt (do NOT modify):
@Composable
@Preview
fun App() { /* template body — stays as-is */ }
From existing composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt (do NOT modify — sibling reference):
package dev.ulfrx.recipe
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
// class MainActivity : ComponentActivity() { ... setContent { App() } }
From existing composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt (do NOT modify):
package dev.ulfrx.recipe
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController { App() }
Current Android manifest shape (attributes to preserve when adding android:name):
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
Current iOS Swift entry (to replace):
import SwiftUI
@main
struct iOSApp: App {
var body: some Scene { WindowGroup { ContentView() } }
}
File 1: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt:
package dev.ulfrx.recipe.di
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = startKoin {
config?.invoke(this)
modules(appModule)
}
File 2: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt:
package dev.ulfrx.recipe.di
import org.koin.dsl.module
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule = module {
// intentionally empty in Phase 1
}
File 3: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt:
package dev.ulfrx.recipe.logging
import co.touchlab.kermit.Logger
fun configureLogging() {
Logger.setTag("recipe")
// Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default.
}
File 4: composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt:
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.logging.configureLogging
fun doInitKoin() {
configureLogging()
initKoin()
}
CRITICAL notes (PITFALL #4 / #10):
- The top-level
fun doInitKoin()in fileKoinIos.ktbecomes the Swift-accessible symbolKoinIosKt.doInitKoin()(Kotlin generates<FileName>Ktfor top-level declarations). doInitKoin()is the SINGLE iOS entry point.MainViewController()(theComposeUIViewControllerfactory) must NOT callstartKoinorinitKoin— it assumes Koin is already started.configureLogging()runs BEFOREinitKoin()so Koin module loading can use Kermit.
Do NOT add any expect/actual declarations — the iOS bridge is a plain top-level function, and Kermit's multiplatform Logger handles the platform-specific writer selection internally.
test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt && test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt && test -f composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt && grep -q 'package dev.ulfrx.recipe.di' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'fun initKoin(config: KoinAppDeclaration? = null)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'startKoin' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'modules(appModule)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'val appModule = module' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt && grep -q 'Logger.setTag("recipe")' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt && grep -q 'fun doInitKoin' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt && grep -q 'configureLogging()' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt && grep -q 'initKoin()' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt
<acceptance_criteria>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt exists and contains package declaration package dev.ulfrx.recipe.di
- Koin.kt imports org.koin.core.KoinApplication, org.koin.core.context.startKoin, org.koin.dsl.KoinAppDeclaration
- Koin.kt defines exactly one top-level function fun initKoin(config: KoinAppDeclaration? = null): KoinApplication whose body is startKoin { config?.invoke(this); modules(appModule) }
- AppModule.kt exists and contains package declaration package dev.ulfrx.recipe.di
- AppModule.kt imports org.koin.dsl.module
- AppModule.kt declares val appModule = module { } (empty — D-14)
- Logging.kt exists and contains package declaration package dev.ulfrx.recipe.logging
- Logging.kt imports co.touchlab.kermit.Logger
- Logging.kt defines fun configureLogging() whose body calls Logger.setTag("recipe") (D-15 — exact string)
- KoinIos.kt exists and contains package declaration package dev.ulfrx.recipe.di
- KoinIos.kt imports dev.ulfrx.recipe.logging.configureLogging
- KoinIos.kt defines fun doInitKoin() whose body is configureLogging(); initKoin() in that exact order
- No file references startKoin directly outside Koin.kt (grep startKoin across composeApp/src returns only Koin.kt)
- App.kt is NOT modified (anti-pattern guard — startKoin never called from inside @Composable)
</acceptance_criteria>
Koin + Kermit commonMain wiring is in place; iOS bridge exposes a Swift-callable KoinIosKt.doInitKoin().
Create: composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt:
package dev.ulfrx.recipe
import android.app.Application
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
import org.koin.android.ext.koin.androidContext
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
configureLogging()
initKoin {
androidContext(this@MainApplication)
}
}
}
CRITICAL:
package dev.ulfrx.recipe(notdev.ulfrx.recipe.android— matches the existingMainActivity.ktsibling).androidContext(this@MainApplication)— the qualifiedthisis required because theinitKoin { ... }lambda'sthisis aKoinApplication, not the Application.configureLogging()runs FIRST, theninitKoin { ... }— establishes the required order (PATTERNS.md "Init order on every platform entry").org.koin.android.ext.koin.androidContextcomes fromio.insert-koin:koin-android(catalog aliaslibs.koin.android, added tocomposeApp/build.gradle.ktsandroidMain deps in Plan 03).
Edit: composeApp/src/androidMain/AndroidManifest.xml — add android:name=".MainApplication" as the first attribute on the <application> element. Do NOT modify any other attribute or element.
Resulting <application> tag:
<application
android:name=".MainApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
The <activity> child element (with android:name=".MainActivity") stays unchanged. The full XML structure (declarations, <manifest>, <intent-filter>) is preserved — only the single android:name=".MainApplication" attribute is added.
test -f composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q '^package dev.ulfrx.recipe$' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'class MainApplication : Application()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'override fun onCreate()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'configureLogging()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'androidContext(this@MainApplication)' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'android:name=".MainApplication"' composeApp/src/androidMain/AndroidManifest.xml && grep -q 'android:name=".MainActivity"' composeApp/src/androidMain/AndroidManifest.xml
<acceptance_criteria>
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt exists
- Package declaration is exactly package dev.ulfrx.recipe (matches sibling MainActivity.kt)
- Imports include android.app.Application, dev.ulfrx.recipe.di.initKoin, dev.ulfrx.recipe.logging.configureLogging, org.koin.android.ext.koin.androidContext
- Class declaration is class MainApplication : Application()
- onCreate() body calls super.onCreate() first, then configureLogging(), then initKoin { androidContext(this@MainApplication) } — in exactly that order
- composeApp/src/androidMain/AndroidManifest.xml contains literal android:name=".MainApplication" attribute on the <application> element
- composeApp/src/androidMain/AndroidManifest.xml still contains android:name=".MainActivity" on the <activity> element (unchanged)
- composeApp/src/androidMain/AndroidManifest.xml still contains <intent-filter> with MAIN action + LAUNCHER category (unchanged)
- composeApp/src/androidMain/AndroidManifest.xml top-level <manifest> declaration unchanged
</acceptance_criteria>
Android Application subclass starts Koin + Kermit on process boot; manifest registers the subclass.
Replace: composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt:
package dev.ulfrx.recipe
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
fun main() {
configureLogging()
initKoin()
application {
Window(
onCloseRequest = ::exitApplication,
title = "recipe",
) {
App()
}
}
}
Replace: composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt:
package dev.ulfrx.recipe
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
configureLogging()
initKoin()
ComposeViewport {
App()
}
}
CRITICAL (PITFALL #8): configureLogging() and initKoin() MUST run BEFORE ComposeViewport { } — otherwise the first koinViewModel<X>() inside composition throws. Phase 1 has no ViewModels, so this is defensive — but the shape must be correct from day 1.
Replace: iosApp/iosApp/iOSApp.swift (Swift file, not Kotlin):
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
init() {
KoinIosKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
CRITICAL:
import ComposeApp— matches the framework basename set inrecipe.kotlin.multiplatform(D-20 / PITFALL #10). The existing file does NOT import ComposeApp; add it.init() { KoinIosKt.doInitKoin() }— the Swift symbolKoinIosKtis auto-generated from Kotlin fileKoinIos.ktin packagedev.ulfrx.recipe.di(created in Task 1).ContentView()invocation stays unchanged;ContentView.swiftalready callsMainViewControllerKt.MainViewController()which returns aComposeUIViewController— do NOT modifyContentView.swift.- Do NOT call
startKoinfromMainViewController()— iOS init is centralized iniOSApp.init()to avoid PITFALL #4. grep -q '^package dev.ulfrx.recipe$' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'configureLogging()' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'initKoin()' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'Window(' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q '^package dev.ulfrx.recipe$' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'configureLogging()' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'initKoin()' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'ComposeViewport' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'import ComposeApp' iosApp/iosApp/iOSApp.swift && grep -q 'KoinIosKt.doInitKoin()' iosApp/iosApp/iOSApp.swift && grep -q 'init() {' iosApp/iosApp/iOSApp.swift <acceptance_criteria>composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kthasconfigureLogging()on a line precedinginitKoin(), and both precedeapplication {(init order invariant)- JVM main imports include
dev.ulfrx.recipe.di.initKoinANDdev.ulfrx.recipe.logging.configureLogging - JVM main preserves
Window(onCloseRequest = ::exitApplication, title = "recipe") { App() } composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kthasconfigureLogging()on a line precedinginitKoin(), and both precedeComposeViewport {(PITFALL #8)- Web main imports include
dev.ulfrx.recipe.di.initKoinANDdev.ulfrx.recipe.logging.configureLogging - Web main still has
@OptIn(ExperimentalComposeUiApi::class)onfun main() iosApp/iosApp/iOSApp.swiftcontains exactlyimport SwiftUIANDimport ComposeApp(both imports required)iosApp/iosApp/iOSApp.swiftcontainsinit() {followed byKoinIosKt.doInitKoin()— exactly one calliosApp/iosApp/iOSApp.swiftpreserves@main struct iOSApp: App { ... body: some Scene { WindowGroup { ContentView() } } }MainViewController.ktis NOT modified (the existing file returnsComposeUIViewController { App() }— Koin bootstrapped outside, PITFALL #4)App.ktis NOT modified (anti-pattern guard) </acceptance_criteria> All four platform entry points callconfigureLogging()theninitKoin()before composition; iOS Swift wiresKoinIosKt.doInitKoin()exactly once ininit().
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Platform process start → DI container initialization | Each platform (Android onCreate, iOS App.init, JVM main, Wasm main) is a trusted bootstrap context; initKoin() is called once, from code we control. |
| Kotlin top-level fun → Swift generated symbol | KoinIos.kt in package dev.ulfrx.recipe.di is compiled into the ComposeApp.framework Swift binary as KoinIosKt.doInitKoin(). No runtime risk — compile-time symbol mapping. |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-01-04-01 | Denial of Service | Koin double-init on iOS second cold launch (PITFALL #4) | mitigate | Only iOSApp.init() calls KoinIosKt.doInitKoin(). MainViewController.kt does NOT call startKoin. Task 3 acceptance criteria explicitly prohibits startKoin in MainViewController.kt. If Koin is accidentally started twice, KoinApplicationAlreadyStartedException fires on launch — visible and easy to diagnose. |
| T-01-04-02 | Denial of Service | Wasm composition runs before Koin init (PITFALL #8) | mitigate | Task 3 explicitly orders configureLogging() → initKoin() → ComposeViewport { }. Phase 1 has no ViewModels so the symptom would not surface until Phase 5+, but the order is correct from day 1. |
| T-01-04-03 | Tampering | App.kt calling startKoin from inside @Composable |
mitigate | Task 1 + Task 3 acceptance criteria prohibit modification of App.kt. App.kt template preserves the anti-pattern-free shape. |
| T-01-04-04 | Information Disclosure | Kermit logs leaking sensitive data | accept | Phase 1 has no sensitive data in the codebase (no auth, no user records, no PII). Kermit tag "recipe" is a build identifier, not a secret. Revisit when Phase 2 (Auth) introduces tokens — at that point, Kermit's .i { } lambda evaluation prevents accidental string concat of secrets if authors follow the lambda idiom. |
| T-01-04-05 | Elevation of Privilege | Android manifest android:name=".MainApplication" registers custom Application subclass |
accept | This is the standard Android lifecycle — MainApplication.onCreate() runs in the app's own process, same privilege as MainActivity. No escalation. |
| </threat_model> |
- Task 1, 2, 3
<automated>blocks pass (grep-based). tools/verify-no-version-literals.shcontinues to exit 0 (no build files modified).tools/verify-shared-pure.shcontinues to exit 0 (shared/ not touched).- Plan 07 runs
./gradlew buildand./gradlew :composeApp:jvmTest— those will exerciseinitKoin()via composition and catch any Koin config error.
No ./gradlew invocation is in this plan's <automated> blocks — Plan 05 + Plan 07 run the compile gates. Keep this plan's verification grep-fast (<5s total).
<success_criteria>
- 6 new commonMain/iosMain/androidMain Kotlin files created (Koin.kt, AppModule.kt, Logging.kt, KoinIos.kt, MainApplication.kt — and the init order is correct in each)
- AndroidManifest.xml has
android:name=".MainApplication"attribute added - JVM + Wasm main() entries call
configureLogging()THENinitKoin()BEFORE composition iOSApp.swiftimportsComposeAppand callsKoinIosKt.doInitKoin()ininit()App.ktunmodified (anti-pattern guard)MainViewController.ktunmodified (PITFALL #4 guard) </success_criteria>