diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ba0078e0..41382f55 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "jetwhale", - "description": "Claude Code skills for developing plugins for JetWhale, the Compose Desktop debugging tool.", + "description": "Claude Code skills for JetWhale, the Compose Desktop debugging tool — integrating it into your app, and developing plugins for it.", "owner": { "name": "kitakkun", "url": "https://github.com/kitakkun" @@ -9,8 +9,8 @@ { "name": "jetwhale", "source": "./plugins/jetwhale", - "description": "Skills for developing JetWhale host plugins — currently a QA workflow that drives a plugin's real UI through the debug tool's MCP server.", - "version": "0.1.0", + "description": "Skills for JetWhale, the Compose Desktop debugging tool — integrating it into an app you want to debug, and QA-ing host plugins through the debug tool's MCP server.", + "version": "0.2.0", "author": { "name": "kitakkun", "url": "https://github.com/kitakkun" diff --git a/docs-site/.vitepress/config.mts b/docs-site/.vitepress/config.mts index 96b18470..c5f8b6e5 100644 --- a/docs-site/.vitepress/config.mts +++ b/docs-site/.vitepress/config.mts @@ -78,6 +78,10 @@ export default defineConfig({ text: 'Guide', items: [ { text: 'Network Inspector', link: '/guide/network-inspector' }, + { + text: 'Excluding from Release Builds', + link: '/guide/excluding-from-release-builds', + }, { text: 'MCP Server', link: '/guide/mcp-server' }, { text: 'Host Settings', link: '/guide/host-settings' }, { text: 'ADB Auto Port Mapping', link: '/guide/adb-auto-port-mapping' }, diff --git a/docs/guide/excluding-from-release-builds.md b/docs/guide/excluding-from-release-builds.md new file mode 100644 index 00000000..4b2bbc87 --- /dev/null +++ b/docs/guide/excluding-from-release-builds.md @@ -0,0 +1,283 @@ +# Excluding JetWhale from Release Builds + +JetWhale is a debugging tool: it opens a WebSocket to your machine, records HTTP traffic, and lets +an external process drive your app. None of that belongs in a build you ship. + +Keeping the *artifact* out is easy — `debugImplementation` on Android, or a build-flavor-specific +dependency elsewhere. Keeping the *call sites* out is the hard part: as soon as your shared code +says `startJetWhale { }` or `networkAgent.ktorClientPlugin()`, the release compilation needs those +symbols too, and the dependency comes right back. + +This page describes one way out for apps that use a DI container with contribution merging. The +examples use [Metro](https://zacsweers.github.io/metro/), whose `@ContributesBinding(replaces = …)` +expresses the idea directly, but the shape applies to any DI framework that can swap an +implementation per build variant. + +## The problem, concretely + +| Approach | Why it falls short | +|----------|--------------------| +| `debugImplementation` alone | The release variant fails to compile the moment shared code references JetWhale. | +| Android `src/debug/kotlin` | Variant source sets are an Android Gradle Plugin feature. A KMP `commonMain` — where your HTTP client and app startup usually live — has no equivalent. | +| `if (BuildConfig.DEBUG) { … }` | Compiles, but the dependency and every JetWhale class still ship in the release binary. The check is a runtime guard, not an exclusion. | + +What all three lack is a **seam**: a boundary that production code can compile against without +knowing JetWhale exists. + +## The shape of the fix + +Own the abstraction yourself. Production code depends on a small interface of your own; JetWhale +lives behind an implementation of it that only exists on the debug classpath. + +``` +:app @DependencyGraph(AppScope::class) + │ implementation(:core:debug) + │ debugImplementation(:debug-jetwhale) + │ + ├── :core:debug ← always compiled + │ interface DebugToolingInitializer + │ NoOpInitializer @ContributesBinding(AppScope::class) + │ + └── :debug-jetwhale ← debug classpath only; the only module that imports JetWhale + JetWhaleInitializer @ContributesBinding( + AppScope::class, + replaces = [NoOpInitializer::class], + ) +``` + +Both modules contribute a binding for `DebugToolingInitializer`. On the debug classpath the +JetWhale one `replaces` the no-op, so exactly one survives the merge. On the release classpath the +JetWhale module is simply absent and the no-op stands unopposed — no flags, no `expect`/`actual`, +no source set gymnastics. + +::: tip Where the switch happens +Metro merges contributions where the `@DependencyGraph` is declared, by scanning that compilation's +classpath. So the variant-specific dependency belongs on the module holding the graph — usually +your app module. +::: + +## 1. Declare the seam in production code + +```kotlin +// :core:debug — on every classpath, release included +package com.example.debug + +interface DebugToolingInitializer { + fun initialize() +} + +@ContributesBinding(AppScope::class) +@Inject +class NoOpInitializer : DebugToolingInitializer { + override fun initialize() = Unit +} +``` + +That is the whole production-side surface. Nothing here knows what a debugger is — name the seam +after the role it plays in your app, not after the tool behind it. + +## 2. Contribute the JetWhale-backed implementation + +Everything below lives in `:debug-jetwhale`, the one module that may import +`com.kitakkun.jetwhale.*`. + +The Network Inspector requires the *same* `JetWhaleNetworkAgentPlugin` instance in two places — the +HTTP client and `startJetWhale { }` (see [Network Inspector](/guide/network-inspector#setup)). A +scoped binding is exactly the tool for that, so give the agents a single owner: + +```kotlin +// :debug-jetwhale +package com.example.debug.jetwhale + +@SingleIn(AppScope::class) +@Inject +class JetWhaleAgents { + val network: JetWhaleNetworkAgentPlugin = JetWhaleNetworkAgentPlugin() +} +``` + +Then implement the seam: + +```kotlin +@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +@Inject +class JetWhaleInitializer( + private val agents: JetWhaleAgents, +) : DebugToolingInitializer { + override fun initialize() { + startJetWhale { + connection { + host = "localhost" + port = 5080 + } + plugins { + register(agents.network) + } + } + } +} +``` + +::: warning `replaces` names the contributing class +`replaces = [NoOpInitializer::class]` — the *class that contributes* the binding, not the bound type +`DebugToolingInitializer`. Both contributions must also target the same scope, or the merge leaves +two bindings in place. +::: + +## 3. Start it + +Your app calls the seam, never JetWhale: + +```kotlin +// :app — Application.onCreate(), or the first line of main() +appGraph.debugToolingInitializer.initialize() +``` + +In a release build this is a call to an empty method, which R8 removes outright. + +## 4. Attach the Network Inspector to your HTTP client + +Startup is a single binding, so `replaces` fits. Client customization is different: there may be +zero of them, or several. That is a multibinding, and an absent contribution just means an absent +element — no `replaces` needed at all. + +Declare the seam and the empty case in production code: + +```kotlin +// :core:debug +fun interface HttpClientDecorator { + fun decorate(client: HttpClient) +} + +@ContributesTo(AppScope::class) +interface HttpClientDecoratorDeclarations { + // Release builds contribute nothing, and an empty multibinding is an error by default. + @Multibinds(allowEmpty = true) + fun httpClientDecorators(): Set +} +``` + +Apply them wherever you build the client — still production code, still JetWhale-free: + +```kotlin +// :core:network +@ContributesTo(AppScope::class) +interface NetworkBindings { + @Provides + @SingleIn(AppScope::class) + fun provideHttpClient(decorators: Set): HttpClient = + HttpClient().also { client -> decorators.forEach { it.decorate(client) } } +} +``` + +And contribute the JetWhale decorator from the debug module: + +```kotlin +// :debug-jetwhale +@ContributesIntoSet(AppScope::class) +@Inject +class JetWhaleHttpClientDecorator( + private val agents: JetWhaleAgents, +) : HttpClientDecorator { + override fun decorate(client: HttpClient) { + client.plugin(HttpSend).intercept(agents.network.ktorSendInterceptor(client)) + } +} +``` + +This uses the `HttpSend` interceptor rather than `install(...)` because the client comes from the +graph already built. Register it once per client — `HttpSend` accepts duplicate interceptors +silently and would record every transaction twice. + +OkHttp works the same way: contribute an `Interceptor` with `@ContributesIntoSet` from the debug +module, and have the production `OkHttpClient` provider add every element of the (possibly empty) +set. + +## 5. Wire the variants in Gradle + +### Android + +```kotlin +// :app/build.gradle.kts +dependencies { + implementation(projects.core.debug) + debugImplementation(projects.debugJetwhale) +} +``` + +### Kotlin Multiplatform + +KMP has no build variants, so gate the dependency on a Gradle property: + +```kotlin +// :app/build.gradle.kts +val jetwhaleEnabled = providers.gradleProperty("jetwhale.enabled").orNull.toBoolean() + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.core.debug) + if (jetwhaleEnabled) implementation(projects.debugJetwhale) + } + } +} +``` + +Run your debug builds with `-Pjetwhale.enabled=true` (or set it in a local, un-committed +`gradle.properties`); release CI omits it and gets the no-op. Flipping the property changes the +compile classpath, so the graph module recompiles — expected, and cheap enough for a switch you +throw once per session. + +If you would rather not thread a property through the build, the alternative is two thin entry-point +modules — `:app-debug` and `:app-release` — each declaring its own graph and depending on the +appropriate set of modules. + +## Verify it + +The point of all this is a release binary with no trace of JetWhale, so check the classpath rather +than trusting the wiring: + +```shell +# Android +./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep jetwhale +``` + +No output means no JetWhale — no classes for R8 to process, no keep rules to write, and no way for +a stray `startJetWhale` call to reach production. + +## Pitfalls + +- **The no-op must live in an always-present module.** If it sits next to the JetWhale + implementation, release builds lose both and the graph fails to resolve + `DebugToolingInitializer`. +- **Forgetting `replaces` fails loudly.** The debug build stops at compile time with + `Metro/DuplicateBinding`, naming both contributions. That is the safety net working — it cannot + silently pick one. +- **Keep the seam interface out of the debug module.** Production code has to compile against it. +- **Don't let JetWhale types leak into the seam.** The moment `DebugToolingInitializer` mentions + `JetWhaleSession` or `JetWhaleNetworkAgentPlugin` in its signature, production code needs the + dependency again. +- **One agent instance, shared.** `@SingleIn(AppScope::class)` on `JetWhaleAgents` is what + guarantees the plugin registered with `startJetWhale { }` is the one installed into the HTTP + client. Two instances means the host shows no traffic. + +## Other DI frameworks + +The seam is the same everywhere; only the mechanism for making the debug side win differs. Each of +these was checked by building and running it, not inferred from the annotations: + +- **Anvil / kotlin-inject-anvil** — `@ContributesBinding(replaces = [...])` carries the same + meaning and the module layout transfers unchanged. Two caveats: kotlin-inject has no equivalent of + `@Multibinds(allowEmpty = true)`, so model the HTTP decorator as an ordinary binding with a no-op + default rather than a set; and Square Anvil generates through the Kotlin compiler plugin, so + Dagger has to run on kapt — under KSP nothing is generated at all. +- **Hilt** — `@InstallIn` modules are discovered from the classpath, so a `@BindsOptionalOf` + declaration in `src/main` plus a module in `src/debug` is enough. No release-side counterpart is + needed; the generated component simply binds `Optional.empty()`. +- **Plain Dagger** — `@Component(modules = [...])` names its modules, and `src/main` cannot name a + class that only exists in `src/debug`. Here you do need the same fully-qualified module in both + source sets, empty in release. Note that Dagger's `@Multibinds` allows an empty set by default — + there is no `allowEmpty` to pass. +- **Koin / manual DI** — resolution is at runtime, so nothing fails the release build for you. + Declare the JetWhale definitions only in the debug source set and reach for them with + `getOrNull` / `getAll`, which return absent and empty respectively when nothing is registered. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index f5a38005..b302eb38 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -55,7 +55,10 @@ and may break (especially around `inline` functions) with future releases. Prefe ::: tip Only add JetWhale to debug builds (e.g. `debugImplementation` on Android, or your own build-flavor -wiring) — it is a debugging tool and should not ship in release builds. +wiring) — it is a debugging tool and should not ship in release builds. Once shared code calls +`startJetWhale { }`, a debug-only dependency stops being enough on its own; see +[Excluding from Release Builds](/guide/excluding-from-release-builds) for a DI-based seam that keeps +release builds free of JetWhale entirely. ::: ## 3. Start JetWhale in your app diff --git a/plugins/jetwhale/.claude-plugin/plugin.json b/plugins/jetwhale/.claude-plugin/plugin.json index 61893676..604b5233 100644 --- a/plugins/jetwhale/.claude-plugin/plugin.json +++ b/plugins/jetwhale/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jetwhale", - "description": "Skills for developing JetWhale host plugins — currently a QA workflow that drives a plugin's real UI through the debug tool's MCP server.", - "version": "0.1.0", + "description": "Skills for JetWhale, the Compose Desktop debugging tool — integrating it into an app you want to debug, and QA-ing host plugins through the debug tool's MCP server.", + "version": "0.2.0", "author": { "name": "kitakkun", "url": "https://github.com/kitakkun" diff --git a/plugins/jetwhale/README.md b/plugins/jetwhale/README.md index cc37543f..5a123bf7 100644 --- a/plugins/jetwhale/README.md +++ b/plugins/jetwhale/README.md @@ -1,9 +1,10 @@ # JetWhale plugin for Claude Code -Skills for developing [JetWhale](https://github.com/kitakkun/JetWhale) host plugins. +Skills for using and extending [JetWhale](https://github.com/kitakkun/JetWhale). | Skill | What it covers | |---|---| +| `/jetwhale:integrate` | Adding JetWhale to an app you want to debug — surveying the build, HTTP client and DI framework, then wiring startup and traffic capture behind a seam so no JetWhale symbol reaches release builds | | `/jetwhale:plugin-qa` | Driving a host plugin's real UI through the debug tool's MCP server — screenshots, gestures, persisted state, restart restore, and a headless debuggee to drive it against | ## Install @@ -23,16 +24,20 @@ claude plugin marketplace add kitakkun/JetWhale --sparse .claude-plugin plugins Either way, installing copies just this directory into `~/.claude/plugins/cache`. -## Why the skill lives in the JetWhale repository +## Why the skills live in the JetWhale repository -A QA skill is only useful while it is true, and what it describes — MCP tool names, the QA agent's -control API, which ports the launch tasks accept — moves with the code. Keeping it here means a -change to the host and the change to its documented workflow land in the same commit, reviewed -together. A separate repository would let the two drift, and a QA guide that quietly lies is worse -than none. +A skill is only useful while it is true, and what these describe — MCP tool names, the QA agent's +control API, which ports the launch tasks accept, the published artifact coordinates — moves with +the code. Keeping them here means a change to the host and the change to its documented workflow +land in the same commit, reviewed together. A separate repository would let the two drift, and a +guide that quietly lies is worse than none. ## Requirements -The skill assumes your plugin module applies the `com.kitakkun.jetwhale.host` Gradle plugin and sets -`jetwhalePlugin.hostVersion`; that is what provides the `runJetWhale` and `runJetWhaleQaAgent` tasks -it drives. See the [plugin development guide](https://github.com/kitakkun/JetWhale/tree/main/docs). +`/jetwhale:integrate` runs against the app you want to debug and needs nothing installed beyond +that project; it will tell you if the project's Kotlin version is too old. + +`/jetwhale:plugin-qa` assumes your plugin module applies the `com.kitakkun.jetwhale.host` Gradle +plugin and sets `jetwhalePlugin.hostVersion`; that is what provides the `runJetWhale` and +`runJetWhaleQaAgent` tasks it drives. See the +[plugin development guide](https://github.com/kitakkun/JetWhale/tree/main/docs). diff --git a/plugins/jetwhale/skills/integrate/SKILL.md b/plugins/jetwhale/skills/integrate/SKILL.md new file mode 100644 index 00000000..fb9b5378 --- /dev/null +++ b/plugins/jetwhale/skills/integrate/SKILL.md @@ -0,0 +1,197 @@ +--- +name: integrate +description: Add JetWhale to an app you want to debug — survey the project's build, HTTP client and DI framework, then wire startup and traffic capture behind a seam so no JetWhale symbol reaches release builds. +--- + +# Integrating JetWhale + +Adding the dependency is three lines. Adding it *without* dragging a debugging tool into the +shipped binary is the actual job, and it is decided by two things you do not control: how the +project splits debug from release, and which DI framework it uses. + +So this skill is a survey followed by a routing decision. Do not start editing until §1 and §2 are +answered — a wiring chosen for the wrong DI framework has to be undone before the right one goes +in, and the wrong dependency configuration is invisible until someone builds a release months +later. + +**The rule everything else serves:** production code must never name a JetWhale type. Not in an +import, not in a signature, not behind an `if (DEBUG)`. Anything that names JetWhale needs JetWhale +on the classpath to compile, and a dependency you cannot remove from the release compile classpath +is a dependency you ship. + +## 1. Survey the project + +Run these before deciding anything. Each answer routes a later step. + +```bash +# Targets: is this Android-only, or Kotlin Multiplatform? +grep -rlE 'kotlin\("multiplatform"\)|kotlin-multiplatform' --include=build.gradle.kts . + +# DI framework +grep -rnE 'dev\.zacsweers\.metro|com\.squareup\.anvil|lastmile\.kotlin\.inject\.anvil|com\.google\.dagger|dagger\.hilt|io\.insert-koin' \ + --include=*.kts --include=*.toml . | head + +# HTTP client +grep -rnE 'io\.ktor:ktor-client|com\.squareup\.okhttp3' --include=*.kts --include=*.toml . | head + +# An existing debug seam to ride on +grep -rn 'BuildConfig.DEBUG' --include=*.kt . | head +find . -type d -name debug -path '*/src/*' | head +grep -rlniE 'class (NoOp|Noop)[A-Za-z]*' --include=*.kt . | head +``` + +Answer these five, out loud, before continuing: + +| Question | Why it routes | +|---|---| +| Android variants, KMP, or both? | Whether `debugImplementation` exists at all (§3) | +| Which DI framework? | Which reference file to follow (§4) | +| Ktor, OkHttp, both, or neither? | Whether the Network Inspector is in scope at all | +| Is there already a debug-only module or `src/debug` source set? | Ride it instead of inventing one (§2) | +| Where does the app start — `Application.onCreate()`, `main()`, an initializer list? | Where the one call goes (§4) | + +**Kotlin version.** JetWhale needs **Kotlin 2.3+** in the consuming project; older versions fail +the build with metadata-version errors. Check it now (`grep -n 'kotlin' gradle/libs.versions.toml`) +— if the project is older, stop and say so. `-Xskip-metadata-version-check` exists but is an +unsupported escape hatch, not a plan. + +## 2. Ride an existing seam before building one + +Most apps that already carry debug-only tooling — Chucker, Flipper, LeakCanary, an internal debug +drawer — have solved this problem once. Reuse costs nothing and matches what reviewers expect. + +Look for, in order: + +1. **A debug-only Gradle module** (`:debug`, `:core:debug-tooling`, anything pulled in with + `debugImplementation`). Put the JetWhale wiring there and you are nearly done. +2. **An existing initializer abstraction** — an interface with a no-op implementation, an + `AppInitializer` list, a `Set` multibinding. Contribute one more element. +3. **`src/debug` / `src/release` source sets** holding variant-specific implementations of a shared + interface. Add your implementation to the debug side. + +Only when none of these exist do you create the seam yourself, following the reference for the +project's DI framework. + +**Do not introduce a DI framework to solve this.** A project with no seam and no container wants +the plain-interface version in `references/no-di.md`, not a new dependency in its production build. + +## 3. Add the dependencies + +```kotlin +dependencies { + // `implementation` here only because this block belongs in a debug-only module. + // In an Android app module it is `debugImplementation` — see the table below. + implementation("com.kitakkun.jetwhale:jetwhale-agent-runtime:") + // only if capturing HTTP traffic — match the app's client: + implementation("com.kitakkun.jetwhale:jetwhale-network-inspector-agent-ktor:") + implementation("com.kitakkun.jetwhale:jetwhale-network-inspector-agent-okhttp:") +} +``` + +Take `` from the [releases page](https://github.com/kitakkun/JetWhale/releases) — do not +guess it, and do not copy a version out of an older document. + +**Which configuration** is the whole point: + +| Project shape | How the dependency stays out of release | +|---|---| +| Android app or module | `debugImplementation`, on the module that will hold the JetWhale wiring | +| KMP, no Android variants | No variant concept exists. Gate the dependency on a Gradle property (`if (providers.gradleProperty("jetwhale.enabled").orNull.toBoolean())`), or keep two thin entry-point modules | +| A dedicated debug-only module | The module itself is added with `debugImplementation`; inside it, plain `implementation` is correct | + +If the answer is "the shared module everything depends on, with plain `implementation`", stop. +That ships JetWhale. Go back to §2. + +## 4. Wire it — follow the reference for the project's DI framework + +Each reference gives the seam, the two implementations, and the framework's mechanism for making +the debug one win. They share a shape: an app-owned interface, a no-op bound by default, and a +JetWhale-backed implementation that displaces it on the debug classpath only. + +| Detected | Read | The edge that framework has | +|---|---|---| +| Metro (`dev.zacsweers.metro`) | `references/metro.md` | Empty multibindings need `@Multibinds(allowEmpty = true)` | +| kotlin-inject-anvil | `references/anvil.md` | **No empty multibindings at all** — use `replaces` for every seam | +| Square Anvil (maintenance mode) | `references/anvil.md` | **Dagger must run on kapt, not KSP** — under KSP nothing is generated and nothing says so | +| Hilt | `references/dagger-hilt.md` | `@InstallIn` is discovered, so the debug side is the only side | +| Plain Dagger | `references/dagger-hilt.md` | Modules are listed, so a same-name module per variant is unavoidable | +| Koin | `references/koin.md` | Runtime resolution — the compiler catches nothing | +| None | `references/no-di.md` | Same-signature factory per variant; KMP has no variants | + +Every row was verified by building and running a project, not reasoned about. See +[Verified against](#verified-against) for versions and evidence. + +Whichever you follow, two JetWhale-side facts hold: + +- **`startJetWhale { }` is called once**, as early as the app can — `Application.onCreate()`, the + first line of `main()`, or the SwiftUI `App` init. It returns a session handle that a + connect-once app can ignore. +- **One `JetWhaleNetworkAgentPlugin` instance serves two call sites** — installed into the HTTP + client, and registered in `plugins { register(...) }`. Two instances is the classic mistake: the + app connects, the host lists the session, and no traffic ever appears. Give it a singleton + binding and inject it in both places. + +## 5. Verify — the classpath, not the wiring + +The wiring compiling proves nothing about release builds. Check the artifact: + +```bash +# Android: nothing at all should come back +./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep -i jetwhale + +# Both variants must still compile — release is the one that catches a leaked reference +./gradlew assembleDebug assembleRelease # or the KMP equivalents +``` + +An empty grep and a green release build together mean the seam holds. Then confirm the debug side +actually works, because a perfectly isolated integration that never connects is the other failure: + +1. Launch the JetWhale host (default port **5080**). +2. Android only — `adb reverse tcp:5080 tcp:5080`, or enable ADB auto port mapping in the host. +3. Launch the debug build. It appears as a session in the host within a second or two. +4. If the Network Inspector is wired, make one request and watch it land. + +Nothing in the host means the agent never connected: wrong port, no port forwarding, or +`startJetWhale` not reached. Session present but no traffic means two agent instances — see §4. + +## 6. Report what you did + +State plainly: + +- which seam you rode or created, and in which module +- the dependency configuration used, and the release-classpath grep result +- whether you saw the session connect, or only that it compiles — do not imply a live check you + did not run +- anything you left unwired (e.g. OkHttp present but only Ktor wired) and why + +## Verified against + +Each pattern below was built as a four-module project (`:seam`, `:tooling`, `:app-debug` depending +on both, `:app-release` depending on `:seam` only) and **run**, so the recorded result is the +binding that actually resolved — not one inferred from the annotations. + +| Framework | Versions | Evidence | +|---|---|---| +| Metro | 1.3.2, Kotlin 2.4.10 | release `noop` + empty decorator set; debug real binding; `@SingleIn` holder identical across both injection sites | +| kotlin-inject-anvil | 0.1.7, kotlin-inject 0.9.0, KSP 2.3.10, Kotlin 2.3.10 | `replaces` resolves both ways; an empty `Set` fails KSP outright | +| Square Anvil | 2.7.0, Dagger 2.60.1, Kotlin 2.2.20 | `replaces` resolves both ways — **only** after moving Dagger from KSP to kapt | +| Dagger | 2.60.1 | `@BindsOptionalOf` → `Optional.empty()` in release, present in debug; `@Multibinds` allows empty with no parameter | +| Hilt + Android variants | 2.60.1, AGP 9.3.0, Kotlin 2.4.10 | generated component: `Optional.of(...)` in debug vs `Optional.empty()` in release, with no release-side module | +| Koin | 4.2.2 | `getOrNull` null in release; `getAll` empty; `single` shares one instance | +| No DI + AGP variants | AGP 9.3.0, Kotlin 2.4.10 | debug APK dex carries the debug-only class, release APK carries zero occurrences | + +Classpath isolation was checked on the built artifacts in the Android project: the debug-only +project appeared on `debugRuntimeClasspath`, was absent from `releaseRuntimeClasspath`, and its +classes were absent from the release APK's dex. + +When a project's versions differ materially from these, re-check the sharp edge for that framework +before trusting the shape. + +## Reference + +- [Excluding from Release Builds](https://kitakkun.github.io/JetWhale/guide/excluding-from-release-builds) + — the long-form version of §4, with the Metro examples in full +- [Getting Started](https://kitakkun.github.io/JetWhale/guide/getting-started) — the + `startJetWhale { }` DSL, wss, per-platform startup locations +- [Network Inspector](https://kitakkun.github.io/JetWhale/guide/network-inspector) — Ktor and + OkHttp adapters, mocking, redaction diff --git a/plugins/jetwhale/skills/integrate/references/anvil.md b/plugins/jetwhale/skills/integrate/references/anvil.md new file mode 100644 index 00000000..0535195c --- /dev/null +++ b/plugins/jetwhale/skills/integrate/references/anvil.md @@ -0,0 +1,153 @@ +# Wiring with kotlin-inject-anvil, or an existing Square Anvil setup + +Two unrelated libraries that happen to share a name and the word `replaces`. Both merge +contributions across the compile classpath, so the module layout and the seam are identical to +Metro's — read [`metro.md`](metro.md) first and treat this file as the delta. + +- **kotlin-inject-anvil** (Amazon, actively developed) — the section below. +- **Square Anvil** — in maintenance mode and pinned to an old Kotlin, so that section is written for + a project that is already on it, and leads with the constraint to check rather than the syntax. + +Both were verified by building a four-module project (`:seam`, `:tooling`, `:app-debug` depending on +both, `:app-release` depending on `:seam` only) and running each app: release resolves the no-op, +debug resolves the real implementation, and a scoped holder is shared across two injection sites. +Each has one sharp edge that the Metro shape does not have. + +## kotlin-inject-anvil + +Verified with kotlin-inject-anvil 0.1.7, kotlin-inject 0.9.0, KSP 2.3.10, Kotlin 2.3.10. + +Annotations live under `software.amazon.lastmile.kotlin.inject.anvil`, with kotlin-inject's `@Inject` +(`me.tatarka.inject.annotations.Inject`) and `@SingleIn(AppScope::class)` for scoping. The component +is `@MergeComponent(AppScope::class)` on an abstract class, instantiated with +`AppComponent::class.create()`. + +```kotlin +// production module +interface DebugToolingInitializer { + fun initialize(): String +} + +@ContributesBinding(AppScope::class) +@Inject +class NoOpInitializer : DebugToolingInitializer { + override fun initialize() = "noop" +} +``` + +```kotlin +// debug-only module +@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +@Inject +@SingleIn(AppScope::class) +class JetWhaleInitializer( + private val agents: JetWhaleAgents, +) : DebugToolingInitializer { + override fun initialize() = "jetwhale" +} +``` + +### Use `replaces` for the HTTP decorator too — not a multibinding + +`@ContributesBinding` does carry a `multibinding: Boolean = false` parameter, so the Metro shape +looks like it should transfer. **It does not.** kotlin-inject has no equivalent of +`@Multibinds(allowEmpty = true)`, so a `Set` with zero contributions does not resolve, and the +release build fails at KSP time: + +``` +e: [ksp] Cannot find an @Inject constructor or provider for: Set +``` + +Release contributing nothing is exactly the case this seam needs, so the multibinding route is +closed. Model the decorator as an ordinary binding with a no-op default and displace it the same way +as the initializer: + +```kotlin +// production +@ContributesBinding(AppScope::class) +@Inject +class NoOpDecorator : HttpClientDecorator { + override fun decorate(client: HttpClient) = Unit +} + +// debug-only +@ContributesBinding(AppScope::class, replaces = [NoOpDecorator::class]) +@Inject +class JetWhaleHttpClientDecorator(private val agents: JetWhaleAgents) : HttpClientDecorator { + override fun decorate(client: HttpClient) { + client.plugin(HttpSend).intercept(agents.network.ktorSendInterceptor(client)) + } +} +``` + +One mechanism for both seams, and it is the mechanism that works. + +## Square Anvil — for a project already on it + +Anvil 2.7.0 (October 2025) is built against Kotlin 2.2.20, and Metro is its successor, so this is +not a library a project adopts today. It is one a project already has — often a large, long-lived +Android app, which is exactly the kind that most benefits from keeping the debugger out of its +release build. + +### Check the Dagger processor first: kapt, not KSP + +Worth settling before writing any wiring. Anvil is a Kotlin **compiler plugin**: it adds +`@Component` and the merged supertypes during compilation. KSP reads source before that happens, so +Dagger's KSP processor never sees a component to process. + +There is no warning for this — nothing is generated, and the build fails later at a point that looks +unrelated: + +``` +e: Unresolved reference: DaggerAppComponent +``` + +Both variants build and resolve correctly once Dagger's processor moves to kapt: + +```kotlin +plugins { + kotlin("kapt") +} +dependencies { + kapt("com.google.dagger:dagger-compiler:2.60.1") +} +``` + +A project that already runs Dagger through KSP cannot have both, so this is a decision for the team +rather than a detail to change in passing. + +### The wiring itself + +Identical to Metro's, with `com.squareup.anvil.annotations.ContributesBinding`, Dagger's +`@Inject constructor()` and `@Singleton`, and `@MergeComponent(AppScope::class)` on the component — +instantiated as `DaggerAppComponent.create()`. `replaces` still names the contributing class: + +```kotlin +// production module +@ContributesBinding(AppScope::class) +class NoOpInitializer @Inject constructor() : DebugToolingInitializer { … } + +// debug-only module +@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +class JetWhaleInitializer @Inject constructor(private val agents: JetWhaleAgents) : + DebugToolingInitializer { … } +``` + +Verified with Anvil 2.7.0, Dagger 2.60.1, Kotlin 2.2.20. + +### The exit + +This wiring transfers to Metro with only the annotation packages changed — and there +`@Multibinds(allowEmpty = true)` is available again, so the HTTP decorator can go back to being a +multibinding. If the team is weighing a migration, the seam is a small, self-contained place to +start. + +## Common failure modes + +| Symptom | Cause | +|---|---| +| Duplicate binding for the seam type | `replaces` missing, or the contributions target different scopes | +| Release cannot resolve the seam | The no-op is in the debug-only module | +| `Cannot find an @Inject constructor or provider for: Set<…>` | kotlin-inject-anvil: an empty multibinding. Use `replaces` with a no-op instead | +| `Unresolved reference: DaggerAppComponent`, nothing generated | Square Anvil with Dagger on KSP. Move Dagger to kapt | +| Contribution silently ignored | The debug module is not on the compile classpath of the component declaration — the variant dependency has to sit on the module that merges | diff --git a/plugins/jetwhale/skills/integrate/references/dagger-hilt.md b/plugins/jetwhale/skills/integrate/references/dagger-hilt.md new file mode 100644 index 00000000..2471e9f4 --- /dev/null +++ b/plugins/jetwhale/skills/integrate/references/dagger-hilt.md @@ -0,0 +1,160 @@ +# Wiring with Dagger or Hilt + +Neither has Metro's `replaces`. What they have instead is **optional bindings**: production code +declares that a binding *may be absent*, and the debug side is the only one that supplies it. That +is a better fit than it sounds — the release behaviour this seam wants is "do nothing", which is +exactly what absence means. + +Whether you need a release-side counterpart at all comes down to one difference: + +| | How the debug module reaches the graph | Release-side counterpart | +|---|---|---| +| **Hilt** | Discovered from `@InstallIn` on the classpath | **Not needed** — one-sided | +| **Plain Dagger** | Listed explicitly in `@Component(modules = [...])` | **Required** — a same-name module per variant | + +Verified with Dagger 2.60.1, Hilt 2.60.1, AGP 9.3.0, Kotlin 2.4.10, KSP 2.3.10. + +## Hilt — one-sided, no release counterpart + +Declare the optional binding once, in `src/main`: + +```kotlin +// src/main — production code, never names JetWhale +interface DebugToolingInitializer { + fun initialize(): String +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class SeamModule { + @BindsOptionalOf + abstract fun optionalInitializer(): DebugToolingInitializer +} +``` + +Supply it from `src/debug` only. There is no `src/release` file: + +```kotlin +// src/debug — the only source set that imports JetWhale +@Module +@InstallIn(SingletonComponent::class) +object JetWhaleModule { + @Provides + @Singleton + fun agents(): JetWhaleAgents = JetWhaleAgents() + + @Provides + fun initializer(agents: JetWhaleAgents): DebugToolingInitializer = + DebugToolingInitializer { + startJetWhale { + connection { host = "localhost"; port = 5080 } + plugins { register(agents.network) } + } + } +} +``` + +Inject `Optional` and call it: + +```kotlin +// src/main +@Inject lateinit var initializer: Optional + +initializer.ifPresent { it.initialize() } +``` + +`@Singleton` on the agent provider is what keeps one instance across the HTTP client and the session. + +This works because Hilt aggregates every `@InstallIn` module it finds on the variant's classpath. +The generated component proves it — same app, two variants: + +```java +// app/build/generated/hilt/component_sources/debug/…/DaggerApp_HiltComponents_SingletonC.java +App_MembersInjector.injectInitializer(instance, Optional.of(debugToolingInitializer())); + +// …/release/… +App_MembersInjector.injectInitializer(instance, Optional.empty()); +``` + +The release component contains no reference to `JetWhaleModule` at all, and no +`JetWhaleModule_*Factory` is generated for that variant. + +## Plain Dagger — the same-name module pair + +Without Hilt, `@Component(modules = [...])` names its modules explicitly, and `src/main` cannot name +a class that exists only in `src/debug`. So the module has to exist in both source sets under the +same fully-qualified name — empty in release, providing in debug. + +```kotlin +// src/main +@Singleton +@Component(modules = [SeamModule::class, DebugToolingModule::class]) +interface AppComponent { + fun initializer(): Optional +} +``` + +`SeamModule` (with `@BindsOptionalOf`) stays in `src/main`; `DebugToolingModule` is declared twice — +empty in `src/release`, providing in `src/debug`. Nothing keeps that pair in sync but convention, so +build both variants in CI. + +## HTTP client capture + +Dagger's `@Multibinds` allows an empty set **by default** — unlike Metro, there is no `allowEmpty` +parameter to set: + +```kotlin +// src/main +@Module +@InstallIn(SingletonComponent::class) +abstract class HttpClientDecoratorModule { + @Multibinds + abstract fun decorators(): Set +} +``` + +```kotlin +// src/debug +@Provides +@IntoSet +fun jetwhaleDecorator(agents: JetWhaleAgents): HttpClientDecorator = + HttpClientDecorator { client -> + client.plugin(HttpSend).intercept(agents.network.ktorSendInterceptor(client)) + } +``` + +The production `HttpClient` / `OkHttpClient` provider injects `Set` and applies +every element. In release the set is empty and the provider is unchanged. + +## Gradle, and what it actually buys you + +```kotlin +dependencies { + implementation("com.google.dagger:hilt-android:2.60.1") + ksp("com.google.dagger:hilt-compiler:2.60.1") + debugImplementation(project(":tooling")) // or the JetWhale artifacts directly +} +``` + +Verified end to end on the built artifacts, not just the wiring: + +``` +:app:dependencies --configuration releaseRuntimeClasspath → no :tooling +:app:dependencies --configuration debugRuntimeClasspath → +--- project :tooling +debug APK dex strings: FakeAgents ×5 +release APK dex strings: FakeAgents ×0 +``` + +KMP has no variant source sets. Put the debug module in its own Gradle module and gate the +dependency on a Gradle property — see the KMP section of [`metro.md`](metro.md), which is +framework-independent. + +## Failure modes + +| Symptom | Cause | +|---|---| +| `MissingBinding` in release only | Plain Dagger with no release-side counterpart module, or `@BindsOptionalOf` never declared | +| `DuplicateBindings` | Both source sets ended up on one compile — check the release variant's source set list | +| Unresolved JetWhale reference in release | Something in `src/main` names a JetWhale type — the seam leaked | +| Session connects, no traffic | Two agent instances — `@Singleton` missing on the agent provider | +| `The 'org.jetbrains.kotlin.android' plugin is no longer required since AGP 9.0` | AGP 9 has built-in Kotlin; drop the plugin rather than pinning AGP back | diff --git a/plugins/jetwhale/skills/integrate/references/koin.md b/plugins/jetwhale/skills/integrate/references/koin.md new file mode 100644 index 00000000..758ea34a --- /dev/null +++ b/plugins/jetwhale/skills/integrate/references/koin.md @@ -0,0 +1,107 @@ +# Wiring with Koin + +Koin resolves at runtime, so nothing stops a release build from *compiling* against JetWhale — the +compiler will not catch a leak for you. The discipline has to come from the module layout: the +seam's implementation lives in a variant-specific file, and the JetWhale dependency is +variant-specific too, so a leak fails the release build at compile time rather than shipping. + +## The seam + +```kotlin +// src/main — production code +interface DebugToolingInitializer { + fun initialize() +} +``` + +## One Koin module per variant + +Declare the same `Module` value in both source sets, and load it once from `startKoin`. + +```kotlin +// src/release +val debugToolingModule = module { + single { + object : DebugToolingInitializer { + override fun initialize() = Unit + } + } +} +``` + +```kotlin +// src/debug — the only source set importing JetWhale +val debugToolingModule = module { + single { JetWhaleNetworkAgentPlugin() } + single { + val agent: JetWhaleNetworkAgentPlugin = get() + DebugToolingInitializer { + startJetWhale { + connection { host = "localhost"; port = 5080 } + plugins { register(agent) } + } + } + } +} +``` + +```kotlin +// src/main — unchanged between variants +startKoin { + modules(appModule, networkModule, debugToolingModule) +} + +get().initialize() +``` + +`single { }` — not `factory { }` — is what keeps one `JetWhaleNetworkAgentPlugin` across the HTTP +client and the session. A `factory` here produces the "session connects, no traffic" bug. + +## HTTP client capture + +Koin has no multibinding, so give the production client provider a list it can resolve in both +variants: + +```kotlin +// src/main — production +val networkModule = module { + single { HttpClient().also { client -> getAll().forEach { it.decorate(client) } } } +} +``` + +`getAll()` returns every definition of the type and is empty when none are declared, which is +exactly the release case — verified on Koin 4.2.2: the release side resolved `client[]` and +`getOrNull()` returned null, while the debug side resolved both. Declare +the decorator only in `src/debug`: + +```kotlin +// src/debug +single { + val agent: JetWhaleNetworkAgentPlugin = get() + HttpClientDecorator { client -> client.plugin(HttpSend).intercept(agent.ktorSendInterceptor(client)) } +} +``` + +On an older Koin without `getAll`, `getOrNull()` against a single definition +does the same job and reads more plainly. + +## Gradle + +```kotlin +dependencies { + debugImplementation("com.kitakkun.jetwhale:jetwhale-agent-runtime:") + debugImplementation("com.kitakkun.jetwhale:jetwhale-network-inspector-agent-ktor:") +} +``` + +KMP without Android variants has no `src/debug`; put the debug Koin module in its own Gradle module +and gate the dependency on a Gradle property — see the KMP section of [`metro.md`](metro.md). + +## Failure modes + +| Symptom | Cause | +|---|---| +| `NoDefinitionFoundException` for the seam in release | The release-side module file is missing, or `debugToolingModule` was not passed to `modules(...)` | +| Release compiles but ships JetWhale | The dependency was added with `implementation`, not `debugImplementation` | +| Session connects, no traffic | `factory` instead of `single` for the agent, or two separate `single` definitions | +| Runtime crash only in release | Koin resolves at runtime — a missing definition is not a compile error. Launch the release build once before calling it done | diff --git a/plugins/jetwhale/skills/integrate/references/metro.md b/plugins/jetwhale/skills/integrate/references/metro.md new file mode 100644 index 00000000..0442f15f --- /dev/null +++ b/plugins/jetwhale/skills/integrate/references/metro.md @@ -0,0 +1,171 @@ +# Wiring with Metro + +Metro merges contributions at the `@DependencyGraph` declaration by scanning that compilation's +classpath, and `@ContributesBinding(replaces = ...)` lets one contribution displace another. That is +exactly the seam: contribute a no-op from a module that is always present, and a JetWhale-backed +implementation from a module that only exists on the debug classpath. + +The variant-specific dependency therefore belongs on **the module that declares the graph**, usually +the app module. + +Verified with Metro 1.3.2 and Kotlin 2.4.10, by running a four-module project both ways: the release +app resolved `noop` with an empty decorator set, the debug app resolved the JetWhale binding, and +the `@SingleIn` holder reported the same instance identity through both the initializer and the +decorator. + +## Module layout + +``` +:app @DependencyGraph(AppScope::class) + │ implementation(:core:debug) + │ debugImplementation(:debug-jetwhale) + │ + ├── :core:debug ← every classpath + └── :debug-jetwhale ← debug classpath only; the only module importing JetWhale +``` + +## 1. The seam, in production code + +```kotlin +// :core:debug +interface DebugToolingInitializer { + fun initialize() +} + +@ContributesBinding(AppScope::class) +@Inject +class NoOpInitializer : DebugToolingInitializer { + override fun initialize() = Unit +} +``` + +## 2. The JetWhale side + +```kotlin +// :debug-jetwhale +@SingleIn(AppScope::class) +@Inject +class JetWhaleAgents { + val network: JetWhaleNetworkAgentPlugin = JetWhaleNetworkAgentPlugin() +} + +@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +@Inject +class JetWhaleInitializer( + private val agents: JetWhaleAgents, +) : DebugToolingInitializer { + override fun initialize() { + startJetWhale { + connection { + host = "localhost" + port = 5080 + } + plugins { + register(agents.network) + } + } + } +} +``` + +`@SingleIn(AppScope::class)` on `JetWhaleAgents` is what guarantees the plugin registered with the +session is the same one installed into the HTTP client. + +## 3. The call site + +```kotlin +appGraph.debugToolingInitializer.initialize() +``` + +Add the accessor to the graph interface. In release this calls an empty method that R8 removes. + +## 4. HTTP client capture — a multibinding, not a replacement + +Startup is one binding, so `replaces` fits. Client customization may have zero contributions, which +is what a multibinding models: the debug module contributes an element, release contributes none. +No `replaces` involved. + +```kotlin +// :core:debug +fun interface HttpClientDecorator { + fun decorate(client: HttpClient) +} + +@ContributesTo(AppScope::class) +interface HttpClientDecoratorDeclarations { + // Empty multibindings are an error by default, and release contributes nothing. + @Multibinds(allowEmpty = true) + fun httpClientDecorators(): Set +} +``` + +```kotlin +// :core:network — production code, still JetWhale-free +@ContributesTo(AppScope::class) +interface NetworkBindings { + @Provides + @SingleIn(AppScope::class) + fun provideHttpClient(decorators: Set): HttpClient = + HttpClient().also { client -> decorators.forEach { it.decorate(client) } } +} +``` + +```kotlin +// :debug-jetwhale +@ContributesIntoSet(AppScope::class) +@Inject +class JetWhaleHttpClientDecorator( + private val agents: JetWhaleAgents, +) : HttpClientDecorator { + override fun decorate(client: HttpClient) { + client.plugin(HttpSend).intercept(agents.network.ktorSendInterceptor(client)) + } +} +``` + +`HttpSend` rather than `install(...)` because the client arrives from the graph already built. +Register once per client — `HttpSend` accepts duplicate interceptors silently and records every +transaction twice. + +OkHttp is the same shape: contribute an `Interceptor` with `@ContributesIntoSet`, and have the +production `OkHttpClient` provider add every element of the (possibly empty) set as an application +interceptor. + +## Gradle + +```kotlin +// Android +dependencies { + implementation(projects.core.debug) + debugImplementation(projects.debugJetwhale) +} +``` + +```kotlin +// KMP — no variants, so gate on a property +val jetwhaleEnabled = providers.gradleProperty("jetwhale.enabled").orNull.toBoolean() + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.core.debug) + if (jetwhaleEnabled) implementation(projects.debugJetwhale) + } + } +} +``` + +Run debug builds with `-Pjetwhale.enabled=true`; release CI omits it. Flipping the property changes +the compile classpath, so the graph module recompiles. + +## Failure modes + +| Symptom | Cause | +|---|---| +| `Metro/DuplicateBinding` naming both implementations | `replaces` missing, or the two contributions target different scopes | +| Release build cannot resolve `DebugToolingInitializer` | The no-op lives in the debug-only module; move it to the always-present one | +| Empty-multibinding error in release | `@Multibinds(allowEmpty = true)` missing, or declared in the debug module instead of production code | +| Session connects, no traffic | Two `JetWhaleNetworkAgentPlugin` instances — check `@SingleIn` on the holder | + +`replaces` names the **contributing class** (`NoOpInitializer`), never the bound type +(`DebugToolingInitializer`). diff --git a/plugins/jetwhale/skills/integrate/references/no-di.md b/plugins/jetwhale/skills/integrate/references/no-di.md new file mode 100644 index 00000000..5026230c --- /dev/null +++ b/plugins/jetwhale/skills/integrate/references/no-di.md @@ -0,0 +1,125 @@ +# Wiring without a DI framework + +A project with no container does not need one to keep JetWhale out of release builds. The seam is a +plain interface and a variant-specific factory — roughly ten lines, no new dependency in production +code. + +Do **not** introduce a DI framework for this. Adding a container to an app that has deliberately +avoided one is a far larger change than the integration it would serve, and it is not yours to make. + +## Android — variant source sets + +```kotlin +// src/main — production code, no JetWhale anywhere +interface DebugTooling { + fun start() +} + +object DebugToolingHolder { + val instance: DebugTooling by lazy { createDebugTooling() } +} +``` + +`createDebugTooling()` is declared once per variant, with the same signature: + +```kotlin +// src/release +internal fun createDebugTooling(): DebugTooling = object : DebugTooling { + override fun start() = Unit +} +``` + +```kotlin +// src/debug — the only source set importing JetWhale +internal fun createDebugTooling(): DebugTooling = object : DebugTooling { + private val networkAgent = JetWhaleNetworkAgentPlugin() + + override fun start() { + startJetWhale { + connection { host = "localhost"; port = 5080 } + plugins { register(networkAgent) } + } + } + + // expose the agent if the HTTP client needs it — see below +} +``` + +Call it once, from `Application.onCreate()`: + +```kotlin +DebugToolingHolder.instance.start() +``` + +The Gradle side is a single line: + +```kotlin +dependencies { + debugImplementation("com.kitakkun.jetwhale:jetwhale-agent-runtime:") +} +``` + +Verified on AGP 9.3.0 / Kotlin 2.4.10 by reading the built APKs rather than the wiring: the debug +APK's dex carried the debug-only class and the `"jetwhale:"` marker, while the release APK carried +`"noop"` and zero occurrences of the debug-only class. `:tooling` appeared on +`debugRuntimeClasspath` and was absent from `releaseRuntimeClasspath`. + +## HTTP client capture + +The client is built in production code, so give the seam a method that can do nothing: + +```kotlin +// src/main +interface DebugTooling { + fun start() + fun decorate(client: HttpClient) // release: empty body +} +``` + +```kotlin +// src/main — where the client is built +val client = HttpClient().also { DebugToolingHolder.instance.decorate(it) } +``` + +`HttpClient` here is Ktor's type, which production code already depends on — that is what makes it +safe to name in the seam. The rule is only that **JetWhale** types stay out of it. + +The debug implementation holds the agent as a field, so `start()` and `decorate()` share one +instance. Two instances is the classic mistake: the session connects and no traffic ever appears. + +## KMP — no variant source sets + +`src/debug` is an Android Gradle Plugin feature; `expect`/`actual` splits by *platform*, not by +build type, so neither helps. Two options: + +1. **A debug-only Gradle module** holding the JetWhale implementation, with the dependency gated on + a Gradle property. Production code then needs a way to find the implementation without naming + it — a `ServiceLoader`-style lookup, or an `init` block in the debug module that registers + itself into a mutable holder in production code: + + ```kotlin + // production + object DebugToolingHolder { + var instance: DebugTooling = NoOpDebugTooling + } + ``` + + ```kotlin + // debug module, called explicitly from the debug entry point + DebugToolingHolder.instance = JetWhaleDebugTooling() + ``` + +2. **Two thin entry-point modules** — `:app-debug` and `:app-release`, each with its own `main()` + that wires what it needs. More files, but no mutable global and no reflection. + +Option 2 is usually the better fit for a Compose Multiplatform desktop app, where the entry point is +already tiny. Option 1 fits when the entry point is shared and platform-specific. + +## Failure modes + +| Symptom | Cause | +|---|---| +| Release build cannot resolve `createDebugTooling` | Only the debug source set defines it — both variants need one | +| Unresolved JetWhale reference in release | A JetWhale type reached the seam or `src/main` | +| Session connects, no traffic | `start()` and `decorate()` are using different agent instances | +| Nothing appears in the host | The holder was never touched, so `by lazy` never ran — confirm the call site is actually reached |