Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions demo/shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions jetwhale-agent-runtime/api/jetwhale-agent-runtime.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -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<kotlin/String>) // com.kitakkun.jetwhale.agent.runtime/JetWhaleBuildEnvironment.registerHostCandidates|registerHostCandidates(kotlin.String?;kotlin.collections.List<kotlin.String>){}[0]
}

final fun com.kitakkun.jetwhale.agent.runtime/startJetWhale(kotlin/Function1<com.kitakkun.jetwhale.agent.runtime/JetWhaleConfigurationScope, kotlin/Unit>) // com.kitakkun.jetwhale.agent.runtime/startJetWhale|startJetWhale(kotlin.Function1<com.kitakkun.jetwhale.agent.runtime.JetWhaleConfigurationScope,kotlin.Unit>){}[0]
5 changes: 5 additions & 0 deletions jetwhale-agent-runtime/api/jvm/jetwhale-agent-runtime.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<HostCandidate>) {
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)
Expand All @@ -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<HostCandidate>): 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 ->
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<HostCandidate> {
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"
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String>) {
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<HostCandidate> = 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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<HostCandidate>)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)
}

Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading