From e140fb6c0eba7e161cccb58fe926da9443ae8898 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Sun, 19 Jul 2026 05:40:33 +0900 Subject: [PATCH] feat(agent-runtime): candidate-list connection with build-machine IP injection Connection targets become an ordered candidate list tried per attempt with a short per-candidate timeout (2s), walked the same way on reconnect: explicit `host` (when set, wins) -> build-injected LAN addresses -> localhost fallback. The winning candidate is logged at INFO. A new `com.kitakkun.jetwhale.agent` Gradle plugin captures the build machine's non-loopback IPv4 addresses and hostname at build time and generates `applyJetWhaleBuildEnvironment()` into the applied module's Kotlin source set, which registers them via the new public `JetWhaleBuildEnvironment` registry. Call it once before `startJetWhale {}` and a physical device reaches the build machine with no host/IP written; emulators/simulators fall through to localhost. Injection is toggled with `jetwhale { injectBuildHostCandidates = false }` (e.g. for CI); when off the generated function is a no-op. The demo drops its hardcoded host to prove the zero-config path. mDNS discovery (separate PR) plugs in as the optional last-resort candidate source. --- demo/shared/build.gradle.kts | 2 + .../demo/shared/InitializeJetWhale.kt | 8 +- docs/guide/getting-started.md | 59 +++++++++++ .../api/jetwhale-agent-runtime.klib.api | 4 + .../api/jvm/jetwhale-agent-runtime.api | 5 + .../DefaultJetWhaleMessagingService.kt | 44 +++++++-- .../agent/runtime/HostCandidateResolver.kt | 37 +++++++ .../agent/runtime/JetWhaleBuildEnvironment.kt | 63 ++++++++++++ .../agent/runtime/JetWhaleMessagingService.kt | 10 +- .../agent/runtime/JetWhaleServiceDsl.kt | 16 ++- .../runtime/HostCandidateResolverTest.kt | 63 ++++++++++++ jetwhale-gradle-plugin/build.gradle.kts | 18 ++++ .../gradle/GenerateBuildEnvironmentTask.kt | 80 +++++++++++++++ .../jetwhale/gradle/JetWhaleAgentPlugin.kt | 99 +++++++++++++++++++ 14 files changed, 491 insertions(+), 17 deletions(-) create mode 100644 jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/HostCandidateResolver.kt create mode 100644 jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleBuildEnvironment.kt create mode 100644 jetwhale-agent-runtime/src/jvmTest/kotlin/com/kitakkun/jetwhale/agent/runtime/HostCandidateResolverTest.kt create mode 100644 jetwhale-gradle-plugin/src/main/kotlin/com/kitakkun/jetwhale/gradle/GenerateBuildEnvironmentTask.kt create mode 100644 jetwhale-gradle-plugin/src/main/kotlin/com/kitakkun/jetwhale/gradle/JetWhaleAgentPlugin.kt diff --git a/demo/shared/build.gradle.kts b/demo/shared/build.gradle.kts index f5f5529a3..ff9c7434a 100644 --- a/demo/shared/build.gradle.kts +++ b/demo/shared/build.gradle.kts @@ -10,6 +10,8 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.jetbrainsCompose) alias(libs.plugins.composeCompiler) + // Injects the build machine's LAN host candidates so the demo needs no hardcoded host/IP. + id("com.kitakkun.jetwhale.agent") } kotlin { diff --git a/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/InitializeJetWhale.kt b/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/InitializeJetWhale.kt index 6fa31ab2c..c576809c4 100644 --- a/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/InitializeJetWhale.kt +++ b/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/InitializeJetWhale.kt @@ -3,11 +3,17 @@ package com.kitakkun.jetwhale.demo.shared import com.kitakkun.jetwhale.agent.runtime.KtorLogLevel import com.kitakkun.jetwhale.agent.runtime.LogLevel import com.kitakkun.jetwhale.agent.runtime.startJetWhale +import com.kitakkun.jetwhale.generated.applyJetWhaleBuildEnvironment fun initializeJetWhale() { + // Registers the build machine's LAN addresses (captured at build time by the JetWhale Gradle + // plugin) as connection candidates, so no host/IP has to be written below. A physical device on + // the LAN reaches the build machine directly; emulators/simulators fall through to localhost. + applyJetWhaleBuildEnvironment() + startJetWhale { connection { - host = "localhost" + // No host set: build-injected candidates are tried first, then localhost as the fallback. port = 5443 ssl { // Fetches the host's active CA over the plain channel (via ADB forwarding) and pins diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index f5a38005a..55337586c 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -115,6 +115,65 @@ startJetWhale { } ``` +## Zero-config connection (recommended) + +A physical device on the LAN cannot reach the host over `localhost`, and hardcoding the build +machine's IP is brittle. Apply the JetWhale Gradle plugin to your app module and it captures the +build machine's LAN addresses at build time and injects them as connection candidates — so +`startJetWhale {}` connects with no host/IP written anywhere: + +```kotlin +// build.gradle.kts of the app being debugged +plugins { + id("com.kitakkun.jetwhale.agent") +} +``` + +```kotlin +import com.kitakkun.jetwhale.generated.applyJetWhaleBuildEnvironment +import com.kitakkun.jetwhale.agent.runtime.startJetWhale + +fun initializeJetWhale() { + // Registers the build machine's captured LAN addresses. Call once, before startJetWhale. + applyJetWhaleBuildEnvironment() + + startJetWhale { + connection { + port = 5443 // no host: candidates are used + ssl { trustServerCertificate() } + } + plugins { /* ... */ } + } +} +``` + +### How candidates are resolved + +On each (re)connection attempt the agent tries an ordered list of addresses, each with a short +per-candidate timeout, and connects to the first that answers (logged at INFO): + +1. An explicit `host` you set in `connection { }` (when set — it always wins). +2. The build machine's injected LAN addresses (IPv4s, then hostname). +3. `localhost` — the fallback for emulators, simulators, and ADB-forwarded devices. + +So the same build runs on a physical device (reaches the build machine) and an emulator (falls +through to localhost) with no code change. + +### Staleness and CI + +Addresses are captured when the app is built, so they are correct as long as the build machine keeps +the same addresses — the norm when one machine both builds and debugs. When they change, the agent +simply falls through to the next candidate. Disable injection for CI or release builds: + +```kotlin +jetwhale { + injectBuildHostCandidates = false +} +``` + +With injection disabled (or on a build that never applied the plugin), `applyJetWhaleBuildEnvironment()` +is generated as a no-op, so the call site keeps compiling. + ## Secure connections (wss) By default the agent connects over plain **ws** (port **5080**). The host can additionally serve diff --git a/jetwhale-agent-runtime/api/jetwhale-agent-runtime.klib.api b/jetwhale-agent-runtime/api/jetwhale-agent-runtime.klib.api index c84594eee..e450ee992 100644 --- a/jetwhale-agent-runtime/api/jetwhale-agent-runtime.klib.api +++ b/jetwhale-agent-runtime/api/jetwhale-agent-runtime.klib.api @@ -88,4 +88,8 @@ abstract interface com.kitakkun.jetwhale.agent.runtime/JetWhaleSslConfigurationS abstract fun trustServerCertificate() // com.kitakkun.jetwhale.agent.runtime/JetWhaleSslConfigurationScope.trustServerCertificate|trustServerCertificate(){}[0] } +final object com.kitakkun.jetwhale.agent.runtime/JetWhaleBuildEnvironment { // com.kitakkun.jetwhale.agent.runtime/JetWhaleBuildEnvironment|null[0] + final fun registerHostCandidates(kotlin/String?, kotlin.collections/List) // com.kitakkun.jetwhale.agent.runtime/JetWhaleBuildEnvironment.registerHostCandidates|registerHostCandidates(kotlin.String?;kotlin.collections.List){}[0] +} + final fun com.kitakkun.jetwhale.agent.runtime/startJetWhale(kotlin/Function1) // com.kitakkun.jetwhale.agent.runtime/startJetWhale|startJetWhale(kotlin.Function1){}[0] diff --git a/jetwhale-agent-runtime/api/jvm/jetwhale-agent-runtime.api b/jetwhale-agent-runtime/api/jvm/jetwhale-agent-runtime.api index 9958f2260..52893b25f 100644 --- a/jetwhale-agent-runtime/api/jvm/jetwhale-agent-runtime.api +++ b/jetwhale-agent-runtime/api/jvm/jetwhale-agent-runtime.api @@ -9,6 +9,11 @@ public abstract interface class com/kitakkun/jetwhale/agent/runtime/JetWhaleAppC public abstract fun setDeviceName (Ljava/lang/String;)V } +public final class com/kitakkun/jetwhale/agent/runtime/JetWhaleBuildEnvironment { + public static final field INSTANCE Lcom/kitakkun/jetwhale/agent/runtime/JetWhaleBuildEnvironment; + public final fun registerHostCandidates (Ljava/lang/String;Ljava/util/List;)V +} + public abstract interface class com/kitakkun/jetwhale/agent/runtime/JetWhaleConfigurationScope { public abstract fun app (Lkotlin/jvm/functions/Function1;)V public abstract fun connection (Lkotlin/jvm/functions/Function1;)V diff --git a/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/DefaultJetWhaleMessagingService.kt b/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/DefaultJetWhaleMessagingService.kt index 9fbd13e77..0cb8046f4 100644 --- a/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/DefaultJetWhaleMessagingService.kt +++ b/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/DefaultJetWhaleMessagingService.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.cancellation.CancellationException internal class DefaultJetWhaleMessagingService( @@ -18,16 +19,14 @@ internal class DefaultJetWhaleMessagingService( private var keepAwakeJob: Job? = null private var retryCount = 0 - override fun startService(host: String, port: Int) { - JetWhaleLogger.i("Starting JetWhale Messaging Service") + override fun startService(candidates: List) { + JetWhaleLogger.i("Starting JetWhale Messaging Service; ${candidates.size} host candidate(s)") keepAwakeJob?.cancel() keepAwakeJob = coroutineScope.launch { while (isActive) { - try { - openConnection(host, port) - } catch (e: CancellationException) { - throw e - } catch (_: Throwable) { + val connected = connectWalkingCandidates(candidates) + if (!connected) { + // No candidate was reachable this pass; back off before walking the list again. pluginService.disconnectAll() retryCount++ val delayMillis = (retryCount * RETRY_DELAY_INCREMENT_MILLIS).coerceAtMost(MAX_RECONNECT_DELAY_MILLIS) @@ -37,11 +36,32 @@ internal class DefaultJetWhaleMessagingService( } } - private suspend fun openConnection(host: String, port: Int) { - val connection = socketClient.openConnection(host, port) + /** + * Tries each candidate in order with a short per-candidate timeout. On the first that connects, + * runs the session until it ends and returns true. Returns false when no candidate connected. + */ + private suspend fun connectWalkingCandidates(candidates: List): Boolean { + for (candidate in candidates) { + val connection = try { + withTimeoutOrNull(PER_CANDIDATE_TIMEOUT_MILLIS) { + socketClient.openConnection(candidate.host, candidate.port) + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + JetWhaleLogger.d("Candidate ${candidate.host}:${candidate.port} (${candidate.source}) failed: ${e.message}") + null + } ?: continue - retryCount = 0 + JetWhaleLogger.i("Connected to ${candidate.host}:${candidate.port} (${candidate.source})") + retryCount = 0 + runSession(connection) + return true + } + return false + } + private suspend fun runSession(connection: JetWhaleConnection) { pluginService.startConnection( scope = coroutineScope, sendFrame = { frame -> @@ -69,5 +89,9 @@ internal class DefaultJetWhaleMessagingService( companion object { private const val RETRY_DELAY_INCREMENT_MILLIS = 1000L private const val MAX_RECONNECT_DELAY_MILLIS = 5000L + + // Per-candidate connect timeout: short so an unreachable/stale address falls through to the + // next candidate quickly instead of stalling the whole walk. + private const val PER_CANDIDATE_TIMEOUT_MILLIS = 2000L } } diff --git a/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/HostCandidateResolver.kt b/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/HostCandidateResolver.kt new file mode 100644 index 000000000..d2e0cf8d2 --- /dev/null +++ b/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/HostCandidateResolver.kt @@ -0,0 +1,37 @@ +package com.kitakkun.jetwhale.agent.runtime + +/** + * Builds the ordered list of host addresses the agent tries when connecting, deduplicated by + * host+port. + * + * Ordering: + * - When the app set an explicit host, that address wins and is tried first, then the build-injected + * candidates, then the localhost fallback. + * - Otherwise (no connection block, or only a port set), the build-injected candidates are tried + * first — the zero-config path for a physical device reaching the build machine — followed by the + * localhost fallback for emulators/simulators and ADB-forwarded devices. + */ +internal fun buildHostCandidates( + configuredHost: String, + configuredPort: Int, + hostExplicitlySet: Boolean, +): List { + val injected = JetWhaleBuildEnvironment.candidates(configuredPort) + val localhost = HostCandidate(LOCALHOST, configuredPort, SOURCE_LOCALHOST) + + val ordered = if (hostExplicitlySet) { + buildList { + add(HostCandidate(configuredHost, configuredPort, SOURCE_CONFIGURED)) + addAll(injected) + add(localhost) + } + } else { + injected + localhost + } + + return ordered.distinctBy { it.host to it.port } +} + +private const val LOCALHOST = "localhost" +private const val SOURCE_CONFIGURED = "configured" +private const val SOURCE_LOCALHOST = "localhost-fallback" diff --git a/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleBuildEnvironment.kt b/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleBuildEnvironment.kt new file mode 100644 index 000000000..7bae9890e --- /dev/null +++ b/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleBuildEnvironment.kt @@ -0,0 +1,63 @@ +package com.kitakkun.jetwhale.agent.runtime + +/** + * A single host address the agent may try when connecting to the debugger host. + * + * @property host The hostname or IP address. + * @property port The port to connect on. + * @property source A short label describing where the candidate came from, used only for logging. + */ +internal data class HostCandidate( + val host: String, + val port: Int, + val source: String, +) + +/** + * Registry of host addresses captured at build time and injected into the app by the JetWhale Gradle + * plugin, so `startJetWhale {}` can reach the build machine over the LAN with no connection block. + * + * The JetWhale Gradle plugin generates a small source file that calls [registerHostCandidates] with + * the build machine's non-loopback IPv4 addresses and hostname. Because the addresses are captured + * when the app is built, they are only correct as long as the build machine keeps the same addresses + * — which is the norm for the common "same machine builds and debugs" workflow. When they go stale, + * the agent simply falls through to the other candidates (explicit config, then localhost). + * + * Registration is additive and idempotent per address, so calling it more than once (e.g. from + * multiple generated files) is safe. + */ +public object JetWhaleBuildEnvironment { + private val mutableAddresses: MutableList = mutableListOf() + private var buildHostName: String? = null + + /** + * Registers host addresses captured at build time. + * + * @param hostName The build machine's hostname, or null when unavailable. + * @param addresses The build machine's non-loopback IPv4 addresses. + */ + public fun registerHostCandidates(hostName: String?, addresses: List) { + if (hostName != null && buildHostName == null) buildHostName = hostName + addresses.forEach { address -> + if (address !in mutableAddresses) mutableAddresses.add(address) + } + } + + /** Clears all registered candidates. Intended for tests. */ + internal fun clear() { + mutableAddresses.clear() + buildHostName = null + } + + /** + * The build-injected candidates for [port], IPv4 addresses first (most reliable), then the + * hostname as a last resort in case the LAN resolves it. + */ + internal fun candidates(port: Int): List = buildList { + mutableAddresses.forEach { add(HostCandidate(it, port, SOURCE_BUILD_ADDRESS)) } + buildHostName?.let { add(HostCandidate(it, port, SOURCE_BUILD_HOSTNAME)) } + } + + private const val SOURCE_BUILD_ADDRESS = "build-injected-address" + private const val SOURCE_BUILD_HOSTNAME = "build-injected-hostname" +} diff --git a/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleMessagingService.kt b/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleMessagingService.kt index ce95345f5..eb84bd289 100644 --- a/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleMessagingService.kt +++ b/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleMessagingService.kt @@ -5,10 +5,12 @@ package com.kitakkun.jetwhale.agent.runtime */ internal interface JetWhaleMessagingService { /** - * Starts the messaging service to connect to the JetWhale debugger server. + * Starts the messaging service, connecting to the first reachable host in [candidates]. * - * @param host The hostname or IP address of the JetWhale debugger server. - * @param port The port number of the JetWhale debugger server. + * The candidates are tried in order on every (re)connection attempt, each with a short timeout, + * so a stale or unreachable address falls through to the next quickly. + * + * @param candidates The ordered host addresses to try; must be non-empty. */ - fun startService(host: String, port: Int) + fun startService(candidates: List) } diff --git a/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleServiceDsl.kt b/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleServiceDsl.kt index 4e10d5228..f3db5fd9f 100644 --- a/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleServiceDsl.kt +++ b/jetwhale-agent-runtime/src/commonMain/kotlin/com/kitakkun/jetwhale/agent/runtime/JetWhaleServiceDsl.kt @@ -33,8 +33,11 @@ public fun startJetWhale(configure: JetWhaleConfigurationScope.() -> Unit) { ), ) service.startService( - host = configuration.connection.host, - port = configuration.connection.port, + candidates = buildHostCandidates( + configuredHost = configuration.connection.host, + configuredPort = configuration.connection.port, + hostExplicitlySet = configuration.connection.hostExplicitlySet, + ), ) } @@ -172,7 +175,16 @@ private class JetWhaleAppConfiguration : JetWhaleAppConfigurationScope { } private class JetWhaleConnectionConfiguration : JetWhaleConnectionConfigurationScope { + // Tracks whether the app set an explicit host so it can take precedence over build-injected + // candidates. The default "localhost" is not treated as an explicit choice. + var hostExplicitlySet: Boolean = false + private set + override var host: String = "localhost" + set(value) { + field = value + hostExplicitlySet = true + } override var port: Int = 8080 val sslConfiguration: JetWhaleSslConfiguration = JetWhaleSslConfiguration() diff --git a/jetwhale-agent-runtime/src/jvmTest/kotlin/com/kitakkun/jetwhale/agent/runtime/HostCandidateResolverTest.kt b/jetwhale-agent-runtime/src/jvmTest/kotlin/com/kitakkun/jetwhale/agent/runtime/HostCandidateResolverTest.kt new file mode 100644 index 000000000..c333deb88 --- /dev/null +++ b/jetwhale-agent-runtime/src/jvmTest/kotlin/com/kitakkun/jetwhale/agent/runtime/HostCandidateResolverTest.kt @@ -0,0 +1,63 @@ +package com.kitakkun.jetwhale.agent.runtime + +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class HostCandidateResolverTest { + @BeforeTest + fun setUp() = JetWhaleBuildEnvironment.clear() + + @AfterTest + fun tearDown() = JetWhaleBuildEnvironment.clear() + + @Test + fun `no injected candidates and default host yields localhost only`() { + val candidates = buildHostCandidates(configuredHost = "localhost", configuredPort = 5080, hostExplicitlySet = false) + assertEquals(listOf("localhost" to 5080), candidates.map { it.host to it.port }) + } + + @Test + fun `injected addresses are tried before localhost when no explicit host`() { + JetWhaleBuildEnvironment.registerHostCandidates(hostName = "build-mac", addresses = listOf("192.168.1.10", "10.0.0.5")) + + val candidates = buildHostCandidates(configuredHost = "localhost", configuredPort = 5443, hostExplicitlySet = false) + + assertEquals( + listOf("192.168.1.10", "10.0.0.5", "build-mac", "localhost"), + candidates.map { it.host }, + ) + candidates.forEach { assertEquals(5443, it.port) } + } + + @Test + fun `explicit host wins and is tried first`() { + JetWhaleBuildEnvironment.registerHostCandidates(hostName = null, addresses = listOf("192.168.1.10")) + + val candidates = buildHostCandidates(configuredHost = "192.168.9.9", configuredPort = 5080, hostExplicitlySet = true) + + assertEquals( + listOf("192.168.9.9", "192.168.1.10", "localhost"), + candidates.map { it.host }, + ) + } + + @Test + fun `duplicate host and port are removed`() { + JetWhaleBuildEnvironment.registerHostCandidates(hostName = null, addresses = listOf("localhost")) + + val candidates = buildHostCandidates(configuredHost = "localhost", configuredPort = 5080, hostExplicitlySet = false) + + assertEquals(1, candidates.size) + } + + @Test + fun `registration is idempotent per address`() { + JetWhaleBuildEnvironment.registerHostCandidates(hostName = "m", addresses = listOf("192.168.1.10")) + JetWhaleBuildEnvironment.registerHostCandidates(hostName = "m", addresses = listOf("192.168.1.10")) + + val addresses = JetWhaleBuildEnvironment.candidates(5080).map { it.host } + assertEquals(listOf("192.168.1.10", "m"), addresses) + } +} diff --git a/jetwhale-gradle-plugin/build.gradle.kts b/jetwhale-gradle-plugin/build.gradle.kts index acebee218..9a53d8bc9 100644 --- a/jetwhale-gradle-plugin/build.gradle.kts +++ b/jetwhale-gradle-plugin/build.gradle.kts @@ -14,6 +14,24 @@ group = "com.kitakkun.jetwhale" // Pass -PjetwhaleSnapshot to publish a SNAPSHOT of the current version instead of a release. version = libs.versions.jetwhale.get() + if (hasProperty("jetwhaleSnapshot")) "-SNAPSHOT" else "" +dependencies { + // Needed to wire the generated build-environment source into Kotlin source sets of the applied + // module (com.kitakkun.jetwhale.agent plugin). compileOnly: the consumer provides the Kotlin + // plugin at apply time. + compileOnly(libs.kotlinGradlePlugin) +} + +gradlePlugin { + plugins { + // Applied by an app being debugged to inject the build machine's host candidates so + // startJetWhale {} reaches it with no connection block. + register("jetwhaleAgent") { + id = "com.kitakkun.jetwhale.agent" + implementationClass = "com.kitakkun.jetwhale.gradle.JetWhaleAgentPlugin" + } + } +} + jetwhalePublish { artifactId = "jetwhale-gradle-plugin" name = "JetWhale Gradle Plugin" diff --git a/jetwhale-gradle-plugin/src/main/kotlin/com/kitakkun/jetwhale/gradle/GenerateBuildEnvironmentTask.kt b/jetwhale-gradle-plugin/src/main/kotlin/com/kitakkun/jetwhale/gradle/GenerateBuildEnvironmentTask.kt new file mode 100644 index 000000000..45261dd12 --- /dev/null +++ b/jetwhale-gradle-plugin/src/main/kotlin/com/kitakkun/jetwhale/gradle/GenerateBuildEnvironmentTask.kt @@ -0,0 +1,80 @@ +package com.kitakkun.jetwhale.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +/** + * Generates the `applyJetWhaleBuildEnvironment()` source file that registers the build machine's host + * candidates with the agent runtime. When injection is disabled (or no address was found), the + * generated function is a no-op so app code that calls it keeps compiling. + */ +@DisableCachingByDefault(because = "Trivial code generation from build-time host info; caching adds no value") +abstract class GenerateBuildEnvironmentTask : DefaultTask() { + @get:Input + abstract val injectEnabled: Property + + @get:Input + @get:Optional + abstract val hostName: Property + + @get:Input + abstract val addresses: ListProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val packageDir = outputDir.get().asFile.resolve(PACKAGE.replace('.', '/')) + packageDir.mkdirs() + packageDir.resolve("JetWhaleBuildEnvironmentGenerated.kt").writeText(renderSource()) + } + + private fun renderSource(): String { + val enabled = injectEnabled.get() + val addressList = addresses.get() + val host = hostName.orNull + + val body = if (!enabled || (addressList.isEmpty() && host == null)) { + // Injection disabled or nothing to inject: emit a no-op so callers still compile/link. + " // Build-host candidate injection is disabled or found no addresses." + } else { + val addressLiterals = addressList.joinToString(separator = ", ") { "\"${it.escape()}\"" } + val hostLiteral = host?.let { "\"${it.escape()}\"" } ?: "null" + """ JetWhaleBuildEnvironment.registerHostCandidates( + | hostName = $hostLiteral, + | addresses = listOf($addressLiterals), + | ) + """.trimMargin() + } + + return """ + |// Generated by the JetWhale Gradle plugin. Do not edit. + |package $PACKAGE + | + |import com.kitakkun.jetwhale.agent.runtime.JetWhaleBuildEnvironment + | + |/** + | * Registers the build machine's host candidates captured at build time. Call once before + | * `startJetWhale {}`. + | */ + |public fun applyJetWhaleBuildEnvironment() { + |$body + |} + | + """.trimMargin() + } + + private fun String.escape(): String = replace("\\", "\\\\").replace("\"", "\\\"") + + private companion object { + const val PACKAGE = "com.kitakkun.jetwhale.generated" + } +} diff --git a/jetwhale-gradle-plugin/src/main/kotlin/com/kitakkun/jetwhale/gradle/JetWhaleAgentPlugin.kt b/jetwhale-gradle-plugin/src/main/kotlin/com/kitakkun/jetwhale/gradle/JetWhaleAgentPlugin.kt new file mode 100644 index 000000000..2506a7580 --- /dev/null +++ b/jetwhale-gradle-plugin/src/main/kotlin/com/kitakkun/jetwhale/gradle/JetWhaleAgentPlugin.kt @@ -0,0 +1,99 @@ +package com.kitakkun.jetwhale.gradle + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.provider.Property +import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.register +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinSingleTargetExtension +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface + +/** + * Configuration for the `com.kitakkun.jetwhale.agent` plugin. + */ +interface JetWhaleAgentExtension { + /** + * Whether to inject the build machine's host candidates (non-loopback IPv4 addresses + hostname) + * into the app. When true (the default), the plugin generates a source file the app can apply so + * `startJetWhale {}` reaches the build machine over the LAN with no connection block. + * + * Turn this off for CI builds, where the build machine's addresses are meaningless to the shipped + * app: `jetwhale { injectBuildHostCandidates = false }`. + */ + val injectBuildHostCandidates: Property +} + +/** + * Injects the build machine's host addresses into an app being debugged. + * + * At build time the plugin collects the machine's non-loopback IPv4 addresses and hostname and + * generates a source file into the module's `commonMain` (or `main`) Kotlin source set exposing: + * + * ```kotlin + * public fun applyJetWhaleBuildEnvironment() + * ``` + * + * Call it once before `startJetWhale {}` (e.g. at the top of your `initializeJetWhale()`); it + * registers the captured addresses with the agent runtime so a physical device reaching the build + * machine over the LAN needs no explicit host. The addresses are captured at build time, so they are + * only correct while the build machine keeps the same addresses — the norm when the same machine + * builds and debugs. Explicit `connection { host = ... }` config always takes precedence. + */ +class JetWhaleAgentPlugin : Plugin { + override fun apply(project: Project) { + val extension = project.extensions.create("jetwhale", JetWhaleAgentExtension::class.java) + extension.injectBuildHostCandidates.convention(true) + + val outputDir = project.layout.buildDirectory.dir("generated/jetwhale/kotlin") + + val generateTask = project.tasks.register( + "generateJetWhaleBuildEnvironment", + ) { + injectEnabled.set(extension.injectBuildHostCandidates) + // Captured at configuration time on the build machine; staleness is acceptable (see class + // KDoc) and the values are task inputs so a machine/address change re-runs generation. + hostName.set(resolveHostName()) + addresses.set(resolveNonLoopbackIpv4Addresses()) + this.outputDir.set(outputDir) + } + + // Wire the generated directory into the appropriate Kotlin source set and make compilation + // depend on generation. + project.plugins.withId("org.jetbrains.kotlin.multiplatform") { + val kotlin = project.extensions.getByType() + kotlin.sourceSets.getByName("commonMain").kotlin.srcDir(generateTask) + } + val singleTargetIds = listOf( + "org.jetbrains.kotlin.jvm", + "org.jetbrains.kotlin.android", + ) + singleTargetIds.forEach { pluginId -> + project.plugins.withId(pluginId) { + val kotlin = project.extensions.getByType>() + kotlin.sourceSets.getByName("main").kotlin.srcDir(generateTask) + } + } + } + + private fun resolveHostName(): String? = try { + InetAddress.getLocalHost().hostName + } catch (e: Exception) { + null + } + + /** Enumerates the build machine's up, non-loopback IPv4 addresses. */ + private fun resolveNonLoopbackIpv4Addresses(): List = try { + NetworkInterface.getNetworkInterfaces().asSequence() + .filter { it.isUp && !it.isLoopback } + .flatMap { it.inetAddresses.asSequence() } + .filterIsInstance() + .map { it.hostAddress } + .distinct() + .toList() + } catch (e: Exception) { + emptyList() + } +}