feat(android): pure-Kotlin foundation — AW0 scaffold + AW1 modules (218 tests green)

Implements the verifiable pure-Kotlin core of the Android client per
docs/ANDROID_CLIENT_PLAN.md, mirroring the iOS SPM package set as Gradle modules.
Built + reviewed via multi-agent workflow (explore→implement→verify→review),
then review findings fixed with regression tests.

Modules (all pure JVM, kotlin("jvm"); Android-framework modules scaffolded but
gated off in settings — no Android SDK in this env):
- :wire-protocol  — frozen wire contract (sealed Client/ServerMessage, enums,
  HostEndpoint CSWSH origin derivation, transport interfaces), hand-rolled codec
  byte-identical to the server's JSON.stringify + tolerant kotlinx decode.
- :session-core   — ReconnectMachine (1→2→4→8→16→30 backoff), PingScheduler,
  GateTracker (two-line epoch guard), AwayDigest, UnreadLedger, TitleSanitizer,
  KeyByteMap (byte-matches public/keybar.ts).
- :api-client     — all REST routes, tolerant decode, Origin-iff-guarded, prefs
  unknown-key preservation, pairing probe + host-tier classifier.
- :client-tls     — pure half: PKCS#12 parse, X509KeyManager alias logic,
  CertificateSummary, provider-agnostic wrong-passphrase classification.
- :test-support   — FakeTransport / FakeHttpTransport / virtual-clock fakes.

Verify: ./gradlew clean test → 218 tests, 0 failures (wire-protocol 47,
session-core 60, api-client 69, client-tls 27, test-support 15).

Review (3 lenses, 8/10 each) findings fixed: sessionId JSON-injection escape,
CancellationException propagation in PingScheduler, PairingProbe close-on-cancel
leak, HostEndpoint trim, HostClassifier hoisted to :wire-protocol (type unified),
BouncyCastle-portable wrong-passphrase detection, lone-surrogate escaping.

Known gap (documented, deferred): /push/fcm-token has no server route yet —
plan task A33 (src/push/fcm.ts) delivers it.
This commit is contained in:
Yaojia Wang
2026-07-08 13:45:07 +02:00
parent cf88e7c588
commit 4ea8f7862a
106 changed files with 7502 additions and 0 deletions

42
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# Gradle
.gradle/
build/
**/build/
!gradle/wrapper/gradle-wrapper.jar
!src/**/build/
# Gradle caches / config-cache
.gradle/configuration-cache/
# IDE — IntelliJ IDEA / Android Studio
.idea/
*.iml
*.ipr
*.iws
captures/
.navigation/
# Local machine config (never commit)
local.properties
# Android build outputs (relevant once the SDK-gated modules are enabled)
*.apk
*.aab
*.ap_
*.dex
release/
proguard/
# Secrets — never commit
google-services.json
**/google-services.json
*.keystore
*.jks
*.p12
service-account*.json
# OS cruft
.DS_Store
# Kover / test reports
**/kover/

95
android/README.md Normal file
View File

@@ -0,0 +1,95 @@
# WebTerm — Android client
A native Android client for the WebTerm browser-terminal server, targeting functional
parity with the shipped **iOS** client. See the full design in
[`docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md) (stack §2, module
architecture §3, server contract §4, task waves §5).
This directory is a **Gradle multi-module** project. The module set mirrors the iOS
SPM package set and inherits its rule: *dependencies only flow down; nothing points
upward* (ARCHITECTURE §1).
## ⚠️ No-SDK constraint (why only 5 modules build here)
The current build environment has **no Android SDK**. Everything that can be pure
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the
Android framework (`com.android.*` plugins) is **scaffolded but disabled**.
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):**
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`.
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts)
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
To bring the Android modules online later: install an SDK, add
`local.properties``sdk.dir`, add the Android Gradle Plugin + `google()` to
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in
each stub.
## Module map (mirror of the iOS SPM packages — plan §3)
| iOS SPM package | Android module | Kind | Status |
|------------------------|------------------------|-------------------------------|--------|
| WireProtocol | `:wire-protocol` | pure Kotlin/JVM | ✅ built |
| SessionCore (reducers) | `:session-core` | pure Kotlin/JVM | ✅ built |
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated |
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated |
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
> impls, JVM) is owned by task **A7** and will be added then. The iOS
> `URLSession*Transport`s consolidate into it (plan §3 framing note).
### Dependency graph (arrows = "depends on")
```
:app (SDK-gated)
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls-android
(SDK-gated) │ │ (SDK-gated) │
│ │ │ ▼
│ │ │ :client-tls (pure)
└──────┬───────┴──────────┴──────────────┬────────────────┘
▼ ▼
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
└──────── :test-support → test source sets only
```
`:wire-protocol` is the **frozen shared contract** (Android analogue of
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
interfaces. New wire types are added **only** here (a coordination point).
## Toolchain
- **Gradle** 9.6.1 (via the committed wrapper — always use `./gradlew`).
- **Kotlin** 2.3.21 (matches the Kotlin embedded in Gradle 9.6.1).
- **JVM toolchain** 17 (`jvmToolchain(17)` in every module).
- Versions are pinned in the version catalog
[`gradle/libs.versions.toml`](gradle/libs.versions.toml): kotlinx-serialization-json,
kotlinx-coroutines-core/-test, JUnit5 (Jupiter), Turbine, MockK.
Pure modules apply `kotlin("jvm")` + `kotlin("plugin.serialization")`, wire the
`libs.bundles.unit-test` bundle into `testImplementation`, and run tests on the
JUnit Platform (`tasks.test { useJUnitPlatform() }`).
## Build & test
```bash
# Use the committed wrapper for everything.
./gradlew help # sanity: the build configures
./gradlew projects # lists the 5 pure modules
./gradlew build # compile all pure modules
./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
```
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
> small focused files — same discipline as the rest of the repo.

View File

@@ -0,0 +1,27 @@
// :api-client — pure REST client logic (12 routes, tolerant decode, Origin-iff-
// guarded, strict query encoding, prefs unknown-key preservation, pairing probe /
// PairingError / HostClassifier tiers). Consumes HttpTransport by interface only.
// Depends only on :wire-protocol.
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(17)
}
dependencies {
api(project(":wire-protocol"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
testImplementation(project(":test-support"))
testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher)
}
tasks.test {
useJUnitPlatform()
}

View File

@@ -0,0 +1,10 @@
package wang.yaojia.webterm.api.models
/**
* The two verdicts `POST /hook/decision` accepts — anything else is a 400 server-side. The [wire]
* value is what goes into the request body (`{ sessionId, decision, token }`).
*/
public enum class HookDecision(public val wire: String) {
ALLOW("allow"),
DENY("deny"),
}

View File

@@ -0,0 +1,41 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.StatusTelemetry
import java.util.UUID
/**
* One running (or just-exited) server session from `GET /live-sessions` (read-only discovery — NO
* Origin header). Mirrors `src/types.ts` `LiveSessionInfo` and iOS `APIClient.LiveSessionInfo`
* field-for-field.
*
* Tolerant decode at the untrusted boundary (plan §4):
* - identity/geometry ([id]/[createdAt]/[clientCount]/[exited]/[cols]/[rows]) are REQUIRED — an
* entry missing them is dropped by `LossyDecode.listOrNull`;
* - an unrecognized `status` string maps to [ClaudeStatus.UNKNOWN] (a future server status must
* not make a running session invisible);
* - absent `cwd`/`telemetry`/`lastOutputAt` degrade to `null`.
*
* NOTE: ms timestamps ([createdAt]/[lastOutputAt]) are `Long` — epoch-ms overflows a 32-bit `Int`
* (Swift's `Int` is 64-bit, so iOS used `Int`).
*/
@Serializable
public data class LiveSessionInfo(
@Serializable(with = UuidSerializer::class) val id: UUID,
/** Server `Date.now()` at spawn (ms since epoch). */
val createdAt: Long,
/** Devices currently attached (JOIN/mirror semantics). */
val clientCount: Int,
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
val exited: Boolean,
val cwd: String? = null,
/** Current PTY size (latest-writer-wins on the server). */
val cols: Int,
val rows: Int,
/** Latest statusLine telemetry, if any (B2). */
val telemetry: StatusTelemetry? = null,
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
val lastOutputAt: Long? = null,
)

View File

@@ -0,0 +1,48 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
/**
* Tolerant decode config for the UNTRUSTED server boundary (plan §4 / A8). The server is an
* untrusted input source here: unknown keys are ignored, malformed list elements are dropped one
* by one, and nothing crashes on bad input. The Android analogue of iOS's per-field `try?`
* tolerance in `APIClient/Models.swift`.
*
* ENCODE is deterministic (`ignoreUnknownKeys`/`isLenient` only affect parsing) so the same
* instance also encodes request bodies (`/hook/decision`, `/push/fcm-token`, `PUT /prefs`).
*/
internal val ModelJson: Json = Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Per-element / per-body tolerant decoders (mirror iOS `LiveSessionInfo.decodeList` /
* `LossyBox` / `LossyList`).
*
* - [listOrNull]: a non-array top level yields `null` (the caller raises
* `ApiClientError.InvalidResponseBody` — the pairing "port speaks HTTP but isn't web-terminal"
* signal); malformed elements are dropped, good ones kept.
* - [listOrEmpty]: a non-array top level yields `[]` (matches iOS `TimelineEvent.decodeList`, which
* the server itself returns when timeline capture is disabled).
* - [objectOrNull]: a single object that fails to decode yields `null` (caller raises
* `InvalidResponseBody`).
*/
internal object LossyDecode {
fun <T> listOrNull(bytes: ByteArray, element: KSerializer<T>): List<T>? {
val array = parseArray(bytes) ?: return null
return array.mapNotNull { el -> runCatching { ModelJson.decodeFromJsonElement(element, el) }.getOrNull() }
}
fun <T> listOrEmpty(bytes: ByteArray, element: KSerializer<T>): List<T> =
listOrNull(bytes, element) ?: emptyList()
fun <T> objectOrNull(bytes: ByteArray, deserializer: KSerializer<T>): T? =
runCatching { ModelJson.decodeFromString(deserializer, bytes.decodeToString()) }.getOrNull()
private fun parseArray(bytes: ByteArray): JsonArray? =
runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) as? JsonArray }.getOrNull()
}

View File

@@ -0,0 +1,69 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* One running session belonging to a project (`src/types.ts` `ProjectSessionRef`). The server
* mirrors [LiveSessionInfo] fields into this ref. Tolerant: unknown status → [ClaudeStatus.UNKNOWN],
* absent title → `null`; missing id/clientCount/createdAt/exited drops the ref (nested lossy list).
*/
@Serializable
public data class ProjectSessionRef(
@Serializable(with = UuidSerializer::class) val id: UUID,
val title: String? = null,
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
val clientCount: Int,
val createdAt: Long,
val exited: Boolean,
)
/**
* A discovered project (git repo or recently-used cwd) — `GET /projects` (`src/types.ts`
* `ProjectInfo`). There is NO `namespace` field on the wire; namespace grouping is a client concept
* surfaced only via `UiPrefs.collapsed` group-keys.
*/
@Serializable
public data class ProjectInfo(
val name: String,
val path: String,
val isGit: Boolean,
val branch: String? = null,
/** Uncommitted changes; only present when the server runs the dirty check. */
val dirty: Boolean? = null,
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
val lastActiveMs: Long? = null,
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
)
/** One entry from `git worktree list --porcelain` (`src/types.ts` `WorktreeInfo`). */
@Serializable
public data class WorktreeInfo(
val path: String,
/** Branch name; `null` on detached HEAD. */
val branch: String? = null,
val head: String? = null,
val isMain: Boolean = false,
val isCurrent: Boolean = false,
val locked: Boolean? = null,
val prunable: Boolean? = null,
)
/** Detailed view of one project — `GET /projects/detail?path=` (`src/types.ts` `ProjectDetail`). */
@Serializable
public data class ProjectDetail(
val name: String,
val path: String,
val isGit: Boolean,
val branch: String? = null,
val dirty: Boolean? = null,
@Serializable(with = WorktreeInfoListSerializer::class)
val worktrees: List<WorktreeInfo> = emptyList(),
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
val hasClaudeMd: Boolean = false,
/** CLAUDE.md content (server-truncated for display) when present. */
val claudeMd: String? = null,
)

View File

@@ -0,0 +1,59 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.decodeFromJsonElement
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* Decode a server session id as [UUID]. Server ids are lowercase `crypto.randomUUID()` strings;
* a non-UUID string throws, so `LossyDecode` drops that list element (matches iOS decoding
* `UUID.self` — a malformed id must not surface). Serializes back lowercase (`UUID.toString()`).
*/
internal object UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString())
}
/**
* Decode [ClaudeStatus] by its `wire` value; an unknown/future value maps to
* [ClaudeStatus.UNKNOWN] rather than dropping the entry — a new server status must never make a
* running session invisible (iOS `rawStatus.flatMap(...) ?? .unknown`).
*/
internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ClaudeStatus", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ClaudeStatus =
ClaudeStatus.fromWire(decoder.decodeString()) ?: ClaudeStatus.UNKNOWN
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
}
/**
* A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,
* `ProjectDetail.worktrees`). A bad nested element must not fail the whole parent object.
*/
internal class LossyListSerializer<T>(private val element: KSerializer<T>) : KSerializer<List<T>> {
private val delegate = ListSerializer(element)
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: List<T>) = delegate.serialize(encoder, value)
override fun deserialize(decoder: Decoder): List<T> {
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
val array = json.decodeJsonElement() as? JsonArray ?: return emptyList()
return array.mapNotNull { runCatching { json.json.decodeFromJsonElement(element, it) }.getOrNull() }
}
}
internal object ProjectSessionRefListSerializer :
KSerializer<List<ProjectSessionRef>> by LossyListSerializer(ProjectSessionRef.serializer())
internal object WorktreeInfoListSerializer :
KSerializer<List<WorktreeInfo>> by LossyListSerializer(WorktreeInfo.serializer())

View File

@@ -0,0 +1,17 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* `GET /live-sessions/:id/preview` response: the tail of the session's ring buffer for a
* read-only thumbnail (no attach, no client registered). [data] is opaque ANSI/UTF-8 — feed it to
* a terminal, never parse it. All fields required (a malformed body → `InvalidResponseBody`).
*/
@Serializable
public data class SessionPreview(
@Serializable(with = UuidSerializer::class) val id: UUID,
val cols: Int,
val rows: Int,
val data: String,
)

View File

@@ -0,0 +1,12 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* `GET /config/ui` response (`{ allowAutoMode }`). Reserved for a future permission-mode switcher
* (filters the high-risk raw `auto` mode); the plan-gate three-way UI does not consume it.
*/
@Serializable
public data class UiConfig(
val allowAutoMode: Boolean,
)

View File

@@ -0,0 +1,106 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
/**
* The cross-device Projects-UI prefs blob (`GET /prefs` RO · `PUT /prefs` G) — an
* OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web client
* (`favourites: string[]`, `collapsed: { group-key: true }`); ALL OTHER top-level keys are
* preserved verbatim across decode → mutate → encode, so an Android `PUT` can never clobber prefs
* written by the web/iOS client or a future server (`PUT` replaces the WHOLE blob server-side, so
* key preservation is correctness, not politeness).
*
* Immutable snapshot: [withFavourites]/[withCollapsed] return NEW copies that replace exactly one
* known key and carry every other key through untouched. Numbers round-trip verbatim because a
* parsed [JsonPrimitive] keeps its source token (`42` never re-encodes as `42.0`).
*/
public class UiPrefs private constructor(private val storage: JsonObject) {
/**
* Favourited project paths (★): non-empty JSON strings, de-duplicated, original order kept.
* Wrong-typed entries are dropped, not fatal (mirrors `public/prefs.ts sanitizePrefs`).
*/
public val favourites: List<String>
get() {
val array = storage[KEY_FAVOURITES] as? JsonArray ?: return emptyList()
val out = LinkedHashSet<String>()
for (element in array) {
val primitive = element as? JsonPrimitive ?: continue
if (!primitive.isString) continue
val path = primitive.content
if (path.isNotEmpty()) out.add(path)
}
return out.toList()
}
/**
* Namespace group-key → collapsed. Only literal `true` values count (expanded is the default;
* both web and server sanitizers agree). A JSON string `"true"` does NOT count.
*/
public val collapsed: Map<String, Boolean>
get() {
val obj = storage[KEY_COLLAPSED] as? JsonObject ?: return emptyMap()
val out = LinkedHashMap<String, Boolean>()
for ((key, value) in obj) {
if (key.isEmpty()) continue
if (value is JsonPrimitive && !value.isString && value.booleanOrNull == true) {
out[key] = true
}
}
return out
}
/** New snapshot with `favourites` replaced; every other key untouched (position preserved). */
public fun withFavourites(favourites: List<String>): UiPrefs {
val next = LinkedHashMap<String, JsonElement>(storage)
next[KEY_FAVOURITES] = JsonArray(favourites.map { JsonPrimitive(it) })
return UiPrefs(JsonObject(next))
}
/** New snapshot with `collapsed` replaced; every other key untouched (position preserved). */
public fun withCollapsed(collapsed: Map<String, Boolean>): UiPrefs {
val next = LinkedHashMap<String, JsonElement>(storage)
next[KEY_COLLAPSED] = JsonObject(collapsed.mapValues { JsonPrimitive(it.value) })
return UiPrefs(JsonObject(next))
}
/** Encode the FULL blob (known + unknown keys) for `PUT /prefs`. */
public fun encodeBody(): ByteArray =
ModelJson.encodeToString(JsonObject.serializer(), storage).encodeToByteArray()
override fun equals(other: Any?): Boolean = other is UiPrefs && other.storage == storage
override fun hashCode(): Int = storage.hashCode()
override fun toString(): String = "UiPrefs(storage=$storage)"
public companion object {
private const val KEY_FAVOURITES = "favourites"
private const val KEY_COLLAPSED = "collapsed"
/** Fresh prefs (e.g. first write from a device that never fetched). */
public fun create(
favourites: List<String> = emptyList(),
collapsed: Map<String, Boolean> = emptyMap(),
): UiPrefs = UiPrefs(
JsonObject(
mapOf(
KEY_FAVOURITES to JsonArray(favourites.map { JsonPrimitive(it) }),
KEY_COLLAPSED to JsonObject(collapsed.mapValues { JsonPrimitive(it.value) }),
),
),
)
/**
* Decode a `/prefs` body. `null` = top level is not a JSON object — callers surface
* `InvalidResponseBody` LOUDLY instead of degrading to empty prefs (an empty-based `PUT`
* would wipe the server blob).
*/
public fun decode(bytes: ByteArray): UiPrefs? {
val element = runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) }.getOrNull()
return (element as? JsonObject)?.let { UiPrefs(it) }
}
}
}

View File

@@ -0,0 +1,131 @@
package wang.yaojia.webterm.api.pairing
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.PortUnreachableException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import java.security.cert.CertPathBuilderException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
/**
* Error taxonomy for the pairing probe (Android port of iOS `APIClient.PairingError`). Each case
* maps one probe failure mode to actionable UI copy (`PairingViewModel`, A19, renders these).
*
* DEVIATION from iOS (plan R12): the iOS `localNetworkDenied` case is DROPPED — Android has no
* iOS-style Local-Network permission prompt (a plain WS to a LAN IP needs no runtime permission),
* so the ENETDOWN→localNetworkDenied diagnosis is dead weight here. iOS `atsBlocked` is kept as
* [CleartextBlocked]: the genuine Android analogue is a `network_security_config` cleartext block
* surfaced as [java.net.UnknownServiceException] (plan §6.9/§8 cleartext posture).
*/
public sealed interface PairingError {
/** Nothing answered — connection refused / no route / DNS failure (`ConnectException`, `UnknownHostException`, …). */
public data class HostUnreachable(val underlying: String) : PairingError
/**
* The port speaks HTTP but `GET /live-sessions` did not return the web-terminal shape (a JSON
* array) — "端口对吗?". Also used when probe ② connects but the socket never speaks our protocol.
*/
public data object HttpOkButNotWebTerminal : PairingError
/**
* The WS upgrade (or the guarded kill round-trip) was rejected — the host's Origin whitelist
* does not contain our dialed origin. [hint] carries the exact `ALLOWED_ORIGINS=<origin>` line,
* always derived from [HostEndpoint.originHeader] (never hand-assembled; default ports omitted).
*/
public data class OriginRejected(val hint: String) : PairingError
/**
* Cleartext (`ws://`/`http://`) to a host outside the app's `network_security_config` allowlist
* was blocked by the platform ([java.net.UnknownServiceException]). Android analogue of iOS
* `atsBlocked`. [host] is the dialed host for the actionable copy.
*/
public data class CleartextBlocked(val host: String) : PairingError
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
public data object TlsFailure : PairingError
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
public data object Timeout : PairingError
public companion object {
/**
* Actionable copy for [OriginRejected] — always derived from the SINGLE origin source
* ([HostEndpoint.originHeader]), never hand-assembled. Ported verbatim from iOS.
*/
public fun originRejectedHint(endpoint: HostEndpoint): String =
"服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=${endpoint.originHeader}" +
"(与 App 连接的 URL 完全一致)后重启 web-terminal再重试配对。"
/**
* Classify a transport-level [Throwable] thrown by [wang.yaojia.webterm.wire.HttpTransport]
* or [wang.yaojia.webterm.wire.TermTransport] into the probe taxonomy. Walks the bounded
* `cause` chain (OkHttp wraps causes; never trust an error graph not to cycle) so wrapped
* roots (e.g. an `IOException` wrapping a `ConnectException`) are seen.
*
* @param unrecognizedFallback used by probe step ② — after step ① proved the host reachable
* AND web-terminal-shaped, an upgrade failure with no recognizable network cause is, by
* elimination, the server's Origin 401 (its only upgrade-reject path).
*/
public fun classify(
error: Throwable,
endpoint: HostEndpoint,
unrecognizedFallback: PairingError? = null,
): PairingError {
val chain = causeChain(error)
if (chain.any { it is UnknownServiceException }) {
return CleartextBlocked(host = HostClassifier.hostOf(endpoint))
}
if (chain.any { isTimeout(it) }) return Timeout
if (chain.any { isTls(it) }) return TlsFailure
if (chain.any { isConnectivity(it) }) {
return HostUnreachable(underlying = describe(error))
}
return unrecognizedFallback ?: HostUnreachable(underlying = describe(error))
}
private const val MAX_CAUSE_DEPTH = 8
/** Bounded walk of [Throwable.cause] with an identity cycle-guard (defensive). */
private fun causeChain(error: Throwable): List<Throwable> {
val chain = mutableListOf<Throwable>()
var current: Throwable? = error
while (current != null && chain.size < MAX_CAUSE_DEPTH) {
if (chain.any { it === current }) break
chain.add(current)
current = current.cause
}
return chain
}
// `SocketTimeoutException` extends `InterruptedIOException`; OkHttp's overall call-timeout
// also surfaces as a bare `InterruptedIOException` — both mean "timed out".
private fun isTimeout(e: Throwable): Boolean = e is InterruptedIOException
private fun isTls(e: Throwable): Boolean =
e is SSLException ||
e is CertificateException ||
e is CertPathValidatorException ||
e is CertPathBuilderException
// `ConnectException` / `NoRouteToHostException` / `PortUnreachableException` all extend
// `SocketException`; listed explicitly for readability. `UnknownHostException` is a DNS
// failure (extends `IOException`, not `SocketException`).
private fun isConnectivity(e: Throwable): Boolean =
e is ConnectException ||
e is NoRouteToHostException ||
e is PortUnreachableException ||
e is SocketException ||
e is UnknownHostException
private fun describe(error: Throwable): String =
error.message ?: error::class.simpleName ?: "unknown"
}
}

View File

@@ -0,0 +1,227 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.MessageCodec
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.TermTransport
import wang.yaojia.webterm.wire.TransportConnection
import wang.yaojia.webterm.wire.Tunables
import java.net.URI
import kotlin.time.Duration
/**
* Result of a pairing probe — the validated [HostEndpoint] on success, a [PairingError] on failure.
* Android analogue of iOS `Result<HostEndpoint, PairingError>`. The pairing UI (A19) constructs the
* persisted `Host{id,name}` from the returned endpoint (id/name are not the probe's to know).
*/
public sealed interface PairingProbeResult {
public data class Success(val endpoint: HostEndpoint) : PairingProbeResult
public data class Failure(val error: PairingError) : PairingProbeResult
}
/**
* Public probe entry (Android port of iOS `runPairingProbe`). Two-step probe:
* 1. `GET /live-sessions` (NO Origin) — reachability + web-terminal shape.
* 2. WS `attach(null)` → adopt the server-issued `attached` id → close → **immediately
* `DELETE /live-sessions/:id` WITH Origin** (verifies the Origin guard on both the upgrade and
* the guarded-HTTP side, and never leaks the probe's orphan session).
*
* **Confirm-before-network contract:** callers MUST run this only after the user confirmed a
* scanned/typed host (A19). Step ① already talks to the network and step ② spawns a PTY on the
* target machine — nothing here is speculative, so the UI gate is the caller's responsibility.
*
* The wall-clock deadline is [Tunables.PAIRING_PROBE_TIMEOUT]; tests drive [runPairingProbeCore]
* with an explicit (or `null`) timeout for deterministic virtual-time coverage.
*/
public suspend fun runPairingProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
/**
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
* still apply — fast, race-free tests); otherwise the whole probe is cancelled past the deadline and
* resolves [PairingError.Timeout]. Cancellation is the coroutine analogue of iOS's `group.cancelAll`.
*/
internal suspend fun runPairingProbeCore(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
timeout: Duration?,
): PairingProbeResult {
if (timeout == null) return performProbe(endpoint, http, ws)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
?: PairingProbeResult.Failure(PairingError.Timeout)
}
// ── Probe body ────────────────────────────────────────────────────────────────────────────────
private suspend fun performProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
): PairingProbeResult {
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
probeReachability(endpoint, http)?.let { return it }
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
// unrecognizable connect failure is classified as originRejected.
val connection: TransportConnection = try {
ws.connect(endpoint)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(
PairingError.classify(
error,
endpoint,
unrecognizedFallback = PairingError.OriginRejected(
PairingError.originRejectedHint(endpoint),
),
),
)
}
// Once connected, the connection MUST be closed on every exit path — including the timeout/cancel
// path, where withTimeoutOrNull cancels us mid-`firstOrNull`. Without this finally, a cancel skips
// close() and leaks the WS + its orphan PTY session on the host. NonCancellable so the close still
// runs while we are already being cancelled.
return try {
when (val adoption = adoptAttachedSession(connection)) {
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
}
} finally {
withContext(NonCancellable) { runCatching { connection.close() } }
}
}
/**
* Probe step ①. Returns a [PairingProbeResult.Failure] to short-circuit, or `null` to proceed.
* Reachable + web-terminal-shaped = HTTP 200 with a JSON-array body (an HTML admin page, a 404, or
* a non-array body are all "not web-terminal").
*/
private suspend fun probeReachability(
endpoint: HostEndpoint,
http: HttpTransport,
): PairingProbeResult.Failure? {
val response = try {
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
}
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
}
return null
}
/**
* Send `attach(null)` (explicit JSON `"sessionId":null` via [MessageCodec]) and wait for the
* server-issued `attached` id, skipping any other or undecodable frame (untrusted server; only
* `attached` matters here). A stream that ends or errors before speaking our protocol is NOT an
* Origin problem — the upgrade already succeeded — so it maps to [PairingError.HttpOkButNotWebTerminal].
*/
private suspend fun adoptAttachedSession(connection: TransportConnection): Adoption =
try {
connection.send(MessageCodec.encode(ClientMessage.Attach(sessionId = null)))
val sessionId = connection.frames
.mapNotNull { frame -> (MessageCodec.decodeServer(frame) as? ServerMessage.Attached)?.sessionId }
.firstOrNull()
if (sessionId != null) Adoption.Success(sessionId) else Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
}
/**
* The guarded kill round-trip is part of pairing verification itself (`DELETE` exercises the
* HTTP-side Origin guard the later `hookDecision` will need) AND guarantees the probe leaves no
* orphan session. 204 = killed, 404 = already gone (both success), 403 = Origin guard rejected us.
*/
private suspend fun killProbeSession(
sessionId: String,
endpoint: HostEndpoint,
http: HttpTransport,
): PairingProbeResult {
val response = try {
http.send(
HttpRequest(
method = HttpMethod.DELETE,
url = killUrl(endpoint, sessionId),
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
),
)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
}
return when (response.status) {
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
)
else -> PairingProbeResult.Failure(
PairingError.HostUnreachable(underlying = "HTTP ${response.status}"),
)
}
}
private sealed interface Adoption {
data class Success(val sessionId: String) : Adoption
data class Failure(val error: PairingError) : Adoption
}
// ── URL derivation + shape check ────────────────────────────────────────────────────────────────
private val PROBE_JSON = Json { ignoreUnknownKeys = true; isLenient = true }
/** Web-terminal shape = the body parses as a JSON array (the server replies `[]` when idle). */
private fun isJsonArray(body: ByteArray): Boolean =
runCatching { PROBE_JSON.parseToJsonElement(body.decodeToString()) is JsonArray }.getOrDefault(false)
private fun liveSessionsUrl(endpoint: HostEndpoint): String = httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH
private fun killUrl(endpoint: HostEndpoint, sessionId: String): String =
httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH + "/" + sessionId
/**
* `scheme://host[:port]` from the endpoint's dialed URL — path/query/fragment/credentials dropped
* (same derivation philosophy as [HostEndpoint.wsUrl]). The dialed port is kept verbatim; the host
* is left as [java.net.URI] returns it (IPv6 literals are already bracketed).
*/
private fun httpBaseUrl(endpoint: HostEndpoint): String {
val uri = URI(endpoint.baseUrl)
val scheme = (uri.scheme ?: "http").lowercase()
val host = uri.host ?: ""
val portPart = if (uri.port != -1) ":${uri.port}" else ""
return "$scheme://$host$portPart"
}
private const val LIVE_SESSIONS_PATH = "/live-sessions"
private const val ORIGIN_HEADER = "Origin"
private const val HTTP_OK = 200
private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404

View File

@@ -0,0 +1,173 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.LossyDecode
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TimelineEvent
import java.util.UUID
/**
* Typed client for the server's HTTP surface (12 frozen routes). Pure — consumes [HttpTransport]
* by interface (no OkHttp/Android); `:transport-okhttp` (A7) provides the real impl, the
* `:test-support` fake queues canned responses.
*
* **Origin 铁律 (CSWSH, plan §4.3):** only the guarded (state-changing) routes stamp
* `Origin: endpoint.originHeader`; the read-only GETs never do. Stamping lives in ONE place
* ([ApiRoute.toHttpRequest]) and the value is single-point-derived by [HostEndpoint].
*
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
*/
public class ApiClient(
public val endpoint: HostEndpoint,
private val http: HttpTransport,
) {
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
/** `GET /live-sessions` — the discovery list every device polls. */
public suspend fun liveSessions(): List<LiveSessionInfo> {
val response = perform(Endpoints.liveSessions())
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
return LossyDecode.listOrNull(response.body, LiveSessionInfo.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /live-sessions/:id/preview` — ring-buffer tail for a read-only thumbnail (no attach). */
public suspend fun preview(id: UUID): SessionPreview {
val response = perform(Endpoints.preview(id))
requireOk(response)
return LossyDecode.objectOrNull(response.body, SessionPreview.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /live-sessions/:id/events` — the activity timeline. A non-array body (timeline capture
* disabled) → `[]`; unknown-class entries survive shape-decode and are filtered downstream. */
public suspend fun events(id: UUID): List<TimelineEvent> {
val response = perform(Endpoints.events(id))
requireOk(response)
return LossyDecode.listOrEmpty(response.body, TimelineEvent.serializer())
}
/** `GET /config/ui` — `{ allowAutoMode }`. */
public suspend fun uiConfig(): UiConfig {
val response = perform(Endpoints.uiConfig())
requireOk(response)
return LossyDecode.objectOrNull(response.body, UiConfig.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /projects` — discovered projects with their running sessions merged in. */
public suspend fun projects(): List<ProjectInfo> {
val response = perform(Endpoints.projects())
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
return LossyDecode.listOrNull(response.body, ProjectInfo.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one project. An empty path is
* rejected client-side (mirror of the server's 400) before any network I/O. */
public suspend fun projectDetail(path: String): ProjectDetail {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectDetail(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, ProjectDetail.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.ProjectDetailUnavailable
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs {
val response = perform(Endpoints.getPrefs())
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
return UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
}
// ── G (state-changing — Origin required, byte-equal) ───────────────────────────────────
/** `DELETE /live-sessions/:id`. 204 = success; 404 = already gone (also success on the server,
* but iOS surfaces it as `SessionNotFound` — matched here). */
public suspend fun killSession(id: UUID) {
val response = perform(Endpoints.killSession(id))
when (response.status) {
HttpStatus.NO_CONTENT -> Unit
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `POST /hook/decision` — resolve a held remote approval with a single-use `token` (push
* payload only; NEVER persist it). 403 → stale/mismatched token; 429 → rate-limited. */
public suspend fun hookDecision(sessionId: UUID, decision: HookDecision, token: String) {
val response = perform(Endpoints.hookDecision(sessionId, decision, token))
when (response.status) {
HttpStatus.NO_CONTENT -> Unit
HttpStatus.FORBIDDEN -> throw ApiClientError.DecisionRejected
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `PUT /prefs` — replace the whole blob. Returns the server's sanitized echo — treat IT as the
* new source of truth, not the input. 403 = Origin guard. */
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs {
val response = perform(Endpoints.putPrefs(prefs))
return when (response.status) {
HttpStatus.OK -> UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
public suspend fun registerFcmToken(token: String) {
sendFcmToken(token, Endpoints::registerFcmToken)
}
/** `DELETE /push/fcm-token` — unregister (idempotent → 204 even for an unknown token). */
public suspend fun unregisterFcmToken(token: String) {
sendFcmToken(token, Endpoints::unregisterFcmToken)
}
private suspend fun sendFcmToken(token: String, build: (String) -> ApiRoute) {
val normalized = FcmTokenRule.normalize(token) ?: throw ApiClientError.InvalidFcmToken
val response = perform(build(normalized))
when (response.status) {
HttpStatus.NO_CONTENT -> Unit
HttpStatus.BAD_REQUEST -> throw ApiClientError.InvalidFcmToken
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
// ── Internals ──────────────────────────────────────────────────────────────────────────
private suspend fun perform(route: ApiRoute): HttpResponse {
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
return http.send(request)
}
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
private fun requireOk(response: HttpResponse) {
when (response.status) {
HttpStatus.OK -> Unit
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
}

View File

@@ -0,0 +1,49 @@
package wang.yaojia.webterm.api.routes
/**
* Typed failures for [ApiClient] calls (explicit error handling, plan §4). Transport-level errors
* thrown by `HttpTransport.send` propagate UNWRAPPED (the pairing classifier reads their shape).
*
* [userMessage] is UI-ready copy (matches the iOS `APIClientError.message` 话术). The no-arg cases
* are singletons (`object`) so tests can assert by identity/equality; [UnexpectedStatus] carries the
* offending code.
*/
public sealed class ApiClientError(public val userMessage: String) : Exception(userMessage) {
/** The request could not be built from the endpoint (malformed base URL — should not happen for
* a validated `HostEndpoint`; surfaced instead of crashing). */
public data object InvalidRequest : ApiClientError("无法构造请求(主机地址异常)。")
/** A 2xx arrived but the body is not the endpoint's shape. For `/live-sessions` this is the
* pairing probe's "the port speaks HTTP but is not web-terminal" signal. */
public data object InvalidResponseBody : ApiClientError("服务器响应不是预期格式——端口对吗?")
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
/** 403 from a G route's Origin guard (CSWSH defence). */
public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。")
/** 403 from `POST /hook/decision`: the capability token is missing / mismatched / STALE —
* tokens are single-use and expiring by design. */
public data object DecisionRejected : ApiClientError("审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。")
/** 429: the endpoint is rate-limited per IP by fixed server policy. */
public data object RateLimited : ApiClientError("操作过于频繁,服务器已限流,请稍后再试。")
/** The FCM registration token failed the client-side charset/length check, or the server echoed
* a 400. */
public data object InvalidFcmToken : ApiClientError("推送注册令牌格式异常,请重启 App 重新注册推送。")
/** 400 from `GET /projects/detail` — the `path` query parameter is missing/empty. Also raised
* client-side for an empty path, before any network I/O. */
public data object ProjectPathInvalid : ApiClientError("项目路径为空或不合法。")
/** 404 from `GET /projects/detail` — no project at that path (moved/deleted/not a directory). */
public data object ProjectNotFound : ApiClientError("项目不存在(路径可能已移动或删除)。")
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** Any other non-success status code. */
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status")
}

View File

@@ -0,0 +1,91 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.net.URI
/** Named HTTP status codes used by the client (no magic numbers, plan §4). */
internal object HttpStatus {
const val OK = 200
const val NO_CONTENT = 204
const val BAD_REQUEST = 400
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500
}
/** Header / content-type names (no magic strings inline). */
internal object HeaderName {
const val ORIGIN = "Origin"
const val CONTENT_TYPE = "Content-Type"
}
internal object ContentType {
const val JSON = "application/json"
}
/**
* Whether a route mutates server state — THE security split (plan §4.3): `Origin` is stamped
* **iff** [GUARDED]. If the server ever reclassifies a RO route as guarded, tests go red instead of
* passing by coincidence.
*/
internal enum class OriginPolicy {
/** Read-only GET — MUST NOT carry Origin. */
READ_ONLY,
/** State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal; server 403s a
* missing/foreign Origin (CSWSH defence). */
GUARDED,
}
/**
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
* sites). Origin stamping happens HERE and only here (single point).
*/
internal class ApiRoute(
val method: HttpMethod,
val path: String,
val originPolicy: OriginPolicy,
val body: ByteArray? = null,
val percentEncodedQuery: String? = null,
) {
/**
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
*/
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
val headers = LinkedHashMap<String, String>()
if (originPolicy == OriginPolicy.GUARDED) {
headers[HeaderName.ORIGIN] = endpoint.originHeader
}
if (body != null) {
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
}
return HttpRequest(method = method, url = url, headers = headers, body = body)
}
private companion object {
/**
* Rebuild `<scheme>://<host>[:<port>]<path>[?<query>]` from the dialed base URL, keeping the
* dialed port verbatim (like `wsUrl`, unlike the default-port-dropping Origin). The path and
* pre-encoded query are appended verbatim; any path/query/fragment/credentials the base URL
* carried are dropped.
*/
fun buildUrl(baseUrl: String, path: String, query: String?): String? {
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase() ?: return null
val host = uri.host ?: return null
if (host.isEmpty()) return null
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
val portPart = if (uri.port != -1) ":${uri.port}" else ""
val queryPart = if (query != null) "?$query" else ""
return "$scheme://$serializedHost$portPart$path$queryPart"
}
}
}

View File

@@ -0,0 +1,127 @@
package wang.yaojia.webterm.api.routes
import kotlinx.serialization.Serializable
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.ModelJson
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* Builders for the frozen endpoints (12 routes, verified against `src/http/…` + iOS `Endpoints` /
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
* pair (this client uses FCM, not APNs/VAPID).
*
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
* · `POST|DELETE /push/fcm-token`
*
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
* `/push/fcm-token`), which is still PENDING — so FCM push is non-functional against the current
* server until A33 lands. The client builders exist now so the token lifecycle is ready the moment
* the server route ships; do not "fix" this as a mismatch.
*/
internal object Endpoints {
// ── RO ───────────────────────────────────────────────────────────────────────────────
fun liveSessions(): ApiRoute =
ApiRoute(HttpMethod.GET, "/live-sessions", OriginPolicy.READ_ONLY)
fun preview(id: UUID): ApiRoute =
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/preview", OriginPolicy.READ_ONLY)
fun events(id: UUID): ApiRoute =
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/events", OriginPolicy.READ_ONLY)
fun uiConfig(): ApiRoute =
ApiRoute(HttpMethod.GET, "/config/ui", OriginPolicy.READ_ONLY)
fun projects(): ApiRoute =
ApiRoute(HttpMethod.GET, "/projects", OriginPolicy.READ_ONLY)
/**
* `GET /projects/detail?path=` — RO. The ONE place `path` gets percent-encoded, with a strict
* RFC 3986 unreserved-only set (deliberately stricter than URL-query-allowed: a bare `+` decodes
* to a SPACE in Express's qs parser, and `&`/`=` would split the parameter).
*/
fun projectDetail(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/detail",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
// ── G ────────────────────────────────────────────────────────────────────────────────
fun killSession(id: UUID): ApiRoute =
ApiRoute(HttpMethod.DELETE, "/live-sessions/${pathId(id)}", OriginPolicy.GUARDED)
/** Body is exactly `{ sessionId, decision, token }`. `token` is single-use (push payload only) —
* callers must never persist/log it. */
fun hookDecision(sessionId: UUID, decision: HookDecision, token: String): ApiRoute {
val body = ModelJson.encodeToString(
HookDecisionBody.serializer(),
HookDecisionBody(pathId(sessionId), decision.wire, token),
).encodeToByteArray()
return ApiRoute(HttpMethod.POST, "/hook/decision", OriginPolicy.GUARDED, body = body)
}
/** `PUT /prefs` — G. Replaces the whole blob (server echoes a sanitized 200). */
fun putPrefs(prefs: UiPrefs): ApiRoute =
ApiRoute(HttpMethod.PUT, "/prefs", OriginPolicy.GUARDED, body = prefs.encodeBody())
fun registerFcmToken(normalized: String): ApiRoute =
fcmTokenRoute(HttpMethod.POST, normalized)
fun unregisterFcmToken(normalized: String): ApiRoute =
fcmTokenRoute(HttpMethod.DELETE, normalized)
private fun fcmTokenRoute(method: HttpMethod, normalized: String): ApiRoute {
val body = ModelJson.encodeToString(
FcmTokenBody.serializer(),
FcmTokenBody(normalized),
).encodeToByteArray()
return ApiRoute(method, FCM_TOKEN_PATH, OriginPolicy.GUARDED, body = body)
}
private const val FCM_TOKEN_PATH = "/push/fcm-token"
/**
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
* on the JVM (unlike Swift's uppercase `uuidString`).
*/
private fun pathId(id: UUID): String = id.toString()
/** Strict RFC 3986 unreserved set — everything else is percent-encoded over UTF-8 bytes. */
private val UNRESERVED: Set<Char> =
("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~").toSet()
private fun percentEncode(value: String): String {
val sb = StringBuilder()
for (byte in value.encodeToByteArray()) {
val code = byte.toInt() and 0xFF
val ch = code.toChar()
if (ch in UNRESERVED) {
sb.append(ch)
} else {
sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
}
}
return sb.toString()
}
private val HEX = "0123456789ABCDEF".toCharArray()
@Serializable
private data class HookDecisionBody(val sessionId: String, val decision: String, val token: String)
@Serializable
private data class FcmTokenBody(val token: String)
}

View File

@@ -0,0 +1,24 @@
package wang.yaojia.webterm.api.routes
/**
* Client-side FCM registration-token validator (validate at the boundary, plan §4). Deliberately
* LOOSE, per plan §4.5: non-empty, bounded length, `base64url` charset plus `:` — the FCM token
* format/length is undocumented and changes, so a strict length regex risks rejecting valid tokens.
*
* Unlike iOS's APNs hex rule, FCM tokens are case-sensitive → NOT lowercased. [normalize] returns
* the token unchanged when valid, or `null` (→ `ApiClientError.InvalidFcmToken` before any I/O).
*/
internal object FcmTokenRule {
/** Generous headroom bound; real tokens are ~150250 chars but the ceiling is undocumented. */
private const val MAX_LENGTH = 4096
/** base64url alphabet (`AZ az 09 - _`) plus the `:` that appears in FCM tokens. */
private val ALLOWED: Set<Char> =
(('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', ':')).toSet()
fun normalize(raw: String): String? {
if (raw.isEmpty() || raw.length > MAX_LENGTH) return null
if (!raw.all { it in ALLOWED }) return null
return raw
}
}

View File

@@ -0,0 +1,72 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The prefs round-trip correctness trap: `PUT /prefs` replaces the WHOLE blob, so decode → mutate
* one known key → encode MUST carry every unknown top-level key through verbatim (including exact
* integer formatting), or an Android write clobbers web/iOS/future-server prefs.
*/
class UiPrefsTest {
@Test
fun sanitizesFavouritesAndCollapsedLikeTheWebClient() {
val prefs = UiPrefs.decode(
"""
{"favourites":["/a","/b","/a","",123],"collapsed":{"g1":true,"g2":false,"g3":"true","":true}}
""".trimIndent().toByteArray(),
)!!
// favourites: non-empty strings only, de-duplicated, order preserved; the number 123 dropped.
assertEquals(listOf("/a", "/b"), prefs.favourites)
// collapsed: only literal `true`; false, string "true", and the empty key are all dropped.
assertEquals(mapOf("g1" to true), prefs.collapsed)
}
@Test
fun mutatingOneKeyPreservesUnknownKeysAndIntegerFormatting() {
val original = UiPrefs.decode(
"""
{"favourites":["/old"],"collapsed":{"g":true},"schemaVersion":7,"vendor":{"nested":42,"ratio":1.5}}
""".trimIndent().toByteArray(),
)!!
val mutated = original.withFavourites(listOf("/new"))
val encoded = mutated.encodeBody().decodeToString()
// Known key was replaced...
assertEquals(listOf("/new"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
// ...collapsed (untouched known key) survived...
assertEquals(mapOf("g" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
// ...and every unknown key survived verbatim, integers still integers (42, not 42.0).
assertTrue(encoded.contains("\"schemaVersion\":7"), "unknown scalar key must round-trip: $encoded")
assertTrue(encoded.contains("\"nested\":42"), "nested integer must not become 42.0: $encoded")
assertTrue(encoded.contains("\"ratio\":1.5"), "nested double must round-trip: $encoded")
}
@Test
fun withCollapsedReplacesOnlyThatKey() {
val original = UiPrefs.decode("""{"favourites":["/keep"],"extra":"x"}""".toByteArray())!!
val encoded = original.withCollapsed(mapOf("ns" to true)).encodeBody().decodeToString()
assertEquals(listOf("/keep"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
assertEquals(mapOf("ns" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
assertTrue(encoded.contains("\"extra\":\"x\""))
}
@Test
fun createBuildsAFreshBlobWithBothKnownKeys() {
val encoded = UiPrefs.create(favourites = listOf("/a"), collapsed = mapOf("g" to true))
.encodeBody().decodeToString()
assertEquals("""{"favourites":["/a"],"collapsed":{"g":true}}""", encoded)
}
@Test
fun nonObjectBodyDecodesToNull() {
assertNull(UiPrefs.decode("[]".toByteArray()))
assertNull(UiPrefs.decode("\"hi\"".toByteArray()))
assertNull(UiPrefs.decode("not json".toByteArray()))
}
}

View File

@@ -0,0 +1,65 @@
package wang.yaojia.webterm.api.pairing
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier
/** Ports the tier table of iOS `HostClassificationTests` (plan §5.4, fail-safe unknown→public). */
class HostClassifierTest {
@Test
fun loopbackHostsClassifyAsLoopback() {
val hosts = listOf("localhost", "LOCALHOST", "127.0.0.1", "127.5.9.200", "::1", "[::1]")
hosts.forEach { assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(it), it) }
}
@Test
fun rfc1918AndLinkLocalAndMdnsClassifyAsPrivateLan() {
val hosts = listOf(
"10.0.0.5", "10.255.255.255",
"172.16.0.1", "172.20.10.1", "172.31.255.255",
"192.168.0.9", "192.168.1.1",
"169.254.1.1",
"mac-mini.local", "MAC-MINI.LOCAL", "printer.local",
)
hosts.forEach { assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(it), it) }
}
@Test
fun cgnatAndMagicDnsClassifyAsTailscale() {
val hosts = listOf(
"100.64.0.1", "100.100.1.1", "100.127.255.255",
"mac.tailnet.ts.net", "MAC.TAILNET.TS.NET", "host.ts.net",
)
hosts.forEach { assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(it), it) }
}
@Test
fun everythingElseFailsSafeToPublic() {
val hosts = listOf(
// routable public IPs
"8.8.8.8", "203.0.113.7",
// hostnames
"example.com", "claude.ai",
// boundary-miss IPv4 (just outside the private/tailscale ranges)
"172.15.0.1", "172.32.0.1", "100.63.0.1", "100.128.0.1",
// malformed / out-of-range / wrong arity → fail-safe public
"256.1.1.1", "1.2.3", "999.999.999.999", "not a host", "",
// non-loopback IPv6 (ULA / documentation) → fail-safe public (iOS v1 scope)
"fd00::1", "2001:db8::1",
)
hosts.forEach { assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(it), it) }
}
@Test
fun classifyEndpointDelegatesToHost() {
val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
val tailscale = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
val public = requireNotNull(HostEndpoint.fromBaseUrl("https://example.com"))
assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(lan))
assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(tailscale))
assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(public))
}
}

View File

@@ -0,0 +1,110 @@
package wang.yaojia.webterm.api.pairing
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
/** Re-derives iOS `PairingError.classify` for JVM exceptions (plan R12: drop localNetworkDenied). */
class PairingErrorTest {
private val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
@Test
fun connectionRefusedMapsToHostUnreachable() {
val result = PairingError.classify(ConnectException("Connection refused"), lan)
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
}
@Test
fun dnsFailureMapsToHostUnreachable() {
val result = PairingError.classify(UnknownHostException("no such host"), lan)
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
}
@Test
fun socketTimeoutMapsToTimeout() {
assertEquals(PairingError.Timeout, PairingError.classify(SocketTimeoutException("read timed out"), lan))
}
@Test
fun interruptedIoMapsToTimeout() {
// OkHttp's overall call-timeout surfaces as a bare InterruptedIOException.
assertEquals(PairingError.Timeout, PairingError.classify(InterruptedIOException("timeout"), lan))
}
@Test
fun sslHandshakeFailureMapsToTlsFailure() {
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLHandshakeException("handshake_failure"), lan))
}
@Test
fun genericSslAndCertFailuresMapToTlsFailure() {
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLException("tls"), lan))
assertEquals(PairingError.TlsFailure, PairingError.classify(CertificateException("bad cert"), lan))
}
@Test
fun cleartextBlockMapsToCleartextBlockedWithHost() {
val err = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted")
val result = PairingError.classify(err, lan)
assertInstanceOf(PairingError.CleartextBlocked::class.java, result)
assertEquals("192.168.1.5", (result as PairingError.CleartextBlocked).host)
}
@Test
fun wrappedCauseIsWalked() {
// OkHttp frequently wraps the real cause; the chain walk must see it.
val wrappedConnect = IOException("unexpected end of stream", ConnectException("refused"))
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(wrappedConnect, lan))
val wrappedTls = IOException("io", SSLHandshakeException("handshake"))
assertEquals(PairingError.TlsFailure, PairingError.classify(wrappedTls, lan))
}
@Test
fun unrecognizedErrorUsesFallbackWhenProvided() {
// Probe step ②: after ① passed, an unclassifiable connect failure is the Origin 401.
val fallback = PairingError.OriginRejected("hint")
assertEquals(fallback, PairingError.classify(IllegalStateException("weird"), lan, unrecognizedFallback = fallback))
}
@Test
fun unrecognizedErrorWithoutFallbackIsHostUnreachable() {
assertInstanceOf(
PairingError.HostUnreachable::class.java,
PairingError.classify(IllegalStateException("weird"), lan),
)
}
@Test
fun causeCycleTerminates() {
// A→B→A cycle must not loop forever; B is a ConnectException so it classifies as reachable-failure.
val a = IOException("a")
val b = ConnectException("b")
a.initCause(b)
b.initCause(a)
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(a, lan))
}
@Test
fun originRejectedHintDerivesFromOriginHeaderWithoutDefaultPort() {
val lanHint = PairingError.originRejectedHint(lan)
assertTrue(lanHint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))
val tls = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
val tlsHint = PairingError.originRejectedHint(tls)
assertTrue(tlsHint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
assertFalse(tlsHint.contains(":443"), "default https port must be omitted (no :443 迷信)")
}
}

View File

@@ -0,0 +1,284 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import javax.net.ssl.SSLHandshakeException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Ports iOS `PairingProbeTests`: the two-step probe (① GET reachability/shape, ② WS attach→adopt→
* kill-with-Origin), each failure mode mapped to a [PairingError], full-success leaves no orphan
* session, and the injected-deadline timeout — all under `runTest` virtual time (zero real waits).
*/
class PairingProbeTest {
private data class Fixture(
val endpoint: HostEndpoint,
val http: FakeHttpTransport,
val ws: FakeTermTransport,
val liveSessionsUrl: String,
val killUrl: String,
)
private fun fixture(base: String = BASE): Fixture {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(base))
return Fixture(
endpoint = endpoint,
http = FakeHttpTransport(),
ws = FakeTermTransport(),
liveSessionsUrl = "$base/live-sessions",
killUrl = "$base/live-sessions/$SESSION_ID",
)
}
/** Non-timeout cases use `timeout = null` so the probe never enters the race — deterministic. */
private suspend fun runProbe(f: Fixture, timeout: Duration? = null): PairingProbeResult =
runPairingProbeCore(f.endpoint, f.http, f.ws, timeout)
private fun failureError(result: PairingProbeResult): PairingError {
assertInstanceOf(PairingProbeResult.Failure::class.java, result)
return (result as PairingProbeResult.Failure).error
}
// ── Probe ① failure branches ────────────────────────────────────────────────────────────────
@Test
fun stepOneConnectionRefusedMapsToHostUnreachableAndNeverTouchesWs() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = ConnectException("refused"))
val result = runProbe(f)
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(result))
assertTrue(f.ws.connectAttempts.isEmpty(), "probe must not touch WS when ① fails")
}
@Test
fun stepOneHtmlBodyMapsToNotWebTerminal() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "<html><body>router admin</body></html>".toByteArray())
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
@Test
fun stepOne404MapsToNotWebTerminal() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, status = 404)
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
@Test
fun stepOneNonArrayJsonMapsToNotWebTerminal() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "{\"ok\":true}".toByteArray())
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
@Test
fun stepOneTlsFailureMapsToTlsFailure() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = SSLHandshakeException("handshake_failure"))
assertEquals(PairingError.TlsFailure, failureError(runProbe(f)))
}
@Test
fun stepOneTransportTimeoutMapsToTimeout() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = SocketTimeoutException("read timed out"))
assertEquals(PairingError.Timeout, failureError(runProbe(f)))
}
@Test
fun stepOneCleartextBlockMapsToCleartextBlockedWithHost() = runTest {
val f = fixture()
f.http.queueFailure(
url = f.liveSessionsUrl,
error = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted"),
)
val error = failureError(runProbe(f))
assertInstanceOf(PairingError.CleartextBlocked::class.java, error)
assertEquals("192.168.1.5", (error as PairingError.CleartextBlocked).host)
}
@Test
fun stepOneDnsFailureMapsToHostUnreachable() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = UnknownHostException("nxdomain"))
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(runProbe(f)))
}
// ── Probe ② failure branches ────────────────────────────────────────────────────────────────
@Test
fun stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() = runTest {
// ① passes; ② upgrade fails (a 401 is a shapeless connect error at the transport layer).
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.ws.scriptConnectFailure()
val error = failureError(runProbe(f))
assertInstanceOf(PairingError.OriginRejected::class.java, error)
val hint = (error as PairingError.OriginRejected).hint
assertTrue(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"), hint)
assertFalse(hint.contains(":443"), hint)
}
@Test
fun originRejectedHintOmitsDefaultPortForHttps() = runTest {
val f = fixture(base = "https://mac.tailnet.ts.net")
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.ws.scriptConnectFailure()
val error = failureError(runProbe(f))
assertInstanceOf(PairingError.OriginRejected::class.java, error)
val hint = (error as PairingError.OriginRejected).hint
assertTrue(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"), hint)
assertFalse(hint.contains(":443"), hint)
}
@Test
fun stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() = runTest {
// A queued finish is flushed into the connection at connect → stream closes before `attached`.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.ws.finishFrames()
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
// ── Full pass + kill round-trip ──────────────────────────────────────────────────────────────
@Test
fun fullProbeSuccessAttachesWithNullSessionIdThenKillsWithOrigin() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
f.ws.emit(ATTACHED_FRAME)
val result = runProbe(f)
// Success payload is the probed endpoint.
assertEquals(PairingProbeResult.Success(f.endpoint), result)
// attach(null) is the only WS frame, with an explicit JSON null sessionId key.
assertEquals(1, f.ws.sentFrames.size)
val attach = Json.parseToJsonElement(f.ws.sentFrames.single()).jsonObject
assertEquals("attach", attach["type"]?.jsonPrimitive?.content)
assertTrue(attach["sessionId"] is JsonNull, "sessionId key must be present and JSON null")
// Exactly two HTTP requests: RO GET (NO Origin) then guarded DELETE (byte-equal Origin).
val requests = f.http.recordedRequests
assertEquals(2, requests.size)
assertFalse(requests.first().headers.containsKey("Origin"), "RO GET must not carry Origin")
val kill = requests.last()
assertEquals(HttpMethod.DELETE, kill.method)
assertEquals(f.killUrl, kill.url)
assertEquals(f.endpoint.originHeader, kill.headers["Origin"])
// The probe never holds the connection.
assertEquals(1, f.ws.closeCallCount)
}
@Test
fun publicWrapperHappyPathReturnsEndpoint() = runTest {
// The public entry uses the default Tunables deadline; immediate fakes never trip it.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
f.ws.emit(ATTACHED_FRAME)
val result = runPairingProbe(f.endpoint, f.http, f.ws)
assertEquals(PairingProbeResult.Success(f.endpoint), result)
assertEquals(1, f.ws.closeCallCount)
}
@Test
fun outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
f.ws.emit("""{"type":"output","data":"replay"}""")
f.ws.emit(ATTACHED_FRAME)
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
}
@Test
fun killRejectedByOriginGuardMapsToOriginRejected() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 403)
f.ws.emit(ATTACHED_FRAME)
assertInstanceOf(PairingError.OriginRejected::class.java, failureError(runProbe(f)))
}
@Test
fun killReturning404StillCountsAsSuccess() = runTest {
// Session exited between attach and kill — the goal state (no orphan) is already reached.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 404)
f.ws.emit(ATTACHED_FRAME)
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
}
// ── Timeout (virtual time, zero real waits) ──────────────────────────────────────────────────
@Test
fun probeTimesOutWhenServerNeverSendsAttached() = runTest {
// ① passes; ② connects but the server never replies `attached` → the probe hangs on the
// frame stream until the injected deadline cancels it.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
val result = runProbe(f, timeout = 10.seconds)
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
}
@Test
fun timeoutPathAlwaysClosesTheConnectionExactlyOnce() = runTest {
// ① passes; ② connects but the server never sends `attached` → the probe hangs on the frame
// stream until the injected deadline cancels it MID-adopt. The connection must still close
// (try/finally + NonCancellable), or the probe leaks the WS + its orphan session.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
val result = runProbe(f, timeout = 10.seconds)
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
assertEquals(1, f.ws.closeCallCount, "the probe must close the WS even on the timeout path")
}
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val SESSION_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
const val ATTACHED_FRAME = """{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"""
}
}

View File

@@ -0,0 +1,86 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.io.IOException
import java.util.UUID
/** Status-code → typed [ApiClientError] mapping per route, plus transport errors propagating raw. */
class ApiClientErrorMappingTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
@Test
fun killSessionMapsNotFoundAndForbidden() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 404)
assertEquals(ApiClientError.SessionNotFound, errorOf { client.killSession(ID) })
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 403)
assertEquals(ApiClientError.Forbidden, errorOf { client.killSession(ID) })
}
@Test
fun hookDecisionMapsForbiddenToRejectedAnd429ToRateLimited() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 403)
assertEquals(ApiClientError.DecisionRejected, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") })
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 429)
assertEquals(ApiClientError.RateLimited, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") })
}
@Test
fun projectDetailMapsEmptyPathAndAllServerErrorStatuses() = runTest {
// Empty path is rejected BEFORE any I/O.
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("") })
assertTrue(transport.recordedRequests.isEmpty(), "empty path must not hit the network")
val url = "$BASE/projects/detail?path=%2Fp"
transport.queueSuccess(url = url, status = 400)
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("/p") })
transport.queueSuccess(url = url, status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectDetail("/p") })
transport.queueSuccess(url = url, status = 500)
assertEquals(ApiClientError.ProjectDetailUnavailable, errorOf { client.projectDetail("/p") })
}
@Test
fun putPrefsMapsForbiddenAndUnexpected() = runTest {
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 403)
assertEquals(ApiClientError.Forbidden, errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) })
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 418)
assertEquals(ApiClientError.UnexpectedStatus(418), errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) })
}
@Test
fun readOnlyRoutesMapNonOkToUnexpectedOrSessionNotFound() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", status = 500, body = "boom".toByteArray())
assertEquals(ApiClientError.UnexpectedStatus(500), errorOf { client.liveSessions() })
// preview/events/uiConfig go through requireOk: 404 → SessionNotFound.
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/preview", status = 404)
assertEquals(ApiClientError.SessionNotFound, errorOf { client.preview(ID) })
}
@Test
fun transportLevelErrorsPropagateUnwrapped() = runTest {
transport.queueFailure(url = "$BASE/live-sessions", error = IOException("connection refused"))
val error = errorOf { client.liveSessions() }
assertTrue(error is IOException)
assertEquals("connection refused", error?.message)
}
}

View File

@@ -0,0 +1,167 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.util.UUID
/**
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) across all 12 routes. Verifies method, URL,
* the presence/absence of the `Origin` header, and the exact JSON body for the mutating routes.
*/
class ApiRouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private fun lastRequest(): HttpRequest = transport.recordedRequests.last()
private fun assertGuarded(request: HttpRequest) =
assertEquals(ORIGIN, request.headers[HeaderName.ORIGIN], "guarded route must stamp byte-equal Origin")
private fun assertReadOnly(request: HttpRequest) =
assertFalse(request.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
// ── read-only routes: correct verb+url, NO Origin ───────────────────────────────────────
@Test
fun liveSessionsIsPlainReadOnlyGet() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
client.liveSessions()
val request = lastRequest()
assertEquals(HttpMethod.GET, request.method)
assertEquals("$BASE/live-sessions", request.url)
assertReadOnly(request)
assertNull(request.body)
}
@Test
fun previewEventsUiConfigProjectsPrefsAreReadOnlyGets() = runTest {
transport.queueSuccess(
url = "$BASE/live-sessions/$ID_STR/preview",
body = """{"id":"$ID_STR","cols":80,"rows":24,"data":""}""".toByteArray(),
)
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray())
transport.queueSuccess(url = "$BASE/projects", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/prefs", body = "{}".toByteArray())
client.preview(ID)
client.events(ID)
client.uiConfig()
client.projects()
client.prefs()
val urls = transport.recordedRequests.map { it.url }
assertEquals(
listOf(
"$BASE/live-sessions/$ID_STR/preview",
"$BASE/live-sessions/$ID_STR/events",
"$BASE/config/ui",
"$BASE/projects",
"$BASE/prefs",
),
urls,
)
transport.recordedRequests.forEach {
assertEquals(HttpMethod.GET, it.method)
assertReadOnly(it)
}
}
@Test
fun projectDetailStrictlyPercentEncodesPathInTheQuery() = runTest {
val path = "/home/me/my repo/a+b&c"
transport.queueSuccess(
url = "$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c",
body = """{"name":"repo","path":"$path","isGit":true}""".toByteArray(),
)
client.projectDetail(path)
val request = lastRequest()
assertEquals(HttpMethod.GET, request.method)
assertEquals("$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c", request.url)
assertReadOnly(request)
}
// ── guarded routes: correct verb+url, Origin stamped, exact body ────────────────────────
@Test
fun killSessionIsGuardedDeleteWithNoBody() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
client.killSession(ID)
val request = lastRequest()
assertEquals(HttpMethod.DELETE, request.method)
assertEquals("$BASE/live-sessions/$ID_STR", request.url)
assertGuarded(request)
assertNull(request.body)
}
@Test
fun hookDecisionIsGuardedPostWithExactBodyAndJsonContentType() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
client.hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
val request = lastRequest()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/hook/decision", request.url)
assertGuarded(request)
assertEquals(ContentType.JSON, request.headers[HeaderName.CONTENT_TYPE])
assertEquals(
"""{"sessionId":"$ID_STR","decision":"allow","token":"cap-tok-123"}""",
request.body?.decodeToString(),
)
}
@Test
fun putPrefsIsGuardedPutEchoingTheFullBlob() = runTest {
val body = """{"favourites":["/a"],"collapsed":{}}"""
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = body.toByteArray())
client.putPrefs(UiPrefs.create(favourites = listOf("/a")))
val request = lastRequest()
assertEquals(HttpMethod.PUT, request.method)
assertEquals("$BASE/prefs", request.url)
assertGuarded(request)
assertEquals(body, request.body?.decodeToString())
}
@Test
fun fcmTokenRegisterAndUnregisterAreGuardedWithTokenBody() = runTest {
val token = "fVoT9x-abc_DEF:api-901"
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/push/fcm-token", status = 204)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/push/fcm-token", status = 204)
client.registerFcmToken(token)
client.unregisterFcmToken(token)
val post = transport.recordedRequests[0]
val delete = transport.recordedRequests[1]
assertEquals(HttpMethod.POST, post.method)
assertEquals(HttpMethod.DELETE, delete.method)
assertTrue(post.url.endsWith("/push/fcm-token"))
assertGuarded(post)
assertGuarded(delete)
assertEquals("""{"token":"$token"}""", post.body?.decodeToString())
assertEquals("""{"token":"$token"}""", delete.body?.decodeToString())
}
}

View File

@@ -0,0 +1,68 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/** The intentionally-LOOSE FCM token validator (plan §4.5) + register/unregister error mapping. */
class FcmTokenTest {
private companion object {
const val BASE = "http://h:3000"
const val URL = "$BASE/push/fcm-token"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
@Test
fun acceptsBase64UrlPlusColonAndIsCaseSensitive() {
// base64url chars + ':' , mixed case preserved (FCM tokens are case-sensitive → not lowered).
val token = "cRZ7-x_Y9:APA91bH-Ab_CdEf"
assertEquals(token, FcmTokenRule.normalize(token))
}
@Test
fun rejectsEmptyOverlongAndOutOfCharsetTokens() {
assertNull(FcmTokenRule.normalize(""))
assertNull(FcmTokenRule.normalize("a".repeat(4097)))
assertNull(FcmTokenRule.normalize("has space"))
assertNull(FcmTokenRule.normalize("has/slash"))
assertNull(FcmTokenRule.normalize("emoji😀"))
assertNotNull(FcmTokenRule.normalize("a".repeat(4096))) // exactly at the bound is fine
}
@Test
fun invalidTokenIsRejectedBeforeAnyNetworkIO() = runTest {
val error = runCatching { client.registerFcmToken("bad token") }.exceptionOrNull()
assertEquals(ApiClientError.InvalidFcmToken, error)
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an invalid token")
}
@Test
fun registerMaps400To403To429() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 400)
assertEquals(ApiClientError.InvalidFcmToken, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 403)
assertEquals(ApiClientError.Forbidden, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 429)
assertEquals(ApiClientError.RateLimited, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
}
@Test
fun registerAndUnregisterSucceedOn204() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 204)
transport.queueSuccess(method = HttpMethod.DELETE, url = URL, status = 204)
// No exception = success.
client.registerFcmToken("okTok")
client.unregisterFcmToken("okTok")
assertEquals(2, transport.recordedRequests.size)
}
}

View File

@@ -0,0 +1,102 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/** End-to-end decode of each model from a well-formed body (exercises the custom serializers). */
class HappyPathDecodeTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
@Test
fun previewDecodesGeometryAndOpaqueData() = runTest {
transport.queueSuccess(
url = "$BASE/live-sessions/$ID_STR/preview",
body = """{"id":"$ID_STR","cols":100,"rows":30,"data":"hello"}""".toByteArray(),
)
val preview = client.preview(ID)
assertEquals(ID, preview.id)
assertEquals(100, preview.cols)
assertEquals("hello", preview.data)
}
@Test
fun uiConfigDecodesAllowAutoMode() = runTest {
transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray())
assertTrue(client.uiConfig().allowAutoMode)
}
@Test
fun liveSessionsDecodesTelemetry() = runTest {
val body = """
[{"id":"$ID_STR","createdAt":10,"clientCount":1,"status":"working","exited":false,"cols":80,"rows":24,
"telemetry":{"at":99,"model":"opus","costUsd":0.42,"linesAdded":10,"pr":{"number":7,"url":"http://x"}}}]
""".trimIndent()
transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray())
val session = client.liveSessions().single()
assertEquals("opus", session.telemetry?.model)
assertEquals(0.42, session.telemetry?.costUsd)
assertEquals(7, session.telemetry?.pr?.number)
}
@Test
fun projectDetailDecodesWorktreesSessionsAndClaudeMd() = runTest {
val body = """
{"name":"repo","path":"/r","isGit":true,"branch":"main","dirty":true,
"worktrees":[{"path":"/r","branch":"main","isMain":true,"isCurrent":true},
{"path":"/r-wt","head":"abc123","isMain":false,"isCurrent":false}],
"sessions":[{"id":"$ID_STR","title":"claude","status":"waiting","clientCount":1,"createdAt":5,"exited":false}],
"hasClaudeMd":true,"claudeMd":"# Repo"}
""".trimIndent()
transport.queueSuccess(url = "$BASE/projects/detail?path=%2Fr", body = body.toByteArray())
val detail = client.projectDetail("/r")
assertEquals("main", detail.branch)
assertEquals(2, detail.worktrees.size)
assertTrue(detail.worktrees[0].isMain)
assertEquals("abc123", detail.worktrees[1].head)
assertEquals("claude", detail.sessions.single().title)
assertEquals("# Repo", detail.claudeMd)
}
@Test
fun prefsRoundTripsThroughGetAndPutEcho() = runTest {
transport.queueSuccess(
url = "$BASE/prefs",
body = """{"favourites":["/a"],"collapsed":{"g":true},"vendor":1}""".toByteArray(),
)
val prefs = client.prefs()
assertEquals(listOf("/a"), prefs.favourites)
// PUT returns the server's sanitized echo — treat IT as truth (server dropped "vendor").
transport.queueSuccess(
method = HttpMethod.PUT,
url = "$BASE/prefs",
body = """{"favourites":["/a","/b"],"collapsed":{}}""".toByteArray(),
)
val echoed = client.putPrefs(prefs.withFavourites(listOf("/a", "/b")))
assertEquals(listOf("/a", "/b"), echoed.favourites)
}
@Test
fun prefsNonObjectBodyThrowsInvalidResponseBody() = runTest {
transport.queueSuccess(url = "$BASE/prefs", body = "[]".toByteArray())
assertEquals(
ApiClientError.InvalidResponseBody,
runCatching { client.prefs() }.exceptionOrNull(),
)
}
}

View File

@@ -0,0 +1,101 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.HostEndpoint
import java.util.UUID
/** Tolerant decode at the UNTRUSTED server boundary (plan §4): drop bad elements, never crash. */
class TolerantDecodeTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
@Test
fun liveSessionsDropsMalformedEntriesAndMapsUnknownStatusToUnknown() = runTest {
// Entry 1: valid, unknown status string. Entry 2: missing required clientCount → dropped.
// Entry 3: non-UUID id → dropped. Entry 4: valid, big-ms createdAt (Long), no telemetry.
val body = """
[
{"id":"$ID_STR","createdAt":1700000000000,"clientCount":2,"status":"reticulating","exited":false,"cols":80,"rows":24},
{"id":"22222222-2222-4222-8222-222222222222","createdAt":1,"status":"idle","exited":false,"cols":80,"rows":24},
{"id":"not-a-uuid","createdAt":1,"clientCount":1,"status":"idle","exited":false,"cols":80,"rows":24},
{"id":"33333333-3333-4333-8333-333333333333","createdAt":1700000000001,"clientCount":0,"status":"working","exited":true,"cwd":"/w","cols":120,"rows":40,"lastOutputAt":1700000000005}
]
""".trimIndent()
transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray())
val sessions = client.liveSessions()
assertEquals(2, sessions.size)
assertEquals(ID, sessions[0].id)
assertEquals(ClaudeStatus.UNKNOWN, sessions[0].status) // unknown wire value → UNKNOWN, not dropped
assertEquals(1_700_000_000_000L, sessions[0].createdAt) // ms fits Long, would overflow Int
assertEquals(ClaudeStatus.WORKING, sessions[1].status)
assertEquals(1_700_000_000_005L, sessions[1].lastOutputAt)
}
@Test
fun liveSessionsNonArrayBodyThrowsInvalidResponseBody() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "{}".toByteArray())
assertEquals(
ApiClientError.InvalidResponseBody,
runCatching { client.liveSessions() }.exceptionOrNull(),
)
}
@Test
fun projectsKeepsProjectButDropsOnlyItsMalformedNestedSession() = runTest {
val body = """
[
{"name":"repo","path":"/r","isGit":true,"branch":"main","sessions":[
{"id":"$ID_STR","status":"working","clientCount":1,"createdAt":1,"exited":false},
{"id":"bad","status":"working","clientCount":1,"createdAt":1,"exited":false}
]}
]
""".trimIndent()
transport.queueSuccess(url = "$BASE/projects", body = body.toByteArray())
val projects = client.projects()
assertEquals(1, projects.size) // project survives its bad nested session
assertEquals(1, projects[0].sessions.size)
assertEquals(ID, projects[0].sessions[0].id)
}
@Test
fun eventsNonArrayBodyDegradesToEmptyAndUnknownClassSurvives() = runTest {
// A non-array body (timeline capture disabled) → [] rather than an error.
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "{}".toByteArray())
assertTrue(client.events(ID).isEmpty())
// Unknown class decodes fine (shape ok); hasKnownClass=false for downstream filtering.
transport.queueSuccess(
url = "$BASE/live-sessions/$ID_STR/events",
body = """[{"at":5,"class":"weird","label":"did a thing"}]""".toByteArray(),
)
val events = client.events(ID)
assertEquals(1, events.size)
assertFalse(events[0].hasKnownClass)
assertEquals("did a thing", events[0].label)
}
@Test
fun singleObjectRoutesThrowInvalidResponseBodyOnGarbage() = runTest {
transport.queueSuccess(url = "$BASE/config/ui", body = "not json".toByteArray())
assertEquals(
ApiClientError.InvalidResponseBody,
runCatching { client.uiConfig() }.exceptionOrNull(),
)
}
}

View File

@@ -0,0 +1,48 @@
// ─────────────────────────────────────────────────────────────────────────────
// :app — the Android application (Compose UI, ViewModels, Hilt DI, FCM push,
// DeepLinkRouter, DesignSystem, EventBus). Mirrors iOS App/WebTerm.
//
// SCAFFOLD STUB ONLY. This module is COMMENTED OUT in settings.gradle.kts because
// this build environment has NO Android SDK. The block below is the intended shape;
// it applies the Android Gradle Plugin, which cannot resolve without an SDK.
//
// TODO(android-sdk): uncomment the include in settings.gradle.kts AND this block,
// add AGP + google() to pluginManagement, and provide local.properties -> sdk.dir.
// ─────────────────────────────────────────────────────────────────────────────
/*
plugins {
id("com.android.application")
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
}
android {
namespace = "wang.yaojia.webterm"
compileSdk = 35
defaultConfig {
applicationId = "wang.yaojia.webterm"
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildFeatures { compose = true }
}
kotlin { jvmToolchain(17) }
dependencies {
implementation(project(":wire-protocol"))
implementation(project(":session-core"))
implementation(project(":api-client"))
implementation(project(":client-tls"))
implementation(project(":client-tls-android"))
implementation(project(":host-registry"))
implementation(project(":terminal-view"))
}
*/

8
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,8 @@
// Root build script. Registers the Kotlin plugins (versions from the catalog) but
// applies none at the root — each module opts in via its own `plugins { }` block.
// Kept intentionally thin (KISS); there is no allprojects/subprojects magic.
plugins {
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
}

View File

@@ -0,0 +1,30 @@
// ─────────────────────────────────────────────────────────────────────────────
// :client-tls-android — the FRAMEWORK half of ClientTLS: AndroidKeyStore import
// (device-bound, non-exportable key), Tink AEAD cert-chain-at-rest storage, and
// connectionPool.evictAll() on rotation. Tested instrumented on a real device.
// Depends on the pure :client-tls module for the parse/selection logic.
//
// SCAFFOLD STUB ONLY — COMMENTED OUT in settings.gradle.kts (no Android SDK here).
// TODO(android-sdk): enable when an Android SDK is available.
// ─────────────────────────────────────────────────────────────────────────────
/*
plugins {
id("com.android.library")
alias(libs.plugins.kotlin.android)
}
android {
namespace = "wang.yaojia.webterm.tlsandroid"
compileSdk = 35
defaultConfig { minSdk = 29 }
}
kotlin { jvmToolchain(17) }
dependencies {
api(project(":client-tls"))
implementation(project(":wire-protocol"))
// implementation("com.google.crypto.tink:tink-android:1.15.0")
}
*/

View File

@@ -0,0 +1,26 @@
// :client-tls (PURE half) — PKCS#12 structural parse (KeyStore("PKCS12"),
// import-parse only), X509KeyManager alias/getPrivateKey selection logic,
// CertificateSummary parsing, PairingError/HostClassifier warning-tier mapping.
// JVM-speed tested and IN the 80% Kover gate. The AndroidKeyStore/Tink framework
// half lives in the (SDK-gated) :client-tls-android module.
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(17)
}
dependencies {
api(project(":wire-protocol"))
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher)
}
tasks.test {
useJUnitPlatform()
}

View File

@@ -0,0 +1,68 @@
package wang.yaojia.webterm.clienttls
import java.security.cert.X509Certificate
import java.time.Instant
import javax.naming.ldap.LdapName
import javax.security.auth.x500.X500Principal
/**
* Display summary of a device certificate (C-iOS-3), shown on the install / rotation screen so the
* user can confirm *which* cert is active and *when* it expires before relying on it. Faithful port
* of iOS `ClientCertificateSummary`.
*
* @property subjectCommonName subject CN — the device/leaf CN (e.g. `t1-android`); null if absent.
* @property issuerCommonName issuer CN — the device-CA CN (e.g. `webterm-device-ca`); null if absent.
* @property notAfter not-after instant; null if it could not be parsed.
*/
public data class CertificateSummary(
val subjectCommonName: String?,
val issuerCommonName: String?,
val notAfter: Instant?,
) {
/**
* Expired relative to [now] (defaults to the current instant). Unknown expiry is treated as NOT
* expired (fail-open for display only — the TLS stack, not this label, is the real gate),
* matching iOS `isExpired`.
*/
public fun isExpired(now: Instant = Instant.now()): Boolean {
val end = notAfter ?: return false
return end.isBefore(now)
}
}
/**
* Reads the display fields off an [X509Certificate].
*
* Unlike iOS (which hand-walks the DER because `SecCertificate` lacks the accessor on iOS), the JVM
* `X509Certificate` already exposes subject/issuer principals and `notAfter`, so this is a thin
* extraction. CN is pulled from the RFC2253 DN via `javax.naming.ldap.LdapName` (JVM stdlib), which
* handles DN escaping/ordering correctly. Any parse miss degrades to `null` fields (the summary is
* display-only; the TLS stack is the gate).
*/
public object CertificateSummaryReader {
private const val COMMON_NAME_TYPE = "CN"
public fun summarize(certificate: X509Certificate): CertificateSummary =
CertificateSummary(
subjectCommonName = commonName(certificate.subjectX500Principal),
issuerCommonName = commonName(certificate.issuerX500Principal),
notAfter = notAfterInstant(certificate),
)
private fun commonName(principal: X500Principal): String? =
try {
LdapName(principal.getName(X500Principal.RFC2253)).rdns
.firstOrNull { it.type.equals(COMMON_NAME_TYPE, ignoreCase = true) }
?.value
?.toString()
} catch (_: Exception) {
null
}
private fun notAfterInstant(certificate: X509Certificate): Instant? =
try {
certificate.notAfter?.toInstant()
} catch (_: Exception) {
null
}
}

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.clienttls
/**
* The pinned device identity as seen by the key-manager selection logic: just enough to *decide*
* whether to present a client certificate. The key material (private key + chain) lives in the
* framework half (A11, AndroidKeyStore-backed) — the pure logic only needs the alias and the key
* algorithm to make the selection.
*
* @property alias the single device-identity alias this app presents.
* @property keyAlgorithm the leaf key's algorithm (`RSA` / `EC`), matched against the server's
* requested client key types.
*/
public data class KeyManagerIdentity(
val alias: String,
val keyAlgorithm: String,
)
/**
* The pure, synchronous alias-selection heart of a client `X509KeyManager` (the Android analogue of
* iOS `MutualTLSChallengeResponder`). Deliberately free of any `SSLEngine` / `Socket` / OkHttp
* state so the full truth table is unit-testable without a live handshake; the framework half (A11)
* is a thin `X509KeyManager` that delegates alias decisions here and reads the actual
* `PrivateKey`/chain from AndroidKeyStore for the [KeyManagerIdentity.alias].
*
* Truth table (mirrors iOS C-iOS-1):
* a. identity present + server accepts our key type → present our [KeyManagerIdentity.alias].
* b. identity present + key-type mismatch → present nothing (null) — a clean, classifiable
* handshake failure rather than a wrong-type cert.
* c. no identity installed → present nothing (null) — never a silent
* wrong-cert; the TLS handshake fails and the failure surfaces (iOS `.cancel`).
*
* @property identity the installed device identity, or null when none is installed.
*/
public class ClientKeyManagerLogic(
private val identity: KeyManagerIdentity?,
) {
/**
* The alias to present for a client-certificate request whose acceptable public-key types are
* [keyTypes] (the `keyType` argument of `X509KeyManager.chooseClientAlias`). Returns the pinned
* alias iff an identity is installed AND its key algorithm is acceptable; null otherwise. A null
* or empty [keyTypes] (server expressed no constraint) is treated as "accept" (fail-open — keep
* the handshake working when there is exactly one pinned identity).
*/
public fun chooseClientAlias(keyTypes: Array<String>?): String? {
val id = identity ?: return null
return if (accepts(keyTypes)) id.alias else null
}
/**
* All aliases eligible for [keyType] (the `X509KeyManager.getClientAliases` shape): a
* single-element array of the pinned alias when eligible, else null.
*/
public fun clientAliases(keyType: String?): Array<String>? {
val id = identity ?: return null
return if (accepts(if (keyType == null) null else arrayOf(keyType))) arrayOf(id.alias) else null
}
/**
* True iff [candidate] is the pinned alias of an installed identity. The framework
* `getPrivateKey(alias)` / `getCertificateChain(alias)` MUST gate on this before returning key
* material, so a foreign alias never leaks our identity.
*/
public fun ownsAlias(candidate: String?): Boolean =
identity != null && candidate != null && candidate == identity.alias
private fun accepts(keyTypes: Array<String>?): Boolean {
val id = identity ?: return false
if (keyTypes == null || keyTypes.isEmpty()) return true
return keyTypes.any { it.equals(id.keyAlgorithm, ignoreCase = true) }
}
}

View File

@@ -0,0 +1,163 @@
package wang.yaojia.webterm.clienttls
import java.io.ByteArrayInputStream
import java.io.IOException
import java.security.GeneralSecurityException
import java.security.KeyStore
import java.security.PrivateKey
import java.security.UnrecoverableKeyException
import java.security.cert.X509Certificate
import javax.crypto.BadPaddingException
/**
* The structural result of parsing a `.p12` import file — the private key, its leaf certificate,
* and the issuer chain that accompanies it in the TLS handshake. Android analogue of iOS
* `ClientIdentity`, but holding only the *pure JVM* material.
*
* @property alias the PKCS#12 key-entry alias the identity was found under.
* @property privateKey the leaf private key (device authentication key).
* @property keyAlgorithm the private key's algorithm (e.g. `RSA`, `EC`) — drives
* [ClientKeyManagerLogic] client-alias selection.
* @property leafCertificate the leaf certificate (chain[0]); the device's own cert.
* @property issuerCertificates the issuer chain (the CA chain, leaf excluded) — may be empty when
* the server already pins the trust anchor. Mirrors iOS `issuerChain` (do not repeat the leaf).
*/
public data class ParsedClientIdentity(
val alias: String,
val privateKey: PrivateKey,
val keyAlgorithm: String,
val leafCertificate: X509Certificate,
val issuerCertificates: List<X509Certificate>,
) {
/** The full chain with the leaf at index 0, as presented to the server. */
public val fullChain: List<X509Certificate>
get() = listOf(leafCertificate) + issuerCertificates
/** Display summary of the leaf certificate for the install/rotation UI. */
public fun summary(): CertificateSummary = CertificateSummaryReader.summarize(leafCertificate)
}
/**
* Decoded, but carried no importable private-key entry (e.g. a certs-only / truststore `.p12`).
* The iOS `PKCS12ImportError.noIdentity` analogue.
*/
public class NoClientIdentityException(message: String) : GeneralSecurityException(message)
/**
* Not a decodable PKCS#12 blob (truncated / not a `.p12` / unsupported algorithms). The iOS
* `PKCS12ImportError.corruptFile` / `.unsupported` analogue; retains the underlying cause.
*/
public class Pkcs12DecodeException(message: String, cause: Throwable? = null) :
GeneralSecurityException(message, cause)
/**
* Structural PKCS#12 parse via `KeyStore("PKCS12")` — JVM stdlib, needs NO Android SDK. This is the
* pure half of the iOS `PKCS12Importer`: it validates the passphrase and extracts the identity, but
* (unlike the framework half A11) never touches AndroidKeyStore/Tink and never persists anything.
*
* Error surface (matches the A10 verify contract):
* - wrong passphrase → [UnrecoverableKeyException] (the JVM idiom; the underlying cause is
* unwrapped so callers see the specific security exception, not a generic [IOException]).
* - corrupt / not-a-p12 / unsupported → [Pkcs12DecodeException].
* - decoded but no key entry → [NoClientIdentityException].
*/
public object Pkcs12Parse {
private const val PKCS12 = "PKCS12"
private const val MAX_CAUSE_DEPTH = 8
/** Case-insensitive message fragments that a JCE provider uses for a rejected PKCS#12 passphrase. */
private val WRONG_PASSPHRASE_HINTS = listOf("password", "mac", "integrity")
/**
* Parse [bytes] with [passphrase] and return the first key-entry identity found. The alias is
* selected by preferring the first entry that is a *key* entry (a `.p12` may also carry
* trusted-cert entries, which are skipped — they hold no private key).
*/
public fun parse(bytes: ByteArray, passphrase: String): ParsedClientIdentity {
val password = passphrase.toCharArray()
val keyStore = loadKeyStore(bytes, password)
val alias = firstKeyAlias(keyStore)
?: throw NoClientIdentityException("PKCS#12 contained no private-key entry")
return buildIdentity(keyStore, alias, password)
}
private fun loadKeyStore(bytes: ByteArray, password: CharArray): KeyStore {
val keyStore = KeyStore.getInstance(PKCS12)
try {
ByteArrayInputStream(bytes).use { keyStore.load(it, password) }
} catch (e: IOException) {
throw classifyLoadFailure(e)
}
return keyStore
}
/**
* Map a `KeyStore.load` [IOException] to the A10 error surface. A wrong passphrase surfaces
* DIFFERENTLY per JCE provider: SunJSSE throws an IOException caused by [UnrecoverableKeyException]
* (MAC/integrity check), while Android's BouncyCastle throws one caused by a
* [BadPaddingException] (or carries a "password"/"mac"/"integrity" message). Treat ALL of those
* as the wrong-passphrase signal so a bad password is never misreported on-device as a corrupt
* file; only a genuinely undecodable blob stays a [Pkcs12DecodeException]. Never echoes the
* passphrase. `internal` so the classification is unit-testable without a provider-specific blob.
*/
internal fun classifyLoadFailure(error: IOException): GeneralSecurityException =
if (isWrongPassphrase(error)) {
wrongPassphrase(error)
} else {
Pkcs12DecodeException("PKCS#12 blob could not be decoded", error)
}
private fun isWrongPassphrase(error: Throwable): Boolean =
causeChain(error).any { cause ->
cause is UnrecoverableKeyException ||
cause is BadPaddingException ||
messageHintsWrongPassphrase(cause.message)
}
private fun messageHintsWrongPassphrase(message: String?): Boolean {
val lowered = message?.lowercase() ?: return false
return WRONG_PASSPHRASE_HINTS.any { it in lowered }
}
/** Prefer a provider-native [UnrecoverableKeyException]; otherwise wrap (never echo the passphrase). */
private fun wrongPassphrase(error: Throwable): UnrecoverableKeyException =
causeChain(error).filterIsInstance<UnrecoverableKeyException>().firstOrNull()
?: UnrecoverableKeyException("PKCS#12 passphrase was rejected (MAC/integrity check failed)")
/** Bounded cause-chain walk with an identity cycle-guard (never trust an error graph not to cycle). */
private fun causeChain(error: Throwable): List<Throwable> {
val chain = mutableListOf<Throwable>()
var current: Throwable? = error
while (current != null && chain.size < MAX_CAUSE_DEPTH) {
if (chain.any { it === current }) break
chain.add(current)
current = current.cause
}
return chain
}
/** First alias that holds a private key; trusted-cert-only entries are skipped. */
private fun firstKeyAlias(keyStore: KeyStore): String? =
keyStore.aliases().toList().firstOrNull { keyStore.isKeyEntry(it) }
private fun buildIdentity(
keyStore: KeyStore,
alias: String,
password: CharArray,
): ParsedClientIdentity {
// getKey with a wrong key-password throws UnrecoverableKeyException directly (propagated).
val key = keyStore.getKey(alias, password) as? PrivateKey
?: throw NoClientIdentityException("Entry '$alias' held no private key")
val chain = (keyStore.getCertificateChain(alias) ?: emptyArray())
.filterIsInstance<X509Certificate>()
val leaf = chain.firstOrNull()
?: throw NoClientIdentityException("Entry '$alias' had no certificate chain")
return ParsedClientIdentity(
alias = alias,
privateKey = key,
keyAlgorithm = key.algorithm,
leafCertificate = leaf,
issuerCertificates = chain.drop(1),
)
}
}

View File

@@ -0,0 +1,64 @@
package wang.yaojia.webterm.clienttls
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
import kotlin.time.Duration.Companion.days
/** Ports iOS `CertificateSummary` tests — field extraction off a real cert + the isExpired rule. */
class CertificateSummaryTest {
private fun leafSummary(): CertificateSummary =
Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE).summary()
@Test
fun extractsSubjectAndIssuerCommonNamesFromRealCertificate() {
val summary = leafSummary()
assertEquals(Fixtures.LEAF_SUBJECT_CN, summary.subjectCommonName)
assertEquals(Fixtures.LEAF_ISSUER_CN, summary.issuerCommonName)
}
@Test
fun extractsNotAfterFromRealCertificate() {
val summary = leafSummary()
assertNotNull(summary.notAfter, "notAfter should parse off the fixture cert")
// Fixture was minted with a ~100-year validity, so it is not expired now.
assertFalse(summary.isExpired(Instant.now()))
}
@Test
fun isExpiredIsTrueStrictlyAfterNotAfter() {
val notAfter = Instant.parse("2030-01-01T00:00:00Z")
val summary = CertificateSummary(subjectCommonName = "x", issuerCommonName = "y", notAfter = notAfter)
assertFalse(summary.isExpired(notAfter.minusMillis(1)), "just before expiry")
assertFalse(summary.isExpired(notAfter), "exactly at notAfter is not yet expired")
assertTrue(summary.isExpired(notAfter.plusMillis(1)), "just after expiry")
assertTrue(summary.isExpired(notAfter.plusSeconds(365.days.inWholeSeconds)), "well after expiry")
}
@Test
fun unknownExpiryFailsOpenAsNotExpired() {
val summary = CertificateSummary(subjectCommonName = null, issuerCommonName = null, notAfter = null)
assertFalse(summary.isExpired(Instant.now()), "null notAfter is display-only fail-open → not expired")
}
@Test
fun realCertificateNotAfterMatchesLeafExpiry() {
// Derive isExpired boundaries from the parsed notAfter so the test stays deterministic
// regardless of the fixture generation date.
val summary = leafSummary()
val notAfter = requireNotNull(summary.notAfter)
assertFalse(summary.isExpired(notAfter.minusSeconds(1)))
assertTrue(summary.isExpired(notAfter.plusSeconds(1)))
}
@Test
fun summaryFromCertificateReaderMatchesParsedLeaf() {
val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE)
val direct = CertificateSummaryReader.summarize(identity.leafCertificate)
assertEquals(identity.summary(), direct)
}
}

View File

@@ -0,0 +1,59 @@
package wang.yaojia.webterm.clienttls
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* Truth table for the pure client X509KeyManager alias selection (iOS `MutualTLSChallengeResponder`
* analogue): present iff an identity is installed AND its key type is acceptable.
*/
class ClientKeyManagerLogicTest {
private val alias = "device"
private val installed = ClientKeyManagerLogic(KeyManagerIdentity(alias = alias, keyAlgorithm = "RSA"))
private val empty = ClientKeyManagerLogic(identity = null)
@Test
fun presentsAliasWhenServerAcceptsOurKeyType() {
assertEquals(alias, installed.chooseClientAlias(arrayOf("RSA")))
assertEquals(alias, installed.chooseClientAlias(arrayOf("EC", "RSA")))
assertEquals(alias, installed.chooseClientAlias(arrayOf("rsa")), "match is case-insensitive")
}
@Test
fun failsOpenWhenServerExpressesNoKeyTypeConstraint() {
assertEquals(alias, installed.chooseClientAlias(null))
assertEquals(alias, installed.chooseClientAlias(emptyArray()))
}
@Test
fun presentsNothingOnKeyTypeMismatch() {
assertNull(installed.chooseClientAlias(arrayOf("EC")))
assertNull(installed.chooseClientAlias(arrayOf("DSA", "EC")))
}
@Test
fun presentsNothingWhenNoIdentityInstalled() {
assertNull(empty.chooseClientAlias(arrayOf("RSA")))
assertNull(empty.chooseClientAlias(null))
assertNull(empty.clientAliases("RSA"))
assertFalse(empty.ownsAlias(alias))
}
@Test
fun clientAliasesReturnsSingletonWhenEligible() {
assertArrayEquals(arrayOf(alias), installed.clientAliases("RSA"))
assertArrayEquals(arrayOf(alias), installed.clientAliases(null))
assertNull(installed.clientAliases("EC"))
}
@Test
fun ownsAliasGatesKeyMaterialToThePinnedAlias() {
assertTrue(installed.ownsAlias(alias))
assertFalse(installed.ownsAlias("someone-else"))
assertFalse(installed.ownsAlias(null))
}
}

View File

@@ -0,0 +1,80 @@
package wang.yaojia.webterm.clienttls
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier
/** Ports iOS `HostClassificationTests` — the four §5.4 warning tiers + the fail-safe default. */
class HostClassifierTest {
@Test
fun loopbackTierMatchesLocalTargets() {
val hosts = listOf("localhost", "LOCALHOST", "127.0.0.1", "127.8.8.8", "::1", "[::1]")
for (host in hosts) {
assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(host), "$host should be loopback")
}
}
@Test
fun privateLanTierMatchesRfc1918AndLinkLocal() {
val hosts = listOf(
"10.0.0.5", "192.168.0.9", "172.16.0.1", "172.31.255.255",
"169.254.1.1", "mac-mini.local", "Mac-Mini.LOCAL",
)
for (host in hosts) {
assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(host), "$host should be privateLAN")
}
}
@Test
fun tailscaleTierMatchesCgnatAndMagicDns() {
val hosts = listOf(
"100.64.0.1", "100.100.1.1", "100.127.255.255",
"mac.tailnet.ts.net", "foo.TS.NET",
)
for (host in hosts) {
assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(host), "$host should be tailscale")
}
}
@Test
fun publicTierIsTheFailSafeDefault() {
// Out of range: 172.32 exceeds 172.16/12; 100.128 & 100.63 exceed 100.64/10; malformed IPv4.
val hosts = listOf(
"8.8.8.8", "203.0.113.7", "example.com", "tsnet.example.com",
"172.32.0.1", "100.128.0.1", "100.63.255.255", "256.1.1.1", "1.2.3", "",
)
for (host in hosts) {
assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(host), "$host should be public")
}
}
@Test
fun classifyEndpointMatchesHostStringVersion() {
val vectors = listOf(
"http://127.0.0.1:3000" to HostNetworkTier.LOOPBACK,
"http://192.168.1.5:3000" to HostNetworkTier.PRIVATE_LAN,
"http://100.100.1.1:3000" to HostNetworkTier.TAILSCALE,
"https://mac.tailnet.ts.net" to HostNetworkTier.TAILSCALE,
"https://example.com" to HostNetworkTier.PUBLIC,
)
for ((url, tier) in vectors) {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(url)) { "endpoint for $url" }
assertEquals(tier, HostClassifier.classify(endpoint), url)
}
}
@Test
fun ipv4OctetsStrictParseRejectsNonDottedQuads() {
assertNull(HostClassifier.ipv4Octets(""))
assertNull(HostClassifier.ipv4Octets("1.2.3"))
assertNull(HostClassifier.ipv4Octets("1.2.3.4.5"))
assertNull(HostClassifier.ipv4Octets("256.1.1.1"))
assertNull(HostClassifier.ipv4Octets("-1.2.3.4"))
assertNull(HostClassifier.ipv4Octets("a.b.c.d"))
assertNull(HostClassifier.ipv4Octets(".1.2.3"))
assertEquals(listOf(10, 0, 0, 255), HostClassifier.ipv4Octets("10.0.0.255")?.toList())
}
}

View File

@@ -0,0 +1,89 @@
package wang.yaojia.webterm.clienttls
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.io.IOException
import java.security.UnrecoverableKeyException
import javax.crypto.BadPaddingException
/** Ports iOS `PKCS12Importer` tests against a real keytool-minted `.p12` fixture (pure JVM). */
class Pkcs12ParseTest {
@Test
fun parsesLeafIdentityWithChainAndKey() {
val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE)
assertEquals("device", identity.alias, "must select the key entry, not the trusted-cert entry")
assertEquals("RSA", identity.keyAlgorithm)
assertEquals(Fixtures.LEAF_SUBJECT_CN, identity.summary().subjectCommonName)
}
@Test
fun issuerChainExcludesTheLeafAndKeepsTheCa() {
val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE)
// Full chain is leaf → ca; issuerCertificates drops the leaf (mirrors iOS issuerChain).
assertEquals(2, identity.fullChain.size)
assertEquals(1, identity.issuerCertificates.size)
assertEquals(identity.leafCertificate, identity.fullChain.first())
val caSummary = CertificateSummaryReader.summarize(identity.issuerCertificates.first())
assertEquals(Fixtures.LEAF_ISSUER_CN, caSummary.subjectCommonName, "issuer cert is the device CA")
}
@Test
fun wrongPassphraseThrowsUnrecoverableKeyException() {
assertThrows<UnrecoverableKeyException> {
Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.WRONG_PASSPHRASE)
}
}
@Test
fun corruptBlobThrowsDecodeException() {
val garbage = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8)
assertThrows<Pkcs12DecodeException> {
Pkcs12Parse.parse(garbage, Fixtures.PASSPHRASE)
}
}
@Test
fun bouncyCastleBadPaddingIsClassifiedAsWrongPassphraseNotCorrupt() {
// On-device (BouncyCastle) a wrong passphrase surfaces as an IOException caused by
// BadPaddingException, NOT UnrecoverableKeyException — it must still map to wrong-passphrase.
val ioe = IOException("exception unwrapping private key", BadPaddingException("pad block corrupted"))
assertInstanceOf(UnrecoverableKeyException::class.java, Pkcs12Parse.classifyLoadFailure(ioe))
}
@Test
fun wrongPasswordMessageIsClassifiedAsWrongPassphrase() {
// Some providers only report the failure in the message (no typed cause).
assertInstanceOf(
UnrecoverableKeyException::class.java,
Pkcs12Parse.classifyLoadFailure(IOException("keystore password was incorrect")),
)
}
@Test
fun genuinelyUndecodableBlobStaysDecodeException() {
// A structural DER failure carries no wrong-passphrase signal → stays a decode error.
assertInstanceOf(
Pkcs12DecodeException::class.java,
Pkcs12Parse.classifyLoadFailure(IOException("DerInputStream.getLength(): lengthTag=127, too big")),
)
}
@Test
fun certsOnlyStoreThrowsNoClientIdentity() {
assertThrows<NoClientIdentityException> {
Pkcs12Parse.parse(Fixtures.trustP12(), Fixtures.PASSPHRASE)
}
}
@Test
fun privateKeyIsRecoverableWithCorrectPassphrase() {
val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE)
assertTrue(identity.privateKey.encoded.isNotEmpty(), "private key material is present")
}
}

File diff suppressed because one or more lines are too long

10
android/gradle.properties Normal file
View File

@@ -0,0 +1,10 @@
# JVM args for the Gradle daemon.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Opt into stable Gradle features used by the build.
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=true
# Kotlin: use the K2 compiler defaults; official code style.
kotlin.code.style=official

View File

@@ -0,0 +1,37 @@
## Version catalog — single source of truth for plugin & dependency versions.
## Pure JVM modules only (this env has NO Android SDK). See android/README.md.
## Kotlin pinned to 2.3.21 to match the Kotlin embedded in Gradle 9.6.1 (best
## compatibility with the Kotlin Gradle Plugin on Gradle 9.6).
[versions]
kotlin = "2.3.21"
kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2"
junit = "5.13.4"
junitPlatform = "1.13.4"
turbine = "1.2.1"
mockk = "1.14.2"
[libraries]
# Serialization
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
# Coroutines
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Test — JUnit5 (Jupiter)
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" }
# Test — Flow assertions + mocking
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
[bundles]
# Convenience bundle wired into every pure module's testImplementation.
unit-test = ["junit-jupiter", "kotlinx-coroutines-test", "turbine", "mockk"]
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Binary file not shown.

View File

@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
android/gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

82
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

View File

@@ -0,0 +1,33 @@
// ─────────────────────────────────────────────────────────────────────────────
// :host-registry — Host / HostStore + DataStore-backed storage, LastSessionStore.
// Mirrors the iOS HostRegistry package (storage behind an interface).
//
// NOTE: the plan (§3) treats the storage half as Android-framework-bound (DataStore),
// so it is scaffolded here as an SDK-gated module. Its pure logic could later be
// split into a JVM module if the Kover gate needs it; for now it is COMMENTED OUT
// in settings.gradle.kts (no Android SDK here).
//
// SCAFFOLD STUB ONLY. TODO(android-sdk): enable when an Android SDK is available.
// ─────────────────────────────────────────────────────────────────────────────
/*
plugins {
id("com.android.library")
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "wang.yaojia.webterm.hostregistry"
compileSdk = 35
defaultConfig { minSdk = 29 }
}
kotlin { jvmToolchain(17) }
dependencies {
implementation(project(":wire-protocol"))
// implementation("androidx.datastore:datastore-preferences:1.1.1")
// implementation("androidx.datastore:datastore:1.1.1")
}
*/

View File

@@ -0,0 +1,28 @@
// :session-core — pure reducers/state machines (SessionEngine, ReconnectMachine,
// PingScheduler, GateTracker, AwayDigest, UnreadLedger, KeyByteMap, TitleSanitizer,
// SessionEvent). Consumes TermTransport by interface only; runs entirely under
// runTest virtual time. Depends only on :wire-protocol.
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(17)
}
dependencies {
api(project(":wire-protocol"))
implementation(libs.kotlinx.coroutines.core)
// Fakes (FakeConnectionPinger, ...) + TestScope/virtual-time, exposed as `api` by
// :test-support, so reducers can be driven under runTest without inventing a clock seam.
testImplementation(project(":test-support"))
testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher)
}
tasks.test {
useJUnitPlatform()
}

View File

@@ -0,0 +1,74 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.TimelineEvent
/**
* The server's `TimelineClass` vocabulary (src/types.ts:429, produced by
* src/session/timeline.ts CLASS_MAP). Kept in lockstep with
* [TimelineEvent.KNOWN_CLASSES] (a sync test asserts equality). Internal — the UI
* sees counts, not class strings.
*/
private object TimelineClassName {
const val TOOL = "tool"
const val WAITING = "waiting"
const val DONE = "done"
const val STUCK = "stuck"
}
/**
* "What happened while I was away" summary (A6; ports iOS `SessionCore.AwayDigest`,
* plan §3.2 — frozen shape). Reduced ONCE after a reconnect from the session's
* activity timeline (`GET /live-sessions/:id/events`, src/types.ts:434-439) and
* rendered above the terminal (A21/A22).
*/
public data class AwayDigest(
/** Events with class `tool` at/after `since` (PreToolUse + PostToolUse). */
val toolRuns: Int,
/** Events with class `waiting` (approval requests / notifications). */
val waitingCount: Int,
/** Any `done` event seen (Stop / SessionEnd). */
val sawDone: Boolean,
/** Any `stuck` event seen (A5 silent-too-long derivation). */
val sawStuck: Boolean,
/** The newest events (post-`since`), oldest→newest, truncated to `limit`. */
val recent: List<TimelineEvent>,
) {
/** True when there is nothing to show (the UI's "don't render" signal). */
public val isEmpty: Boolean get() = this == EMPTY
public companion object {
/** The all-zero digest: nothing happened → UI skips rendering entirely. */
public val EMPTY: AwayDigest = AwayDigest(
toolRuns = 0,
waitingCount = 0,
sawDone = false,
sawStuck = false,
recent = emptyList(),
)
/**
* Folds a timeline into a digest. PURE function — no I/O, no clock.
*
* - [events]: `/events` entries (untrusted server input; order not assumed
* — entries are stably re-ordered by `at` before truncation).
* - [sinceMs]: the moment the user left (ms since epoch); strictly-earlier
* events are excluded, an event AT the instant still counts.
* - [limit]: max [recent] entries kept (newest win); `<= 0` keeps none.
*/
public fun reduce(events: List<TimelineEvent>, sinceMs: Long, limit: Int): AwayDigest {
val away = events
.filter { it.at >= sinceMs }
.sortedBy { it.at }
if (away.isEmpty()) return EMPTY
val keep = if (limit <= 0) 0 else limit
return AwayDigest(
toolRuns = away.count { it.eventClass == TimelineClassName.TOOL },
waitingCount = away.count { it.eventClass == TimelineClassName.WAITING },
sawDone = away.any { it.eventClass == TimelineClassName.DONE },
sawStuck = away.any { it.eventClass == TimelineClassName.STUCK },
recent = away.takeLast(keep),
)
}
}
}

View File

@@ -0,0 +1,63 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
/**
* One held permission gate as the UI should render it (A6; ports iOS
* `SessionCore.GateState`, plan §3.2 — frozen shape). Produced by [GateTracker]
* from `status` frames; a `null` gate means "gate lifted".
*
* [epoch] is the stale-decision guard (mirrors the web client's
* `pendingEpochValue`, public/terminal-session.ts:98-102): it increments only on
* a pending false→true rising edge, and every approve/reject carries the epoch it
* was tapped against — a stale epoch is dropped so a slow tap can never approve
* the NEXT gate (防误批新 gate).
*/
public data class GateState(
/** `plan` → three-way affordances; `tool` → two-way (public/tabs.ts:334-350). */
val kind: GateKind,
/** Server-supplied context (tool name for tool gates); untrusted display text. */
val detail: String?,
/** Rising-edge counter; see type doc. */
val epoch: Int,
) {
/** The action set for this gate: plan → three-way, tool → two-way. */
public val affordances: List<Affordance>
get() = when (kind) {
GateKind.PLAN -> listOf(Affordance.APPROVE_AUTO, Affordance.APPROVE_REVIEW, Affordance.KEEP_PLANNING)
GateKind.TOOL -> listOf(Affordance.APPROVE, Affordance.REJECT)
}
/**
* One user-facing gate action. Pure affordance DATA — display strings live in
* the App layer; the wire mapping mirrors public/tabs.ts:334-350.
*/
public enum class Affordance {
/** Plan gate: "Approve + Auto" → `approve(mode = ACCEPT_EDITS)`. */
APPROVE_AUTO,
/** Plan gate: "Approve + Review" → `approve(mode = DEFAULT)`. */
APPROVE_REVIEW,
/** Plan gate: "Keep Planning" → `reject` (stays in plan mode). */
KEEP_PLANNING,
/** Tool gate: "Approve" → `approve(mode = null)`. */
APPROVE,
/** Tool gate: "Reject" → `reject`. */
REJECT;
/**
* The exact wire message the web client sends for this affordance
* (public/tabs.ts:345-347: the plan three-way only ever sends
* acceptEdits / default / reject — never raw `auto`, plan §3.1 note).
*/
public val clientMessage: ClientMessage
get() = when (this) {
APPROVE_AUTO -> ClientMessage.Approve(ApproveMode.ACCEPT_EDITS)
APPROVE_REVIEW -> ClientMessage.Approve(ApproveMode.DEFAULT)
KEEP_PLANNING -> ClientMessage.Reject
APPROVE -> ClientMessage.Approve(null)
REJECT -> ClientMessage.Reject
}
}
}

View File

@@ -0,0 +1,74 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.GateKind
/**
* Pure reducer that folds `status` frames' `(pending, gate, detail)` into the
* current [GateState]? (A6; ports iOS `SessionCore.GateTracker`). Mirrors
* public/terminal-session.ts:306-311:
*
* - pending false→true rising edge → epoch +1, new gate held;
* - sustained pending → SAME epoch, kind/detail refresh from the latest frame
* (the server is the source of truth; reattach re-sync stays correct);
* - pending true→false falling edge → gate cleared, epoch counter retained;
* - `gate == null` while pending → treated as a tool gate, because an
* unrecognized gate value decodes as null ([wang.yaojia.webterm.wire.ServerMessage])
* and the pending signal must never be lost (web: `gate ?? null` → tool buttons).
*
* PURE state machine: [reduce] never mutates the receiver — it returns a new
* immutable snapshot. No I/O, no clock; the caller (SessionEngine, A14) feeds
* frames in and emits a gate change event.
*/
public class GateTracker private constructor(
/** The gate currently held server-side, or `null` when none. */
public val current: GateState?,
/**
* Highest epoch ever issued; NOT reset on falling edges so decisions against a
* resolved gate can never match a later one.
*/
private val lastEpoch: Int,
) {
/** Feeds one `status` frame's gate-relevant fields; returns the next snapshot. */
public fun reduce(pending: Boolean, gate: GateKind?, detail: String?): GateTracker {
if (!pending) {
// Falling edge (or still idle): gate lifted, epoch counter retained.
return GateTracker(current = null, lastEpoch = lastEpoch)
}
val kind = gate ?: GateKind.TOOL
val held = current
if (held != null) {
// Sustained pending: same epoch, refresh kind/detail from the frame.
val refreshed = GateState(kind = kind, detail = detail, epoch = held.epoch)
return GateTracker(current = refreshed, lastEpoch = lastEpoch)
}
// Rising edge: mint the next epoch (the ONLY place it increments).
val nextEpoch = lastEpoch + 1
val risen = GateState(kind = kind, detail = detail, epoch = nextEpoch)
return GateTracker(current = risen, lastEpoch = nextEpoch)
}
/**
* True iff a decision tapped against [epoch] may be sent NOW: a gate must be
* held and its epoch must match. Stale epoch or no gate → drop the decision
* (防误批新 gate — the two-line stale-guard).
*/
public fun canDecide(epoch: Int): Boolean {
val held = current ?: return false
return held.epoch == epoch
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is GateTracker) return false
return current == other.current && lastEpoch == other.lastEpoch
}
override fun hashCode(): Int = 31 * (current?.hashCode() ?: 0) + lastEpoch
override fun toString(): String = "GateTracker(current=$current, lastEpoch=$lastEpoch)"
public companion object {
/** The starting tracker: no gate held, epoch counter at 0. */
public val INITIAL: GateTracker = GateTracker(current = null, lastEpoch = 0)
}
}

View File

@@ -0,0 +1,74 @@
package wang.yaojia.webterm.session
/**
* Key-name → terminal byte-string table (A6; ports iOS `SessionCore.KeyByteMap`;
* pure data, no I/O).
*
* Byte-for-byte mirror of the web client's `public/keybar.ts` `KEY_MAP` — the
* SINGLE source of truth for every label→bytes lookup: the KeyBar buttons AND the
* hardware-key mapping both resolve through here (plan A17/A25). Hand-writing an
* escape sequence anywhere in the App layer is a review finding.
*
* Control bytes are built from explicit hex code points (`Char(0x1B)` etc.) that
* map 1:1 to the web `\xNN` escapes — never a raw control byte in source. Gotcha
* carried over from the web client: Enter is `\r` (0x0D, carriage return), NOT
* `\n` — synthesized input with a linefeed breaks TUIs (CLAUDE.md gotcha).
*/
public object KeyByteMap {
private const val ESC_CODE = 0x1B
/**
* One key the bar / hardware mapping can send. [webName] mirrors the web
* `KEY_MAP` property name (iOS's `enum Key: String` raw value); declaration
* order mirrors the web table.
*/
public enum class Key(public val webName: String) {
ESC("esc"),
ESC_ESC("escEsc"),
SHIFT_TAB("shiftTab"),
ARROW_UP("arrowUp"),
ARROW_DOWN("arrowDown"),
ARROW_LEFT("arrowLeft"),
ARROW_RIGHT("arrowRight"),
ENTER("enter"),
CTRL_C("ctrlC"),
CTRL_R("ctrlR"),
CTRL_O("ctrlO"),
CTRL_L("ctrlL"),
CTRL_T("ctrlT"),
CTRL_B("ctrlB"),
CTRL_D("ctrlD"),
TAB("tab"),
SLASH("slash"),
}
/**
* The exact byte string to send as `ClientMessage.Input(data)` for [key].
* Values are verbatim from public/keybar.ts KEY_MAP (see `web '\xNN'` notes);
* never edit one side alone.
*/
public fun bytes(key: Key): String = when (key) {
Key.ESC -> esc() // web '\x1b'
Key.ESC_ESC -> esc() + esc() // web '\x1b\x1b'
Key.SHIFT_TAB -> esc() + "[Z" // web '\x1b[Z'
Key.ARROW_UP -> esc() + "[A" // web '\x1b[A'
Key.ARROW_DOWN -> esc() + "[B" // web '\x1b[B'
Key.ARROW_LEFT -> esc() + "[D" // web '\x1b[D'
Key.ARROW_RIGHT -> esc() + "[C" // web '\x1b[C'
Key.ENTER -> "\r" // web '\r' — NOT "\n" (CLAUDE.md gotcha)
Key.CTRL_C -> ctrl(0x03) // web '\x03'
Key.CTRL_R -> ctrl(0x12) // web '\x12'
Key.CTRL_O -> ctrl(0x0F) // web '\x0f'
Key.CTRL_L -> ctrl(0x0C) // web '\x0c'
Key.CTRL_T -> ctrl(0x14) // web '\x14'
Key.CTRL_B -> ctrl(0x02) // web '\x02'
Key.CTRL_D -> ctrl(0x04) // web '\x04'
Key.TAB -> "\t" // web '\t'
Key.SLASH -> "/" // web '/'
}
private fun esc(): String = ctrl(ESC_CODE)
/** A single control byte as a 1-char String (matches the web `\xNN` escape). */
private fun ctrl(code: Int): String = Char(code).toString()
}

View File

@@ -0,0 +1,56 @@
package wang.yaojia.webterm.session
import kotlinx.coroutines.delay
import wang.yaojia.webterm.wire.Tunables
import kotlin.time.Duration
/**
* WS keep-alive pacer (A5, plan §5). The Android analogue of iOS `SessionCore.PingScheduler`.
*
* OkHttp can drive an automatic ping, but the miss POLICY must be deterministic in tests, so
* [run] sends one explicit ping every [interval] (default [Tunables.PING_INTERVAL] = 25 s) using
* a virtual-time [delay]. A half-dead connection otherwise "looks connected" forever.
*
* Miss policy ([Tunables.PONG_MISS_LIMIT] = 2): one missed pong is tolerated; the moment two
* CONSECUTIVE pings go unanswered the connection is declared dead and [run] returns
* [Outcome.CONNECTION_LOST] — the caller (SessionEngine, A14) then feeds [ReconnectMachine.Input.Disconnected]
* into the reconnect machine. Any answered ping resets the consecutive-miss counter.
*
* Zero real timers: [run] suspends on [delay], so under `runTest` a test advances virtual time
* and never waits wall-clock time.
*/
public class PingScheduler(
/** Ping period. Frozen default: [Tunables.PING_INTERVAL] (25 s). */
public val interval: Duration = Tunables.PING_INTERVAL,
) {
/** How [run] RETURNS. Cancellation is NOT a return value — it propagates as an exception. */
public enum class Outcome {
/** [Tunables.PONG_MISS_LIMIT] consecutive pings went unanswered — treat as disconnected. */
CONNECTION_LOST,
}
/**
* Loops until the connection dies or the coroutine is cancelled: [delay] one [interval], then
* await [sendPing].
*
* [sendPing] returns true iff the pong arrived — the transport adapter (A7) owns ping/pong
* resolution; false = a miss. Cancellation of the enclosing coroutine surfaces as a
* `CancellationException` from [delay] and is allowed to PROPAGATE (structured concurrency): it
* is a teardown signal owned by the caller (launch{} + cancel), and swallowing it would run the
* rest of the loop in an already-cancelled scope and defeat `withTimeout`. [run] therefore only
* ever *returns* [Outcome.CONNECTION_LOST]; teardown exits via the thrown exception.
*/
public suspend fun run(sendPing: suspend () -> Boolean): Outcome {
var consecutiveMisses = 0
while (true) {
// Not wrapped in try/catch: a CancellationException from delay() must propagate, not be
// absorbed into a return value (see the KDoc — structured concurrency).
delay(interval)
val isPongReceived = sendPing()
consecutiveMisses = if (isPongReceived) 0 else consecutiveMisses + 1
if (consecutiveMisses >= Tunables.PONG_MISS_LIMIT) {
return Outcome.CONNECTION_LOST
}
}
}
}

View File

@@ -0,0 +1,111 @@
package wang.yaojia.webterm.session
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Pure reconnect back-off reducer (A5, plan §5). The Android analogue of iOS
* `SessionCore.ReconnectMachine`, mirroring the web client's behaviour in
* `public/terminal-session.ts`:
* - the first retry delay is 1 s (`reconnectDelay = 1000`),
* - each disconnect schedules the CURRENT delay, then doubles-and-caps it at 30 s
* (`Math.min(delay * 2, 30_000)`),
* - a successful open resets the delay to 1 s.
*
* Ladder: 1s -> 2s -> 4s -> 8s -> 16s -> 30s -> 30s ...
*
* PURE state machine: [reduce] never mutates the receiver — it returns a new immutable
* snapshot plus the single [Effect] the caller (SessionEngine, A14) must perform. There is
* no clock and no timer here; timing lives with the caller, which is what makes this fully
* testable with zero real waits.
*
* DIVERGENCE FROM iOS (deliberate, plan §5 A5 "carries sessionId"): the machine also carries
* the [sessionId] of the session being (re)connected. iOS's `.connected` returned a brand-new
* `.initial`, dropping any id; here `.connected` resets ONLY the delay ladder and PRESERVES the
* [sessionId], so every reconnect re-attaches to the same server session and the ring buffer is
* replayed (invariant #2). The id is set via [withSessionId] when the server confirms the attach.
*/
public data class ReconnectMachine(
/** Delay the NEXT [Input.Disconnected] will schedule (the current ladder rung). */
public val nextRetryDelay: Duration,
/** The session being reconnected; `null` before the server has issued an id. */
public val sessionId: String?,
) {
/** What happened to the connection (fed by the caller). */
public sealed interface Input {
/** The transport came up and the attach handshake succeeded. */
public data object Connected : Input
/** The transport dropped (or a connect attempt failed). */
public data object Disconnected : Input
/** The scheduled back-off timer elapsed. */
public data object RetryTimerFired : Input
/** The app came to the foreground — connect NOW, skip the timer. */
public data object Foregrounded : Input
/** The user tapped "retry" — connect NOW, skip the timer. */
public data object UserRetry : Input
}
/** The single side effect the caller must perform for an [Input]. */
public sealed interface Effect {
/** Establish (or re-establish) the connection immediately. */
public data object ConnectNow : Effect
/** Arm a timer for [after]; feed [Input.RetryTimerFired] when it elapses. */
public data class ScheduleRetry(val after: Duration) : Effect
/** Nothing to do. */
public data object None : Effect
}
/**
* Feeds one input and returns `(next snapshot, effect to perform)`.
*
* - [Input.Connected]: back-off resets to the initial 1 s rung; the [sessionId] is kept
* (a successful reconnect is still the same session). No effect.
* - [Input.Disconnected]: schedule a retry after the current delay; the next snapshot
* carries the doubled-and-capped delay.
* - [Input.RetryTimerFired] / [Input.Foregrounded] / [Input.UserRetry]: connect NOW.
* Foreground/user-initiated attempts deliberately do NOT reset the ladder — only a
* successful [Input.Connected] does, so mashing retry can never turn back-off into
* hammering (no-reset-on-foreground).
*/
public fun reduce(input: Input): Pair<ReconnectMachine, Effect> =
when (input) {
Input.Connected ->
copy(nextRetryDelay = INITIAL_RETRY_DELAY) to Effect.None
Input.Disconnected -> {
val doubled = nextRetryDelay * BACKOFF_MULTIPLIER
val next = copy(nextRetryDelay = minOf(doubled, MAX_RETRY_DELAY))
next to Effect.ScheduleRetry(after = nextRetryDelay)
}
Input.RetryTimerFired, Input.Foregrounded, Input.UserRetry ->
this to Effect.ConnectNow
}
/** Returns a copy carrying [sessionId] (adopted when the server confirms the attach). */
public fun withSessionId(sessionId: String?): ReconnectMachine = copy(sessionId = sessionId)
public companion object {
/** First retry delay after a disconnect (`public/terminal-session.ts` `reconnectDelay`). */
public val INITIAL_RETRY_DELAY: Duration = 1.seconds
/** Back-off ceiling (`Math.min(delay * 2, 30_000)`). */
public val MAX_RETRY_DELAY: Duration = 30.seconds
/** Doubling factor per failed attempt. */
public const val BACKOFF_MULTIPLIER: Int = 2
/**
* Fresh machine with no session id yet. Seed a known id (reconnecting an existing session)
* with `ReconnectMachine.initial.withSessionId(id)`.
*/
public val initial: ReconnectMachine =
ReconnectMachine(nextRetryDelay = INITIAL_RETRY_DELAY, sessionId = null)
}
}

View File

@@ -0,0 +1,29 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.StatusTelemetry
/**
* A6's half of the [SessionEvent] sealed hierarchy: the COCKPIT subtypes, appended here per the
* split-file ownership A5 reserved in `SessionEvent.kt` (Kotlin allows a sealed hierarchy to be
* extended within the same module+package, and these subtypes carry A6-owned payloads —
* [GateState] / [AwayDigest] — so they live with A6's reducers). Every subtype is TOP-LEVEL so the
* UI reads `is Gate`, `is Digest`, `is Telemetry` uniformly alongside A5's `is Output` etc.
*
* Ports the iOS `SessionEvent.gate` / `.digest` / `.telemetry` cases 1:1.
*/
/**
* The held permission gate changed; `null` = gate lifted ([GateTracker]). Carries the epoch the UI
* must hand back when deciding (the stale-guard, [GateState.epoch]).
*/
public data class Gate(val gate: GateState?) : SessionEvent
/**
* "What happened while I was away" — emitted exactly ONCE after each completed reconnect, reduced
* over the events since the disconnect moment ([AwayDigest.reduce]). An [AwayDigest.isEmpty] digest
* is still delivered; the UI suppresses rendering it (all-zero suppressed).
*/
public data class Digest(val digest: AwayDigest) : SessionEvent
/** Latest statusLine telemetry broadcast (mirrors the server `telemetry` frame, B2). */
public data class Telemetry(val telemetry: StatusTelemetry) : SessionEvent

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.session
import kotlin.time.Duration
/**
* Events the SessionEngine (A14) publishes to the UI (FROZEN contract, plan §5). The Android
* analogue of iOS `SessionCore.SessionEvent`. A single event type carries every engine->UI
* signal so the UI consumes one stream with one `when`.
*
* SPLIT-FILE HIERARCHY (ownership, plan §5): this file (A5) freezes the sealed interface plus the
* connection-lifecycle subtypes ([Connection], [Adopted], [Output], [Exited]). The cockpit
* subtypes (`Gate`, `Digest`, `Telemetry`, which carry A6-owned payloads GateState/AwayDigest/
* StatusTelemetry) are appended by A6 in `SessionEvent-cockpit.kt`. Kotlin only allows a sealed
* hierarchy to be extended within the SAME module+package and a nested subtype cannot be added
* from another file, so ALL subtypes are declared TOP-LEVEL (not nested under the interface) to
* keep A5's and A6's additions uniform — every event reads as `is Output`, `is Gate`, ...
*/
public sealed interface SessionEvent
/**
* Connection lifecycle changed. `.failed` inside [ConnectionState] is a NON-retryable terminal
* state — the engine will not reconnect.
*/
public data class Connection(val state: ConnectionState) : SessionEvent
/**
* The server confirmed the attach. ALWAYS adopt the server-issued id — attaching with an unknown
* id yields a brand-new session and a NEW id (`src/session/manager.ts`). The wire carries the id
* as a string ([wang.yaojia.webterm.wire.ServerMessage.Attached]).
*/
public data class Adopted(val sessionId: String) : SessionEvent
/** Opaque terminal bytes; ring-buffer replay and the live stream share this shape. Feed verbatim. */
public data class Output(val data: String) : SessionEvent
/**
* The shell exited (terminal). `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) means the spawn
* never succeeded. Either way the session is over — the engine stops reconnecting.
*/
public data class Exited(val code: Int, val reason: String? = null) : SessionEvent
/** Connection lifecycle states carried by [Connection]. */
public sealed interface ConnectionState {
/** `open()` was called; the first connect is being established. */
public data object Connecting : ConnectionState
/** Live: transport up, attach handshake done, frames flowing. */
public data object Connected : ConnectionState
/**
* The transport dropped; retry number [attempt] fires after [next]. The ladder mirrors the
* web client via [ReconnectMachine] (1s -> ... -> 30s cap).
*/
public data class Reconnecting(val attempt: Int, val next: Duration) : ConnectionState
/** Ended deliberately: client `close()` (detach — the server-side PTY keeps running) or exit. */
public data object Closed : ConnectionState
/** NON-retryable terminal failure — the engine stopped for good; the UI shows actionable copy. */
public data class Failed(val reason: FailureReason) : ConnectionState
}
/** Why the engine gave up (non-retryable terminal reasons). */
public enum class FailureReason {
/**
* The single-frame ring-buffer replay exceeded [wang.yaojia.webterm.wire.Tunables.MAX_WS_MESSAGE_BYTES]:
* reconnecting would deterministically hit the same wall forever, so the engine never enters
* back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap.
*/
REPLAY_TOO_LARGE,
}

View File

@@ -0,0 +1,76 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.Tunables
import java.text.BreakIterator
/**
* OSC-title sanitiser (A6; ports iOS `SessionCore.TitleSanitizer`). Terminal
* titles (OSC 0/2, surfaced by the emulator's title callback) are HOST/ATTACKER-
* CONTROLLED input — they must pass through here before any list/registry/UI use
* (plan §7).
*
* Rules, in order:
* 1. Strip ALL Unicode control characters (C0 U+0000-001F + C1 U+0080-009F + DEL
* U+007F). The emulator's OSC parsing should already exclude C0, but that is
* NOT assumed — escape injection gets zero tolerance at this boundary.
* 2. Strip the spoofing vectors OSC parsing does NOT touch: zero-width and
* bidirectional formatting characters — U+200B-200F (zero-width space /
* non-joiner / joiner, LRM/RLM), U+202A-202E (bidi embeddings + the classic
* RLO override), U+2066-2069 (bidi isolates), plus U+FEFF (zero-width no-break
* space / BOM). Note: stripping U+200D (ZWJ) splits multi-person emoji into
* their parts — safety over ligature aesthetics.
* 3. Trim leading/trailing whitespace (mirrors web `title.trim() || null`,
* public/tabs.ts:626 — the caller treats "" as "no title").
* 4. Truncate to [Tunables.TITLE_MAX_LENGTH] grapheme clusters (an emoji flood
* caps at 256 visible glyphs and no glyph is ever split).
*
* Pure + idempotent: sanitize(sanitize(x)) == sanitize(x).
*/
public object TitleSanitizer {
/** Zero-width / bidi code-point ranges stripped on top of the control set. */
private val strippedRanges: List<IntRange> = listOf(
0x200B..0x200F, // zero-width space/non-joiner/joiner, LRM, RLM
0x202A..0x202E, // bidi embeddings, pops and overrides (incl. RLO)
0x2066..0x2069, // bidi isolates (LRI/RLI/FSI/PDI)
0xFEFF..0xFEFF, // zero-width no-break space / BOM
)
public fun sanitize(raw: String): String {
val kept = StringBuilder(raw.length)
var i = 0
while (i < raw.length) {
val cp = Character.codePointAt(raw, i)
if (!isDisallowed(cp)) kept.appendCodePoint(cp)
i += Character.charCount(cp)
}
val trimmed = kept.toString().trim()
return truncateToGraphemes(trimmed, Tunables.TITLE_MAX_LENGTH)
}
private fun isDisallowed(codePoint: Int): Boolean {
// Character.CONTROL (Cc) covers C0 (U+0000-001F), DEL (U+007F) and C1
// (U+0080-009F) in one authoritative check — the iOS `.control` analogue.
if (Character.getType(codePoint) == Character.CONTROL.toInt()) return true
return strippedRanges.any { codePoint in it }
}
/**
* Keep the first [max] grapheme clusters (extended grapheme boundaries, the
* Swift `Character` analogue) so an astral glyph or emoji sequence is never
* split mid-cluster. `max <= 0` yields "".
*/
private fun truncateToGraphemes(text: String, max: Int): String {
if (max <= 0) return ""
if (text.isEmpty()) return text
val boundaries = BreakIterator.getCharacterInstance()
boundaries.setText(text)
var count = 0
var boundary = boundaries.next()
while (boundary != BreakIterator.DONE) {
count++
if (count == max) return text.substring(0, boundary)
boundary = boundaries.next()
}
return text // fewer than `max` clusters → whole string
}
}

View File

@@ -0,0 +1,80 @@
package wang.yaojia.webterm.session
/**
* Pure unread-watermark bookkeeping for the session switcher (A6; ports iOS
* `SessionCore.UnreadLedger`).
*
* A session shows an unread dot iff the server's `lastOutputAt` snapshot
* (`GET /live-sessions`, optional field) is STRICTLY newer than the local
* last-seen watermark — mirroring the web tab dot (public/tabs.ts `hasActivity`).
*
* Semantics (documented decisions):
* - **Missing watermark = 0**: a session never seen on this device has unseen
* output by definition (`lastOutputAt >= createdAt > 0` server-side), so it
* lights up until first viewed.
* - **`lastOutputAt` null / non-positive → never unread**: null means a pre-P1
* server that does not serialize the field; non-positive is malformed untrusted
* input — no data, no dot (plan §4: never trust, never guess).
* - **Monotonic**: recording an EARLIER time never lowers an existing watermark
* (late/out-of-order callbacks cannot resurrect a dot).
* - **Capped**: at most [MAX_ENTRIES] watermarks, oldest-seen dropped first — the
* App persists this map (DataStore) and sessions are ephemeral, so unbounded
* growth is a slow leak.
*
* Persistence-agnostic pure type: the App layer owns loading / saving
* [watermarks]; this type never does I/O. Session ids are the wire's UUID-v4
* lowercase strings (matching `ServerMessage.Attached.sessionId`).
*/
public class UnreadLedger(watermarks: Map<String, Long> = emptyMap()) {
/** sessionId → last-seen instant (ms since epoch, same clock as `lastOutputAt`). */
public val watermarks: Map<String, Long> = capped(watermarks)
/**
* New ledger with [sessionId] marked seen at [atMs] (monotonic max — an earlier
* timestamp never lowers the stored watermark). The receiver is untouched.
*/
public fun record(sessionId: String, atMs: Long): UnreadLedger {
val existing = watermarks[sessionId]
val next = if (existing != null) maxOf(existing, atMs) else atMs
return UnreadLedger(watermarks + (sessionId to next))
}
/**
* Unread test: strictly newer server output than the local watermark. null /
* non-positive [lastOutputAt] is never unread (see type doc).
*/
public fun isUnread(sessionId: String, lastOutputAt: Long?): Boolean {
if (lastOutputAt == null || lastOutputAt <= 0L) return false
return lastOutputAt > (watermarks[sessionId] ?: 0L)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is UnreadLedger) return false
return watermarks == other.watermarks
}
override fun hashCode(): Int = watermarks.hashCode()
override fun toString(): String = "UnreadLedger(watermarks=$watermarks)"
public companion object {
/** Upper bound on persisted watermarks (oldest dropped beyond it). */
public const val MAX_ENTRIES: Int = 512
/**
* Keep the [MAX_ENTRIES] NEWEST watermarks (deterministic tie-break by
* sessionId string so equal timestamps cannot flap across runs).
*/
private fun capped(watermarks: Map<String, Long>): Map<String, Long> {
if (watermarks.size <= MAX_ENTRIES) return watermarks
return watermarks.entries
.sortedWith(
compareByDescending<Map.Entry<String, Long>> { it.value }
.thenBy { it.key },
)
.take(MAX_ENTRIES)
.associate { it.key to it.value }
}
}
}

View File

@@ -0,0 +1,96 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.TimelineEvent
/** Ports iOS `AwayDigestTests` — once-per-reconnect fold, all-zero suppressed. */
class AwayDigestTest {
private fun event(at: Long, cls: String, label: String = cls): TimelineEvent =
TimelineEvent(at = at, eventClass = cls, label = label)
@Test
fun emptyDigestReportsEmpty() {
assertTrue(AwayDigest.EMPTY.isEmpty)
assertEquals(0, AwayDigest.EMPTY.toolRuns)
assertTrue(AwayDigest.EMPTY.recent.isEmpty())
}
@Test
fun noEventsReducesToEmpty() {
assertEquals(AwayDigest.EMPTY, AwayDigest.reduce(emptyList(), sinceMs = 100, limit = 5))
}
@Test
fun eventsStrictlyBeforeSinceAreExcluded() {
val events = listOf(event(at = 99, cls = "tool"))
assertEquals(AwayDigest.EMPTY, AwayDigest.reduce(events, sinceMs = 100, limit = 5))
}
@Test
fun anEventAtTheSinceInstantStillCounts() {
val events = listOf(event(at = 100, cls = "tool"))
val digest = AwayDigest.reduce(events, sinceMs = 100, limit = 5)
assertEquals(1, digest.toolRuns)
assertFalse(digest.isEmpty)
}
@Test
fun countsAndFlagsAreFoldedPerClass() {
val events = listOf(
event(at = 100, cls = "tool"),
event(at = 101, cls = "tool"),
event(at = 102, cls = "waiting"),
event(at = 103, cls = "done"),
event(at = 104, cls = "stuck"),
event(at = 105, cls = "user"),
)
val digest = AwayDigest.reduce(events, sinceMs = 100, limit = 10)
assertEquals(2, digest.toolRuns)
assertEquals(1, digest.waitingCount)
assertTrue(digest.sawDone)
assertTrue(digest.sawStuck)
}
@Test
fun recentIsSortedOldestToNewestAndTruncatedToNewest() {
// Deliberately out of order on input.
val events = listOf(
event(at = 300, cls = "tool", label = "c"),
event(at = 100, cls = "tool", label = "a"),
event(at = 200, cls = "tool", label = "b"),
)
val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 2)
// limit=2 keeps the two NEWEST, still oldest→newest ordered.
assertEquals(listOf("b", "c"), digest.recent.map { it.label })
}
@Test
fun nonPositiveLimitKeepsNoRecentEntries() {
val events = listOf(event(at = 100, cls = "tool"))
val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 0)
assertTrue(digest.recent.isEmpty())
assertEquals(1, digest.toolRuns) // counts still fold
}
@Test
fun onlyUserEventsWithNoRecentSuppressesTheDigest() {
// away events exist but none are tool/waiting/done/stuck AND none kept →
// equals EMPTY → the UI suppresses it (all-zero suppressed).
val events = listOf(event(at = 100, cls = "user"), event(at = 101, cls = "user"))
val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 0)
assertTrue(digest.isEmpty)
assertEquals(AwayDigest.EMPTY, digest)
}
@Test
fun userOnlyEventsWithRecentAreNotSuppressed() {
val events = listOf(event(at = 100, cls = "user", label = "typed"))
val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 5)
assertFalse(digest.isEmpty) // recent non-empty even though counters are zero
assertEquals(listOf("typed"), digest.recent.map { it.label })
}
}

View File

@@ -0,0 +1,125 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
/** Ports iOS `GateStateTests` — the two-line epoch stale-guard (security-load-bearing). */
class GateTrackerTest {
@Test
fun initialHoldsNoGateAndDecidesNothing() {
val t = GateTracker.INITIAL
assertNull(t.current)
assertFalse(t.canDecide(epoch = 0))
assertFalse(t.canDecide(epoch = 1))
}
@Test
fun risingEdgeMintsEpochOne() {
val t = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "Bash")
val gate = t.current!!
assertEquals(GateKind.TOOL, gate.kind)
assertEquals("Bash", gate.detail)
assertEquals(1, gate.epoch)
assertTrue(t.canDecide(epoch = 1))
}
@Test
fun nullGateWhilePendingIsTreatedAsTool() {
val t = GateTracker.INITIAL.reduce(pending = true, gate = null, detail = null)
assertEquals(GateKind.TOOL, t.current!!.kind)
}
@Test
fun sustainedPendingKeepsEpochAndRefreshesKindAndDetail() {
val first = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "Bash")
val second = first.reduce(pending = true, gate = GateKind.PLAN, detail = "ExitPlanMode")
val gate = second.current!!
assertEquals(1, gate.epoch, "epoch must not advance while a gate stays held")
assertEquals(GateKind.PLAN, gate.kind)
assertEquals("ExitPlanMode", gate.detail)
}
@Test
fun fallingEdgeClearsGateButRetainsEpochCounter() {
val held = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = null)
val lifted = held.reduce(pending = false, gate = null, detail = null)
assertNull(lifted.current)
assertFalse(lifted.canDecide(epoch = 1))
// Next rising edge must NOT reuse epoch 1 — the counter is retained.
val next = lifted.reduce(pending = true, gate = GateKind.TOOL, detail = null)
assertEquals(2, next.current!!.epoch)
}
@Test
fun staleDecisionAgainstAResolvedGateIsDropped() {
// gate #1 held at epoch 1, then resolved, then gate #2 rises at epoch 2.
val gate1 = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = null)
val resolved = gate1.reduce(pending = false, gate = null, detail = null)
val gate2 = resolved.reduce(pending = true, gate = GateKind.TOOL, detail = null)
assertEquals(2, gate2.current!!.epoch)
assertFalse(gate2.canDecide(epoch = 1), "a slow tap for gate #1 must never approve gate #2")
assertTrue(gate2.canDecide(epoch = 2))
}
@Test
fun canDecideIsFalseWhenNoGateIsHeld() {
val lifted = GateTracker.INITIAL
.reduce(pending = true, gate = GateKind.TOOL, detail = null)
.reduce(pending = false, gate = null, detail = null)
assertFalse(lifted.canDecide(epoch = 1))
}
@Test
fun equalityFoldsCurrentAndEpoch() {
val a = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "x")
val b = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "x")
assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
}
@Test
fun planGateOffersThreeWayAffordances() {
val gate = GateState(kind = GateKind.PLAN, detail = null, epoch = 1)
assertEquals(
listOf(
GateState.Affordance.APPROVE_AUTO,
GateState.Affordance.APPROVE_REVIEW,
GateState.Affordance.KEEP_PLANNING,
),
gate.affordances,
)
}
@Test
fun toolGateOffersTwoWayAffordances() {
val gate = GateState(kind = GateKind.TOOL, detail = null, epoch = 1)
assertEquals(
listOf(GateState.Affordance.APPROVE, GateState.Affordance.REJECT),
gate.affordances,
)
}
@Test
fun affordanceClientMessagesMatchTheWebThreeWayContract() {
assertEquals(
ClientMessage.Approve(ApproveMode.ACCEPT_EDITS),
GateState.Affordance.APPROVE_AUTO.clientMessage,
)
assertEquals(
ClientMessage.Approve(ApproveMode.DEFAULT),
GateState.Affordance.APPROVE_REVIEW.clientMessage,
)
assertEquals(ClientMessage.Reject, GateState.Affordance.KEEP_PLANNING.clientMessage)
assertEquals(ClientMessage.Approve(null), GateState.Affordance.APPROVE.clientMessage)
assertEquals(ClientMessage.Reject, GateState.Affordance.REJECT.clientMessage)
}
}

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Test
/**
* Guards the byte-for-byte contract with `public/keybar.ts` `KEY_MAP`.
*
* [WEB_KEY_MAP] is the KEY_MAP from public/keybar.ts, transcribed with the web's
* `\xNN` escapes rebuilt from explicit hex code points (`Char(0x1B)` = ESC = web
* `\x1b`, etc.). If keybar.ts changes a byte and KeyByteMap is not updated in
* lockstep, [bytesMatchKeybarKeyMapExactly] fails.
*/
class KeyByteMapTest {
private val esc = Char(0x1B).toString()
/** webName (keybar.ts KEY_MAP property) → byte string, in keybar.ts order. */
private val WEB_KEY_MAP: Map<String, String> = linkedMapOf(
"esc" to esc, // '\x1b'
"escEsc" to esc + esc, // '\x1b\x1b'
"shiftTab" to esc + "[Z", // '\x1b[Z'
"arrowUp" to esc + "[A", // '\x1b[A'
"arrowDown" to esc + "[B", //'\x1b[B'
"arrowLeft" to esc + "[D", //'\x1b[D'
"arrowRight" to esc + "[C", //'\x1b[C'
"enter" to "\r", // '\r'
"ctrlC" to Char(0x03).toString(), // '\x03'
"ctrlR" to Char(0x12).toString(), // '\x12'
"ctrlO" to Char(0x0F).toString(), // '\x0f'
"ctrlL" to Char(0x0C).toString(), // '\x0c'
"ctrlT" to Char(0x14).toString(), // '\x14'
"ctrlB" to Char(0x02).toString(), // '\x02'
"ctrlD" to Char(0x04).toString(), // '\x04'
"tab" to "\t", // '\t'
"slash" to "/", // '/'
)
@Test
fun bytesMatchKeybarKeyMapExactly() {
for (key in KeyByteMap.Key.entries) {
val expected = WEB_KEY_MAP[key.webName]
?: error("no keybar.ts KEY_MAP entry for webName='${key.webName}'")
assertEquals(expected, KeyByteMap.bytes(key), "byte mismatch for ${key.name}")
}
}
@Test
fun everyKeybarKeyIsCoveredAndNothingExtra() {
assertEquals(WEB_KEY_MAP.keys, KeyByteMap.Key.entries.map { it.webName }.toSet())
assertEquals(WEB_KEY_MAP.size, KeyByteMap.Key.entries.size)
}
@Test
fun declarationOrderMirrorsTheWebTable() {
assertEquals(WEB_KEY_MAP.keys.toList(), KeyByteMap.Key.entries.map { it.webName })
}
@Test
fun enterIsCarriageReturnNotLinefeed() {
assertEquals("\r", KeyByteMap.bytes(KeyByteMap.Key.ENTER))
assertNotEquals("\n", KeyByteMap.bytes(KeyByteMap.Key.ENTER))
}
@Test
fun escIsASingleByte() {
assertEquals(1, KeyByteMap.bytes(KeyByteMap.Key.ESC).length)
assertEquals(0x1B, KeyByteMap.bytes(KeyByteMap.Key.ESC)[0].code)
}
}

View File

@@ -0,0 +1,121 @@
package wang.yaojia.webterm.session
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeConnectionPinger
import wang.yaojia.webterm.wire.Tunables
class PingSchedulerTest {
/** Fire exactly one ping interval on the virtual clock (mirrors the repo's `tick` helper). */
private fun TestScope.advanceOneInterval() {
testScheduler.advanceTimeBy(Tunables.PING_INTERVAL)
testScheduler.runCurrent()
}
@Test
fun `sends one ping per interval on virtual time`() = runTest {
val pinger = FakeConnectionPinger() // defaultPong = true -> every ping answers
val scheduler = PingScheduler()
val job = launch { scheduler.run { pinger.ping() } }
assertEquals(0, pinger.pingCallCount) // nothing before the first interval elapses
advanceOneInterval()
assertEquals(1, pinger.pingCallCount)
advanceOneInterval()
assertEquals(2, pinger.pingCallCount)
job.cancel()
}
@Test
fun `two consecutive missed pongs declare the connection lost`() = runTest {
val pinger = FakeConnectionPinger()
pinger.scriptPongs(false, false) // the first two pings both miss
val scheduler = PingScheduler()
var outcome: PingScheduler.Outcome? = null
launch { outcome = scheduler.run { pinger.ping() } }
advanceOneInterval() // miss #1 — tolerated
assertNull(outcome)
advanceOneInterval() // miss #2 — dead
assertEquals(PingScheduler.Outcome.CONNECTION_LOST, outcome)
}
@Test
fun `an answered pong resets the consecutive miss counter so isolated misses survive`() = runTest {
val pinger = FakeConnectionPinger()
// miss, pong, miss, pong, miss, pong — never two misses in a row.
pinger.scriptPongs(false, true, false, true, false, true)
val scheduler = PingScheduler()
var outcome: PingScheduler.Outcome? = null
val job = launch { outcome = scheduler.run { pinger.ping() } }
repeat(6) { advanceOneInterval() }
assertNull(outcome) // three isolated misses, each reset — still alive
assertEquals(6, pinger.pingCallCount)
job.cancel()
}
@Test
fun `cancelling the run job propagates cancellation instead of returning a value`() = runTest {
val pinger = FakeConnectionPinger() // always pong -> the loop never ends on its own
val scheduler = PingScheduler()
var returned: PingScheduler.Outcome? = null
var completedNormally = false
val job = launch {
returned = scheduler.run { pinger.ping() }
completedNormally = true // only reached if run() swallowed cancellation and returned
}
advanceOneInterval() // one ping fires; the loop is now suspended on the next delay
assertEquals(1, pinger.pingCallCount)
job.cancel()
job.join()
// The fix: CancellationException propagates out of run() — it does NOT resume the loop and
// return an Outcome. Swallowing (the old bug) would set both of these.
assertTrue(job.isCancelled, "cancellation must propagate out of run()")
assertFalse(completedNormally, "run() must not resume and return after being cancelled")
assertNull(returned, "run() must not swallow cancellation into an Outcome value")
// No further pings after cancellation, even as virtual time marches on.
testScheduler.advanceTimeBy(Tunables.PING_INTERVAL * 3)
testScheduler.runCurrent()
assertEquals(1, pinger.pingCallCount)
}
@Test
fun `a custom interval paces the pings`() = runTest {
val pinger = FakeConnectionPinger()
val scheduler = PingScheduler(interval = Tunables.PING_INTERVAL * 2)
val job = launch { scheduler.run { pinger.ping() } }
// One default interval is NOT enough for the doubled period.
testScheduler.advanceTimeBy(Tunables.PING_INTERVAL)
testScheduler.runCurrent()
assertEquals(0, pinger.pingCallCount)
// A second default interval reaches the custom period.
testScheduler.advanceTimeBy(Tunables.PING_INTERVAL)
testScheduler.runCurrent()
assertEquals(1, pinger.pingCallCount)
job.cancel()
}
}

View File

@@ -0,0 +1,102 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.seconds
class ReconnectMachineTest {
private fun scheduledDelay(effect: ReconnectMachine.Effect): kotlin.time.Duration {
assertTrue(effect is ReconnectMachine.Effect.ScheduleRetry)
return (effect as ReconnectMachine.Effect.ScheduleRetry).after
}
@Test
fun `disconnect ladder climbs 1 2 4 8 16 30 and then caps at 30`() {
var machine = ReconnectMachine.initial
val scheduled = buildList {
repeat(7) {
val (next, effect) = machine.reduce(ReconnectMachine.Input.Disconnected)
add(scheduledDelay(effect))
machine = next
}
}
assertEquals(
listOf(1, 2, 4, 8, 16, 30, 30).map { it.seconds },
scheduled,
)
}
@Test
fun `a successful connect resets the backoff ladder to the initial rung`() {
var machine = ReconnectMachine.initial
repeat(3) { machine = machine.reduce(ReconnectMachine.Input.Disconnected).first } // climb the ladder
val (afterConnect, connectEffect) = machine.reduce(ReconnectMachine.Input.Connected)
assertEquals(ReconnectMachine.Effect.None, connectEffect)
assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, afterConnect.nextRetryDelay)
// The next disconnect schedules the initial 1 s rung again.
val (_, effect) = afterConnect.reduce(ReconnectMachine.Input.Disconnected)
assertEquals(1.seconds, scheduledDelay(effect))
}
@Test
fun `foregrounding connects now WITHOUT resetting the backoff ladder`() {
// Two disconnects: last scheduled 2 s, stored next rung is 4 s.
var machine = ReconnectMachine.initial
machine = machine.reduce(ReconnectMachine.Input.Disconnected).first
machine = machine.reduce(ReconnectMachine.Input.Disconnected).first
val (afterForeground, effect) = machine.reduce(ReconnectMachine.Input.Foregrounded)
assertEquals(ReconnectMachine.Effect.ConnectNow, effect)
// Ladder NOT reset: the next disconnect still schedules 4 s (not 1 s).
val (_, disconnectEffect) = afterForeground.reduce(ReconnectMachine.Input.Disconnected)
assertEquals(4.seconds, scheduledDelay(disconnectEffect))
}
@Test
fun `retry timer fired and user retry both connect now and preserve the snapshot`() {
val machine = ReconnectMachine.initial.reduce(ReconnectMachine.Input.Disconnected).first
val (afterTimer, timerEffect) = machine.reduce(ReconnectMachine.Input.RetryTimerFired)
assertEquals(ReconnectMachine.Effect.ConnectNow, timerEffect)
assertEquals(machine, afterTimer)
val (afterUser, userEffect) = machine.reduce(ReconnectMachine.Input.UserRetry)
assertEquals(ReconnectMachine.Effect.ConnectNow, userEffect)
assertEquals(machine, afterUser)
}
@Test
fun `the session id is carried through disconnect connect and foreground`() {
val machine = ReconnectMachine.initial.withSessionId("abc-123")
assertEquals("abc-123", machine.sessionId)
// Survives a disconnect (entering back-off) ...
val afterDisconnect = machine.reduce(ReconnectMachine.Input.Disconnected).first
assertEquals("abc-123", afterDisconnect.sessionId)
// ... a successful reconnect that resets ONLY the delay ladder ...
val afterConnect = afterDisconnect.reduce(ReconnectMachine.Input.Connected).first
assertEquals("abc-123", afterConnect.sessionId)
assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, afterConnect.nextRetryDelay)
// ... and a foreground connect-now.
val afterForeground = afterConnect.reduce(ReconnectMachine.Input.Foregrounded).first
assertEquals("abc-123", afterForeground.sessionId)
}
@Test
fun `initial seeds the ladder with no id and withSessionId carries one`() {
assertEquals(null, ReconnectMachine.initial.sessionId)
assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, ReconnectMachine.initial.nextRetryDelay)
val seeded = ReconnectMachine.initial.withSessionId("sess-1")
assertEquals("sess-1", seeded.sessionId)
assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, seeded.nextRetryDelay)
}
}

View File

@@ -0,0 +1,60 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.WireConstants
import kotlin.time.Duration.Companion.seconds
class SessionEventTest {
@Test
fun `connection wraps a lifecycle state by value`() {
val event: SessionEvent = Connection(ConnectionState.Connected)
assertEquals(Connection(ConnectionState.Connected), event)
assertNotEquals(Connection(ConnectionState.Connecting), event)
}
@Test
fun `reconnecting carries the attempt number and next delay`() {
val state = ConnectionState.Reconnecting(attempt = 3, next = 8.seconds)
assertEquals(3, state.attempt)
assertEquals(8.seconds, state.next)
}
@Test
fun `failed is the non-retryable terminal state carrying its reason`() {
val state = ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE)
assertEquals(FailureReason.REPLAY_TOO_LARGE, state.reason)
}
@Test
fun `exited defaults its reason to null and surfaces the spawn-failure code`() {
assertNull(Exited(code = 0).reason)
val spawnFailure = Exited(code = WireConstants.SPAWN_FAILED_EXIT_CODE, reason = "spawn failed")
assertEquals(-1, spawnFailure.code)
assertEquals("spawn failed", spawnFailure.reason)
}
@Test
fun `adopted and output carry their payloads by value`() {
assertEquals(Adopted("id-1"), Adopted("id-1"))
assertNotEquals(Adopted("id-1"), Adopted("id-2"))
assertEquals(Output("bytes"), Output("bytes"))
assertNotEquals(Output("a"), Output("b"))
}
@Test
fun `every A5 lifecycle subtype is a SessionEvent`() {
val events: List<SessionEvent> = listOf(
Connection(ConnectionState.Closed),
Adopted("id"),
Output("data"),
Exited(code = 0),
)
assertEquals(4, events.size)
}
}

View File

@@ -0,0 +1,103 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.text.BreakIterator
import wang.yaojia.webterm.wire.Tunables
/** Ports iOS `TitleSanitizerTests` — control/bidi/zero-width stripping + grapheme cap. */
class TitleSanitizerTest {
/** Build a String from raw code points (avoids editor-fragile literals). */
private fun cp(vararg codePoints: Int): String =
buildString { codePoints.forEach { appendCodePoint(it) } }
private fun graphemeCount(s: String): Int {
val bi = BreakIterator.getCharacterInstance()
bi.setText(s)
var count = 0
while (bi.next() != BreakIterator.DONE) count++
return count
}
@Test
fun plainTitlePassesThroughUnchanged() {
assertEquals("web-terminal", TitleSanitizer.sanitize("web-terminal"))
}
@Test
fun stripsC0ControlCharacters() {
// ESC (0x1B) + BEL (0x07) embedded in "a<ESC>b<BEL>c".
val raw = "a" + cp(0x1B) + "b" + cp(0x07) + "c"
assertEquals("abc", TitleSanitizer.sanitize(raw))
}
@Test
fun stripsDelAndC1ControlCharacters() {
// DEL (0x7F) and a C1 control (0x9B — CSI).
val raw = "x" + cp(0x7F) + "y" + cp(0x9B) + "z"
assertEquals("xyz", TitleSanitizer.sanitize(raw))
}
@Test
fun stripsZeroWidthAndBidiSpoofingCharacters() {
val raw = "a" +
cp(0x200B) + // zero-width space
cp(0x200D) + // zero-width joiner
cp(0x200E) + // LRM
cp(0x202E) + // RLO override
cp(0x2066) + // LRI isolate
cp(0xFEFF) + // BOM
"b"
assertEquals("ab", TitleSanitizer.sanitize(raw))
}
@Test
fun trimsSurroundingWhitespace() {
assertEquals("hello", TitleSanitizer.sanitize(" hello \t "))
}
@Test
fun emptyAfterSanitizationYieldsEmptyString() {
val raw = cp(0x200B) + " " + cp(0x1B)
assertEquals("", TitleSanitizer.sanitize(raw))
assertEquals("", TitleSanitizer.sanitize(""))
}
@Test
fun truncatesToTitleMaxLengthGraphemes() {
val raw = "a".repeat(Tunables.TITLE_MAX_LENGTH + 50)
val out = TitleSanitizer.sanitize(raw)
assertEquals(Tunables.TITLE_MAX_LENGTH, out.length)
}
@Test
fun emojiFloodCapsAtMaxGlyphsWithoutSplittingASurrogatePair() {
// Each 😀 (U+1F600) is one grapheme = one surrogate pair (2 UTF-16 units).
val emoji = cp(0x1F600)
val raw = emoji.repeat(Tunables.TITLE_MAX_LENGTH + 40)
val out = TitleSanitizer.sanitize(raw)
assertEquals(Tunables.TITLE_MAX_LENGTH, graphemeCount(out), "capped at 256 visible glyphs")
assertEquals(Tunables.TITLE_MAX_LENGTH * 2, out.length, "no half-emoji: even UTF-16 length")
assertFalse(out.last().isHighSurrogate(), "must not end on a lone high surrogate")
}
@Test
fun sanitizeIsIdempotent() {
val raw = " " + cp(0x202E) + "danger" + cp(0x200B) + " " + cp(0x1B) + " "
val once = TitleSanitizer.sanitize(raw)
val twice = TitleSanitizer.sanitize(once)
assertEquals(once, twice)
}
@Test
fun keepsNonAsciiVisibleText() {
// CJK + accented text must survive (only control/bidi/zero-width are stripped).
val raw = "会话 café"
assertEquals("会话 café", TitleSanitizer.sanitize(raw))
assertTrue(TitleSanitizer.sanitize(raw).isNotEmpty())
}
}

View File

@@ -0,0 +1,75 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/** Ports iOS `UnreadLedgerTests` — watermark monotonicity, cap, untrusted-input guards. */
class UnreadLedgerTest {
private val sid = "11111111-1111-4111-8111-111111111111"
@Test
fun aNeverSeenSessionIsUnreadWhenServerHasOutput() {
val ledger = UnreadLedger()
assertTrue(ledger.isUnread(sid, lastOutputAt = 1))
}
@Test
fun nullLastOutputAtIsNeverUnread() {
assertFalse(UnreadLedger().isUnread(sid, lastOutputAt = null))
}
@Test
fun nonPositiveLastOutputAtIsNeverUnread() {
val ledger = UnreadLedger()
assertFalse(ledger.isUnread(sid, lastOutputAt = 0))
assertFalse(ledger.isUnread(sid, lastOutputAt = -5))
}
@Test
fun unreadRequiresStrictlyNewerThanWatermark() {
val ledger = UnreadLedger().record(sid, atMs = 1_000)
assertFalse(ledger.isUnread(sid, lastOutputAt = 999), "older is read")
assertFalse(ledger.isUnread(sid, lastOutputAt = 1_000), "equal is read")
assertTrue(ledger.isUnread(sid, lastOutputAt = 1_001), "newer is unread")
}
@Test
fun recordIsMonotonicAndDoesNotLowerAnExistingWatermark() {
val ledger = UnreadLedger().record(sid, atMs = 1_000).record(sid, atMs = 500)
assertEquals(1_000L, ledger.watermarks[sid])
assertFalse(ledger.isUnread(sid, lastOutputAt = 900))
}
@Test
fun recordDoesNotMutateTheReceiver() {
val base = UnreadLedger()
val next = base.record(sid, atMs = 1_000)
assertTrue(base.watermarks.isEmpty())
assertEquals(1_000L, next.watermarks[sid])
}
@Test
fun capKeepsTheNewestEntriesByWatermark() {
// One over the cap; the single OLDEST (smallest value) must be dropped.
val raw = buildMap {
for (i in 0..UnreadLedger.MAX_ENTRIES) { // MAX_ENTRIES + 1 entries
put("session-%04d".format(i), i.toLong())
}
}
val ledger = UnreadLedger(raw)
assertEquals(UnreadLedger.MAX_ENTRIES, ledger.watermarks.size)
assertFalse(ledger.watermarks.containsKey("session-0000"), "oldest (value 0) dropped")
assertTrue(ledger.watermarks.containsKey("session-%04d".format(UnreadLedger.MAX_ENTRIES)))
}
@Test
fun equalityFoldsWatermarks() {
val a = UnreadLedger().record(sid, atMs = 5)
val b = UnreadLedger(mapOf(sid to 5L))
assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
}
}

View File

@@ -0,0 +1,38 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
// google() is only needed by the Android-framework modules (see below).
// It requires the Android Gradle Plugin / SDK, absent in this env.
}
}
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
// Modules must not declare their own repositories.
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
// The version catalog at gradle/libs.versions.toml is auto-registered as `libs`.
}
rootProject.name = "webterm-android"
// ── Pure Kotlin/JVM modules (JVM-unit-testable; the 80% Kover targets) ──────────
// These build with the JVM toolchain only — NO Android SDK required.
include(":wire-protocol")
include(":session-core")
include(":api-client")
include(":client-tls")
include(":test-support")
// ── Android-framework modules ───────────────────────────────────────────────────
// Scaffolded (dirs + build.gradle.kts stubs exist) but NOT included, because this
// environment has NO Android SDK. Their build scripts apply `com.android.*`
// plugins that cannot resolve here.
// TODO(android-sdk): enable when an Android SDK is available.
// include(":app")
// include(":terminal-view")
// include(":host-registry")
// include(":client-tls-android")

View File

@@ -0,0 +1,31 @@
// ─────────────────────────────────────────────────────────────────────────────
// :terminal-view — Android-framework-bound Termux `terminal-emulator` +
// `terminal-view` wrap (RemoteTerminalSession: no local process, WS-driven).
// Mirrors the iOS SwiftTerm host view. Depends ONLY on :wire-protocol.
//
// SCAFFOLD STUB ONLY — COMMENTED OUT in settings.gradle.kts (no Android SDK here).
// TODO(android-sdk): enable when an Android SDK is available.
// ─────────────────────────────────────────────────────────────────────────────
/*
plugins {
id("com.android.library")
alias(libs.plugins.kotlin.android)
}
android {
namespace = "wang.yaojia.webterm.terminalview"
compileSdk = 35
defaultConfig { minSdk = 29 }
}
kotlin { jvmToolchain(17) }
dependencies {
implementation(project(":wire-protocol"))
// Termux VT100/xterm emulator (Apache-2.0) — scope to these two libs ONLY,
// never `termux-shared`/app module (GPLv3). Consumable via JitPack.
// implementation("com.termux:terminal-view:0.118.0")
// implementation("com.termux:terminal-emulator:0.118.0")
}
*/

View File

@@ -0,0 +1,28 @@
// :test-support — shared fakes (FakeTransport / FakeHttpTransport / FakeTimeSource)
// mirroring iOS's TestSupport package. Consumed by other modules' test source sets.
// Pure Kotlin/JVM; depends only on :wire-protocol (+ coroutines for the fakes).
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(17)
}
dependencies {
api(project(":wire-protocol"))
api(libs.kotlinx.coroutines.core)
// Exposed as `api` on purpose: the fakes live in `main` (Owns), and downstream
// modules consume both the fakes AND `TestScope`/virtual-time from their test
// source sets via a single `testImplementation(project(":test-support"))`.
api(libs.kotlinx.coroutines.test)
testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher)
}
tasks.test {
useJUnitPlatform()
}

View File

@@ -0,0 +1,114 @@
package wang.yaojia.webterm.testsupport
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
/**
* Errors [FakeHttpTransport] raises on its own. Android analogue of iOS `FakeHTTPTransportError`;
* only the "you forgot to script this route" case survives the port — the Swift `requestMissingURL`
* / `responseConstructionFailed` cases are unreachable here because [HttpRequest.url] is a non-null
* `String` and [HttpResponse] is a plain data class (no `HTTPURLResponse` construction to fail).
*/
public sealed class FakeHttpTransportError(message: String) : Exception(message) {
/**
* [FakeHttpTransport.send] was called for a route with no queued response — the test forgot to
* script it. Loud and identifying (data class → exact-value assertable), never a silent hang.
*/
public data class NoQueuedResponse(val method: HttpMethod, val url: String) :
FakeHttpTransportError("FakeHttpTransport: no queued response for ${method.name} $url")
}
/**
* In-memory [HttpTransport] double (A3, mirrors iOS `FakeHTTPTransport`). `:api-client` (A8/A9)
* cannot tell it apart from `:transport-okhttp`'s real impl (plan §3.4).
*
* - **Queue responses per route (method + exact url), FIFO**: [queueSuccess] / [queueFailure]. An
* unqueued route throws [FakeHttpTransportError.NoQueuedResponse] immediately.
* - **Record every request verbatim, headers included** — so Origin-iff-guarded tests (plan §4.3
* 铁律) can assert exactly which requests carried the `Origin` header, and query-string tests
* (`staged=1`) can assert the exact url.
*
* Thread-safe: one JVM monitor guards the queues and the recording; every section is synchronous.
*/
public class FakeHttpTransport : HttpTransport {
private data class RouteKey(val method: HttpMethod, val url: String)
private sealed interface QueuedResult {
data class Success(
val status: Int,
val headers: Map<String, String>,
val body: ByteArray,
) : QueuedResult
data class Failure(val error: Throwable) : QueuedResult
}
private val lock = Any()
private val queues = mutableMapOf<RouteKey, ArrayDeque<QueuedResult>>()
private val recorded = mutableListOf<HttpRequest>()
/**
* Every request passed to [send], in order, verbatim (method, url, headers, body) — including
* ones that found no queued response and threw.
*/
public val recordedRequests: List<HttpRequest>
get() = synchronized(lock) { recorded.toList() }
// ── Scripting ────────────────────────────────────────────────────────────────────────
/** Queue one successful response for `method url` (FIFO per route). */
public fun queueSuccess(
method: HttpMethod = HttpMethod.GET,
url: String,
status: Int = DEFAULT_OK_STATUS,
headers: Map<String, String> = emptyMap(),
body: ByteArray = EMPTY_BODY,
) {
enqueue(RouteKey(method, url), QueuedResult.Success(status, headers, body))
}
/**
* Queue one transport-level failure for `method url` (e.g. connection-refused →
* `PairingError.hostUnreachable` classification tests, A9). The error is re-thrown by [send].
*/
public fun queueFailure(
method: HttpMethod = HttpMethod.GET,
url: String,
error: Throwable,
) {
enqueue(RouteKey(method, url), QueuedResult.Failure(error))
}
// ── HttpTransport ────────────────────────────────────────────────────────────────────
override suspend fun send(request: HttpRequest): HttpResponse {
val next: QueuedResult =
synchronized(lock) {
recorded.add(request)
val key = RouteKey(request.method, request.url)
val queue = queues[key]
if (queue.isNullOrEmpty()) {
throw FakeHttpTransportError.NoQueuedResponse(request.method, request.url)
}
queue.removeFirst()
}
return when (next) {
is QueuedResult.Failure -> throw next.error
is QueuedResult.Success -> HttpResponse(next.status, next.body, next.headers)
}
}
// ── Internals ──────────────────────────────────────────────────────────────────────
private fun enqueue(key: RouteKey, result: QueuedResult) {
synchronized(lock) { queues.getOrPut(key) { ArrayDeque() }.addLast(result) }
}
public companion object {
/** Default scripted success status (matches iOS `defaultOKStatus`). */
public const val DEFAULT_OK_STATUS: Int = 200
private val EMPTY_BODY: ByteArray = ByteArray(0)
}
}

View File

@@ -0,0 +1,62 @@
package wang.yaojia.webterm.testsupport
import wang.yaojia.webterm.wire.ConnectionPinger
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.PingableConnection
import wang.yaojia.webterm.wire.PingableTermTransport
/**
* A controllable [ConnectionPinger] double. `PingScheduler` (A5) calls [ping] once per interval and
* counts misses against `Tunables.PONG_MISS_LIMIT`; this fake lets a test decide, deterministically,
* which pings answer and which are dropped.
*
* Scripted results are consumed FIFO; once exhausted [ping] returns [defaultPong]. `true` = pong
* arrived in time, `false` = a miss.
*/
public class FakeConnectionPinger(
private val defaultPong: Boolean = true,
) : ConnectionPinger {
private val lock = Any()
private val scriptedPongs = ArrayDeque<Boolean>()
private var callCount = 0
/** Total [ping] calls so far — for `PingScheduler` assertions. */
public val pingCallCount: Int
get() = synchronized(lock) { callCount }
/** Queue pong outcomes consumed FIFO by [ping]; when the queue drains, [ping] uses [defaultPong]. */
public fun scriptPongs(vararg results: Boolean) {
synchronized(lock) { scriptedPongs.addAll(results.toList()) }
}
override suspend fun ping(): Boolean =
synchronized(lock) {
callCount++
scriptedPongs.removeFirstOrNull() ?: defaultPong
}
}
/**
* A [FakeTermTransport] that ALSO implements [PingableTermTransport] (A3, mirrors iOS's
* `transport as? any PingableTermTransport` path). Use this when a test wants `SessionEngine`/
* `PingScheduler` to drive keep-alive through [connectPingable]; use the plain [FakeTermTransport]
* when the closure-driven ping path is under test instead.
*
* The [pinger] is shared across every connection this transport opens, so a test can script pong
* outcomes up front and inspect [FakeConnectionPinger.pingCallCount] after — reconnects reuse it,
* which matches "one keep-alive policy per transport" for assertion simplicity.
*/
public class FakePingableTermTransport(
public val pinger: FakeConnectionPinger = FakeConnectionPinger(),
) : FakeTermTransport(), PingableTermTransport {
/** How many times [connectPingable] (vs the plain [connect]) was taken — for path assertions. */
public var connectPingableCount: Int = 0
private set
override suspend fun connectPingable(endpoint: HostEndpoint): PingableConnection {
val connection = connect(endpoint)
synchronized(this) { connectPingableCount++ }
return PingableConnection(connection = connection, pinger = pinger)
}
}

View File

@@ -0,0 +1,186 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.TermTransport
import wang.yaojia.webterm.wire.TransportConnection
/**
* Errors [FakeTermTransport] raises on its own (sealed so tests can match exact values, e.g.
* `assertFailsWith<FakeTransportError.SendAfterClose>`). Android analogue of iOS
* `FakeTransportError` (there an `Equatable` enum).
*/
public sealed class FakeTransportError(message: String) : Exception(message) {
/** Default error thrown by a scripted connect failure. */
public data object ScriptedConnectFailure :
FakeTransportError("FakeTermTransport: scripted connect failure")
/**
* `send` was called on a connection that already terminated (client `close()`, server finish,
* or server error) — mirrors iOS `FakeTransportError.sendAfterClose`.
*/
public data object SendAfterClose :
FakeTransportError("FakeTermTransport: send on a terminated connection")
}
/**
* In-memory [TermTransport] double (A3, mirrors iOS `FakeTransport`). `SessionEngine` (A14) cannot
* tell it apart from `:transport-okhttp`'s real impl — same frozen contract (plan §3.2).
*
* Capabilities:
* - **Scripted connect failures**: [scriptConnectFailure] queues errors thrown by subsequent
* [connect] calls, FIFO (reconnect/backoff tests).
* - **Manual frame injection** (the server side of the wire): [emit] / [emitError] /
* [finishFrames] drive the latest connection's [TransportConnection.frames] stream. With no live
* connection the event is queued and flushed, in submission order, into the next successful
* [connect] — so a test can script "attach then replay" up front and nothing is silently dropped.
* - **Recording**: every [connect] attempt (scripted failures included), every sent frame (per
* connection and flattened), and every [TransportConnection.close] call.
*
* Thread-safe by construction: all mutable state is guarded by one JVM monitor and every critical
* section is synchronous (no suspension inside `synchronized`), so it is safe under `runTest`
* virtual time and under real multi-thread dispatch alike. Frames buffer unboundedly
* ([Channel.UNLIMITED]) until the consumer collects — zero real-time sleeps.
*
* `open` so the pingable variant ([FakePingableTermTransport]) can reuse [connect]. NOTE: this base
* intentionally does NOT implement `PingableTermTransport` — matching the frozen contract note in
* `TermTransport.kt`, so engine tests can exercise the closure-driven ping path. Use
* [FakePingableTermTransport] when a test wants the `connectPingable` path instead.
*/
public open class FakeTermTransport : TermTransport {
private sealed interface ServerEvent {
data class Frame(val frame: String) : ServerEvent
data class Failure(val error: Throwable) : ServerEvent
data object Finish : ServerEvent
}
private class ConnectionState {
val channel: Channel<String> = Channel(Channel.UNLIMITED)
// consumeAsFlow() is single-collection by contract, matching "one live WS, consumed once".
val framesFlow: Flow<String> = channel.consumeAsFlow()
val sentFrames: MutableList<String> = mutableListOf()
var isTerminated: Boolean = false
}
private val lock = Any()
private val scriptedConnectFailures = ArrayDeque<Throwable>()
private val pendingEvents = ArrayDeque<ServerEvent>()
private val connections = mutableListOf<ConnectionState>()
private val connectAttemptsInternal = mutableListOf<HostEndpoint>()
private var closeCount = 0
/** Every endpoint [connect] was called with, in order — scripted failures included. */
public val connectAttempts: List<HostEndpoint>
get() = synchronized(lock) { connectAttemptsInternal.toList() }
/** Total [TransportConnection.close] calls across all connections (double-close included). */
public val closeCallCount: Int
get() = synchronized(lock) { closeCount }
/** All frames sent by the client, flattened in connection order. */
public val sentFrames: List<String>
get() = synchronized(lock) { connections.flatMap { it.sentFrames.toList() } }
/**
* Frames sent by the client, grouped per successful connection (reconnect tests assert the
* re-attach frame landed on connection index 1, not 0).
*/
public val sentFramesByConnection: List<List<String>>
get() = synchronized(lock) { connections.map { it.sentFrames.toList() } }
// ── Scripting ────────────────────────────────────────────────────────────────────────
/** Queue an error for the next [connect] call (FIFO across calls). */
public fun scriptConnectFailure(
error: Throwable = FakeTransportError.ScriptedConnectFailure,
) {
synchronized(lock) { scriptedConnectFailures.addLast(error) }
}
// ── TermTransport ────────────────────────────────────────────────────────────────────
override suspend fun connect(endpoint: HostEndpoint): TransportConnection {
val index: Int
val state: ConnectionState
synchronized(lock) {
connectAttemptsInternal.add(endpoint)
scriptedConnectFailures.removeFirstOrNull()?.let { throw it }
state = ConnectionState()
index = connections.size
connections.add(state)
// Flush anything scripted before this connection existed, in submission order.
while (pendingEvents.isNotEmpty()) {
apply(pendingEvents.removeFirst(), state)
}
}
return object : TransportConnection {
override val frames: Flow<String> = state.framesFlow
override suspend fun send(frame: String): Unit = recordSend(frame, index)
override suspend fun close(): Unit = recordClose(index)
}
}
// ── Manual injection (server side of the wire) ─────────────────────────────────────────
/** Yield one server JSON text frame into the latest connection (or queue for the next connect). */
public fun emit(frame: String) {
deliver(ServerEvent.Frame(frame))
}
/** Terminate the latest connection's stream with [error] (the "flow throws" half of the contract). */
public fun emitError(error: Throwable) {
deliver(ServerEvent.Failure(error))
}
/** Finish the latest connection's stream cleanly (the "flow completes normally" half). */
public fun finishFrames() {
deliver(ServerEvent.Finish)
}
// ── Internals (all callers hold, or take, the monitor) ─────────────────────────────────
private fun deliver(event: ServerEvent) {
synchronized(lock) {
val live = connections.lastOrNull()?.takeUnless { it.isTerminated }
if (live == null) {
pendingEvents.addLast(event)
} else {
apply(event, live)
}
}
}
private fun apply(event: ServerEvent, state: ConnectionState) {
when (event) {
is ServerEvent.Frame -> state.channel.trySend(event.frame)
is ServerEvent.Failure -> {
state.isTerminated = true
state.channel.close(event.error)
}
ServerEvent.Finish -> {
state.isTerminated = true
state.channel.close()
}
}
}
private fun recordSend(frame: String, index: Int) {
synchronized(lock) {
val state = connections[index]
if (state.isTerminated) throw FakeTransportError.SendAfterClose
state.sentFrames.add(frame)
}
}
private fun recordClose(index: Int) {
synchronized(lock) {
closeCount++
val state = connections[index]
if (state.isTerminated) return
state.isTerminated = true
state.channel.close()
}
}
}

View File

@@ -0,0 +1,74 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.test.TestScope
import kotlin.time.ComparableTimeMark
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlin.time.TestTimeSource
import kotlin.time.TimeSource
/**
* Manually-advanced [TimeSource] (A3, the Android analogue of iOS `FakeClock`). Drives reducers
* that read elapsed time — `PingScheduler`, `AwayDigest`'s fade, telemetry staleness — with ZERO
* real-time waiting: code under test reads a mark and the test moves time with [advance].
*
* Two readable shapes so it fits both reducer styles without inventing A5/A6's clock contract:
* - as a [TimeSource]: inject it and take [TimeSource.markNow]/[ComparableTimeMark.elapsedNow] for
* duration-based logic (ping interval, digest fade);
* - [epochMillis]: an epoch-ms reader for the `at`-timestamp staleness reducers
* (`StatusTelemetry.at` vs `Tunables.TELEMETRY_STALE_TTL_MS`).
*
* Thin wrapper over stdlib [TestTimeSource] (which owns the mark machinery); this class only tracks
* the elapsed offset in parallel so [epochMillis]/[now] can be read. Not tied to the coroutine
* scheduler — see [tick] to advance virtual `delay()` and this clock together in one call.
*/
@OptIn(ExperimentalTime::class)
public class FakeTimeSource(
/** Wall-clock epoch (ms) reported at zero elapsed; [epochMillis] = this + elapsed. */
public val epochBaseMillis: Long = DEFAULT_EPOCH_BASE_MILLIS,
) : TimeSource.WithComparableMarks {
private val backing = TestTimeSource()
private var elapsed: Duration = Duration.ZERO
override fun markNow(): ComparableTimeMark = backing.markNow()
/** Total time advanced since construction. */
public val now: Duration
get() = elapsed
/** Current wall-clock reading in epoch milliseconds ([epochBaseMillis] + [now]). */
public fun epochMillis(): Long = epochBaseMillis + elapsed.inWholeMilliseconds
/** Move time forward (never backward); wakes any mark's [ComparableTimeMark.elapsedNow]. */
public fun advance(by: Duration) {
require(by >= Duration.ZERO) { "FakeTimeSource cannot move backwards (got $by)" }
backing += by
elapsed += by
}
/** `clock += 25.seconds` sugar over [advance]. */
public operator fun plusAssign(by: Duration) {
advance(by)
}
public companion object {
public const val DEFAULT_EPOCH_BASE_MILLIS: Long = 0L
}
}
/**
* Advance BOTH the coroutine virtual clock and a [FakeTimeSource] by [duration] in one call, keeping
* `delay()`-driven scheduling and elapsed-time reads in lockstep. Use inside `runTest` when a
* reducer both suspends on `delay()` AND reads the injected clock:
*
* runTest {
* val clock = FakeTimeSource()
* // ... start the reducer ...
* tick(clock, Tunables.PING_INTERVAL) // fires the delay AND moves the readable clock
* }
*/
public fun TestScope.tick(clock: FakeTimeSource, duration: Duration) {
testScheduler.advanceTimeBy(duration)
testScheduler.runCurrent()
clock.advance(duration)
}

View File

@@ -0,0 +1,75 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.io.IOException
/** Ports iOS `FakeHTTPTransportTests`, plus FIFO/method-scope and failure re-throw. */
class FakeHttpTransportTest {
private companion object {
const val ORIGIN = "http://192.168.1.5:3000"
const val LIST_URL = "http://192.168.1.5:3000/live-sessions"
const val UNQUEUED_URL = "http://192.168.1.5:3000/other"
}
@Test
fun replaysQueuedResponsesRecordsRequestsAndFailsLoudlyWhenUnqueued() = runTest {
// Arrange
val transport = FakeHttpTransport()
transport.queueSuccess(url = LIST_URL, body = "[]".toByteArray())
val request = HttpRequest(
method = HttpMethod.GET,
url = LIST_URL,
headers = mapOf("Origin" to ORIGIN),
)
// Act
val response = transport.send(request)
// Assert: the queued response came back for that route.
assertEquals("[]", String(response.body))
assertEquals(FakeHttpTransport.DEFAULT_OK_STATUS, response.status)
// Assert: the request was recorded verbatim, headers included (Origin-iff-guarded tests).
val recorded = transport.recordedRequests
assertEquals(1, recorded.size)
assertEquals(LIST_URL, recorded[0].url)
assertEquals(ORIGIN, recorded[0].headers["Origin"])
// Assert: an unqueued route throws an explicit, identifying error — and is still recorded.
val thrown = runCatching {
transport.send(HttpRequest(HttpMethod.GET, UNQUEUED_URL))
}.exceptionOrNull()
assertEquals(FakeHttpTransportError.NoQueuedResponse(HttpMethod.GET, UNQUEUED_URL), thrown)
assertEquals(2, transport.recordedRequests.size)
}
@Test
fun queuesAreFifoPerRouteAndMethodScoped() = runTest {
val transport = FakeHttpTransport()
val url = "http://h/live-sessions/abc"
transport.queueSuccess(method = HttpMethod.GET, url = url, body = "first".toByteArray())
transport.queueSuccess(method = HttpMethod.GET, url = url, body = "second".toByteArray())
transport.queueSuccess(method = HttpMethod.DELETE, url = url, status = 204)
assertEquals("first", String(transport.send(HttpRequest(HttpMethod.GET, url)).body))
assertEquals("second", String(transport.send(HttpRequest(HttpMethod.GET, url)).body))
assertEquals(204, transport.send(HttpRequest(HttpMethod.DELETE, url)).status)
}
@Test
fun queuedFailureIsRethrown() = runTest {
val transport = FakeHttpTransport()
val url = "http://h/x"
transport.queueFailure(url = url, error = IOException("connection refused"))
val thrown = runCatching { transport.send(HttpRequest(HttpMethod.GET, url)) }.exceptionOrNull()
assertTrue(thrown is IOException)
assertEquals("connection refused", thrown?.message)
}
}

View File

@@ -0,0 +1,42 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.PingableTermTransport
class FakePingableTermTransportTest {
private fun endpoint(): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
@Test
fun connectPingableReturnsSharedScriptablePinger() = runTest {
val transport = FakePingableTermTransport()
transport.pinger.scriptPongs(true, false)
val pingable = transport.connectPingable(endpoint())
assertEquals(true, pingable.pinger.ping())
assertEquals(false, pingable.pinger.ping())
assertEquals(true, pingable.pinger.ping()) // default once the scripted queue drains
assertEquals(3, transport.pinger.pingCallCount)
assertEquals(1, transport.connectPingableCount)
assertEquals(listOf(endpoint()), transport.connectAttempts) // base recording still works
}
@Test
fun isRecognizedAsPingableTransport() {
val transport: Any = FakePingableTermTransport()
assertTrue(transport is PingableTermTransport)
}
@Test
fun pingerUsesConfiguredDefaultWhenUnscripted() = runTest {
val pinger = FakeConnectionPinger(defaultPong = false)
assertEquals(false, pinger.ping())
assertEquals(false, pinger.ping())
assertEquals(2, pinger.pingCallCount)
}
}

View File

@@ -0,0 +1,98 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
/** Ports iOS `FakeTransportTests`, plus the pending-flush / reconnect / termination behaviors. */
class FakeTermTransportTest {
private fun endpoint(): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
@Test
fun scriptsFailuresDeliversFramesAndRecordsTraffic() = runTest {
// Arrange
val transport = FakeTermTransport()
val endpoint = endpoint()
val attachFrame = """{"type":"attach","sessionId":null}"""
// Act + Assert: a scripted failure makes the next connect throw it.
transport.scriptConnectFailure()
val failure = runCatching { transport.connect(endpoint) }.exceptionOrNull()
assertEquals(FakeTransportError.ScriptedConnectFailure, failure)
// Act: connect for real, send one frame, inject one frame, close cleanly.
val connection = transport.connect(endpoint)
connection.send(attachFrame)
transport.emit("server-frame-1")
transport.finishFrames()
val received = connection.frames.toList()
connection.close()
// Assert: injected frames arrived in order and ended with a clean finish.
assertEquals(listOf("server-frame-1"), received)
// Assert: the double recorded everything the client did.
assertEquals(listOf(attachFrame), transport.sentFrames)
assertEquals(1, transport.closeCallCount)
assertEquals(listOf(endpoint, endpoint), transport.connectAttempts)
}
@Test
fun emitErrorTerminatesStreamWithThatError() = runTest {
val transport = FakeTermTransport()
val connection = transport.connect(endpoint())
val boom = IllegalStateException("boom")
transport.emit("f1")
transport.emitError(boom)
// The buffered frame is delivered, then the stream throws the transport error.
val thrown = runCatching { connection.frames.toList() }.exceptionOrNull()
assertTrue(thrown is IllegalStateException)
assertEquals("boom", thrown?.message)
}
@Test
fun sendAfterCloseThrows() = runTest {
val transport = FakeTermTransport()
val connection = transport.connect(endpoint())
connection.close()
val thrown = runCatching { connection.send("late") }.exceptionOrNull()
assertEquals(FakeTransportError.SendAfterClose, thrown)
assertEquals(1, transport.closeCallCount)
}
@Test
fun queuesEventsBeforeConnectAndFlushesInOrderOnNextConnect() = runTest {
val transport = FakeTermTransport()
// Script server behavior up front, before any connection exists (attach->replay pattern).
transport.emit("replay-1")
transport.emit("replay-2")
transport.finishFrames()
val connection = transport.connect(endpoint())
assertEquals(listOf("replay-1", "replay-2"), connection.frames.toList())
}
@Test
fun reattachFrameLandsOnSecondConnection() = runTest {
val transport = FakeTermTransport()
val endpoint = endpoint()
val c0 = transport.connect(endpoint)
c0.send("attach-0")
transport.finishFrames() // connection 0 terminated (server close)
val c1 = transport.connect(endpoint)
c1.send("attach-1")
assertEquals(listOf(listOf("attach-0"), listOf("attach-1")), transport.sentFramesByConnection)
assertEquals(listOf("attach-0", "attach-1"), transport.sentFrames)
assertEquals(listOf(endpoint, endpoint), transport.connectAttempts)
}
}

View File

@@ -0,0 +1,60 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** Ports iOS `FakeClockTests` — manual advance, zero real-time waiting — plus the epoch/tick shapes. */
class FakeTimeSourceTest {
@Test
fun markElapsesOnlyAfterManualAdvance() {
val clock = FakeTimeSource()
val mark = clock.markNow()
assertEquals(Duration.ZERO, mark.elapsedNow())
clock.advance(10.seconds) // short of a 25s deadline
assertEquals(10.seconds, mark.elapsedNow())
clock += 15.seconds // reach it
assertEquals(25.seconds, mark.elapsedNow())
assertEquals(25.seconds, clock.now)
}
@Test
fun epochMillisTracksAdvanceFromBase() {
val clock = FakeTimeSource(epochBaseMillis = 1_000L)
assertEquals(1_000L, clock.epochMillis())
clock.advance(30.seconds)
assertEquals(31_000L, clock.epochMillis())
}
@Test
fun cannotMoveBackwards() {
val clock = FakeTimeSource()
assertThrows<IllegalArgumentException> { clock.advance((-1).seconds) }
}
@Test
fun tickAdvancesVirtualDelayAndReadableClockTogether() = runTest {
val clock = FakeTimeSource()
var fired = false
launch {
delay(25.seconds)
fired = true
}
assertFalse(fired) // the delayed body has not run yet
tick(clock, 25.seconds)
assertTrue(fired) // virtual delay fired
assertEquals(25.seconds, clock.now) // readable clock moved in lockstep
}
}

View File

@@ -0,0 +1,25 @@
// :wire-protocol — the FROZEN shared contract (Android analogue of src/types.ts +
// WireProtocol). Pure Kotlin/JVM: sealed ClientMessage/ServerMessage, MessageCodec,
// Validation, WireConstants, HostEndpoint, and the transport boundary interfaces.
// Depends on nothing else in the build (top of the dependency graph).
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(17)
}
dependencies {
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.coroutines.core)
testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher)
}
tasks.test {
useJUnitPlatform()
}

View File

@@ -0,0 +1,82 @@
package wang.yaojia.webterm.wire
/**
* Client -> server WS frames (FROZEN contract, plan §3.1). The Android analogue of
* `src/types.ts:87-92` `ClientMessage` and iOS `WireProtocol.ClientMessage`.
*
* Immutable value types. Encoding to the wire is A4 (`MessageCodec`) — this file only
* freezes the SHAPES so every other module reads stable names.
*
* Server-side validation the caller must respect (the server SILENTLY DISCARDS invalid
* frames — `src/protocol.ts:45-86` — no error reply comes back):
* - `attach` MUST be the FIRST frame on a connection (`src/server.ts:766-773`).
* - `resize` cols/rows must be integers in [WireConstants.RESIZE_RANGE] (1..1000).
* - `attach.cwd`, when present, must be an absolute path (leading '/').
* - `input.data` is raw keyboard bytes, passed through VERBATIM — never filtered
* (ARCHITECTURE invariant #9).
*
* DEVIATION NOTE (A2): the task brief listed `Attach(sessionId, cols, rows, cwd)`, but the
* server `attach` frame (`src/protocol.ts` validateAttach, `src/types.ts:88`) and the iOS
* `ClientMessage.attach(sessionId:cwd:)` carry NO cols/rows — the server hardcodes
* DEFAULT_COLS/ROWS on attach (`src/server.ts:774-781`) and takes real dimensions from a
* separate `resize` frame sent right after attach. Adding cols/rows here would make the
* encoded attach frame differ from the web client and break A4's byte-equality vectors, so
* the faithful wire contract (sessionId + optional cwd) is frozen instead.
*/
public sealed interface ClientMessage {
/**
* First frame. `sessionId == null` spawns a NEW session (A4 encodes this as an explicit
* JSON `"sessionId":null` — the key is REQUIRED by the server, `src/protocol.ts:132-134`).
* `cwd` = "new tab here" spawn directory (M6); appended only when [sessionId] is null.
*/
public data class Attach(val sessionId: String?, val cwd: String? = null) : ClientMessage
/** Raw keyboard bytes, verbatim (invariant #9 — no content filtering). */
public data class Input(val data: String) : ClientMessage
/** Own message type so the server can `ioctl(TIOCSWINSZ)` -> SIGWINCH for TUIs. */
public data class Resize(val cols: Int, val rows: Int) : ClientMessage
/**
* Resolve a held permission gate with allow. [mode] is only meaningful for a `plan` gate
* and is encoded as a TOP-LEVEL `mode` key — the server's WS wiring re-parses the raw
* frame for it (`src/server.ts:91-102`). No [mode] = a plain approve.
*/
public data class Approve(val mode: ApproveMode? = null) : ClientMessage
/** Resolve a held permission gate with deny. */
public data object Reject : ClientMessage
}
/**
* Permission mode written back when resolving a `plan` gate. Wire values mirror the server
* whitelist `PermissionMode` (`src/types.ts:371`). The Kotlin analogue of Swift's
* `enum ApproveMode: String` RawRepresentable — [wire] is the JSON string, [fromWire] the
* reverse lookup.
*
* Note: the plan-gate three-way UI only ever sends [ACCEPT_EDITS] / [DEFAULT]; raw [AUTO] is
* gated server-side by ALLOW_AUTO_MODE (default false) and downgraded to `default` otherwise.
*/
public enum class ApproveMode(public val wire: String) {
DEFAULT("default"),
ACCEPT_EDITS("acceptEdits"),
PLAN("plan"),
AUTO("auto");
public companion object {
public fun fromWire(value: String): ApproveMode? = entries.firstOrNull { it.wire == value }
}
}
/** The `type` discriminator whitelist for client frames (`src/protocol.ts:27` ALLOWED_TYPES). */
public enum class ClientMessageType(public val wire: String) {
ATTACH("attach"),
INPUT("input"),
RESIZE("resize"),
APPROVE("approve"),
REJECT("reject");
public companion object {
public fun fromWire(value: String): ClientMessageType? = entries.firstOrNull { it.wire == value }
}
}

View File

@@ -0,0 +1,92 @@
package wang.yaojia.webterm.wire
import java.net.URI
/**
* The four network tiers of the pairing/warning table (plan §5.4). The SINGLE canonical type —
* hoisted into :wire-protocol so :api-client and :client-tls share ONE classifier (the two module
* copies had drifted). Faithful port of iOS `APIClient.HostNetworkTier`; the UI layers
* (PairingViewModel scheme+address 分层提示) consume ONE classification instead of re-deriving it.
*
* | tier | matches | §5.4 hint |
* |---|---|---|
* | [LOOPBACK] | localhost · 127.0.0.0/8 · ::1 | no warning |
* | [PRIVATE_LAN] | RFC1918 (10/8, 172.16/12, 192.168/16) · 169.254/16 · mDNS .local | non-blocking ws:// cleartext notice |
* | [TAILSCALE] | CGNAT 100.64.0.0/10 · MagicDNS *.ts.net | no cleartext warning (WireGuard-encrypted) |
* | [PUBLIC] | everything else (incl. out-of-range / malformed — fail-safe) | strongest blocking warning |
*/
public enum class HostNetworkTier {
LOOPBACK,
PRIVATE_LAN,
TAILSCALE,
PUBLIC,
}
/**
* Pure, stateless classifier over a URL host string (plan §5.4 tiers). Faithful port of iOS
* `HostClassifier`.
*
* IPv6 note: only loopback `::1` is tiered; other IPv6 literals (incl. ULA `fd00::/8`) fall to
* [HostNetworkTier.PUBLIC] — fail-safe over-warning, matching v1 scope (the app dials IPv4 LAN /
* CGNAT / MagicDNS targets). The finer IPv6 handling lives in the UI layer if ever needed.
*/
public object HostClassifier {
private const val MAGIC_DNS_SUFFIX = ".ts.net"
private const val MDNS_SUFFIX = ".local"
private val LOOPBACK_NAMES: Set<String> = setOf("localhost", "::1", "[::1]")
private const val IPV4_OCTET_COUNT = 4
private val IPV4_OCTET_RANGE = 0..255
/**
* Classify a bare host string (as returned by URL host parsing — no scheme, no port).
* Unknown/malformed input is [HostNetworkTier.PUBLIC] by design (fail-safe).
*/
public fun classify(host: String): HostNetworkTier {
val lowered = host.lowercase()
if (lowered in LOOPBACK_NAMES) return HostNetworkTier.LOOPBACK
if (lowered.endsWith(MAGIC_DNS_SUFFIX)) return HostNetworkTier.TAILSCALE
if (lowered.endsWith(MDNS_SUFFIX)) return HostNetworkTier.PRIVATE_LAN
val octets = ipv4Octets(lowered) ?: return HostNetworkTier.PUBLIC
return classifyIpv4(octets)
}
/**
* Convenience for callers holding a [HostEndpoint] (the VM's shape). A `baseUrl` that no longer
* yields a host classifies as [HostNetworkTier.PUBLIC] (fail-safe).
*/
public fun classify(endpoint: HostEndpoint): HostNetworkTier = classify(hostOf(endpoint))
/**
* Extract the bare host from a (validated) [HostEndpoint]. Shared by [classify] and pairing-error
* diagnosis so host derivation happens in ONE place. Trims defensively (never trust at a
* boundary) and returns "" on any parse failure (→ PUBLIC, fail-safe).
*/
public fun hostOf(endpoint: HostEndpoint): String =
runCatching { URI(endpoint.baseUrl.trim()).host }.getOrNull() ?: ""
private fun classifyIpv4(octets: List<Int>): HostNetworkTier {
val first = octets[0]
val second = octets[1]
return when {
first == 127 -> HostNetworkTier.LOOPBACK // loopback 127/8
first == 10 -> HostNetworkTier.PRIVATE_LAN // RFC1918 10/8
first == 192 && second == 168 -> HostNetworkTier.PRIVATE_LAN // RFC1918 192.168/16
first == 172 && second in 16..31 -> HostNetworkTier.PRIVATE_LAN // RFC1918 172.16/12
first == 169 && second == 254 -> HostNetworkTier.PRIVATE_LAN // link-local 169.254/16
first == 100 && second in 64..127 -> HostNetworkTier.TAILSCALE // CGNAT 100.64/10 (Tailscale)
else -> HostNetworkTier.PUBLIC
}
}
/**
* Strict dotted-quad parse; anything else (hostnames, IPv6, out-of-range octets, wrong arity)
* → null. Public so pairing-error diagnosis can reuse the exact same rule.
*/
public fun ipv4Octets(host: String): List<Int>? {
val parts = host.split(".")
if (parts.size != IPV4_OCTET_COUNT) return null
val octets = parts.map { it.toIntOrNull() ?: return null }
if (octets.any { it !in IPV4_OCTET_RANGE }) return null
return octets
}
}

View File

@@ -0,0 +1,102 @@
package wang.yaojia.webterm.wire
import java.net.URI
/**
* A paired web-terminal host (FROZEN contract, plan §3.1). The SINGLE point of derivation for
* the `Origin` header and the WS URL — hand-assembling either anywhere else is a review
* CRITICAL (CSWSH defence, TECH_DOC §7). Android analogue of iOS `WireProtocol.HostEndpoint`.
*
* Derivations are computed once by [fromBaseUrl] and stored immutably:
* - [originHeader] = `<scheme>://<host>[:<port>]`, omitting the scheme's default port
* (http/80, https/443), scheme+host lowercased, IPv6 bracketed — byte-identical to the
* browser's Origin serialization. The server re-parses BOTH sides via `new URL()` and
* compares protocol/hostname/port (`src/http/origin.ts:31-51`), so this must match one of
* the server's NIC-derived allowed origins exactly or the WS upgrade 401s.
* - [wsUrl] = same host+port as dialed, scheme http->ws / https->wss, path replaced by
* [WireConstants.WS_PATH]; query/fragment/credentials dropped. The dialed port is kept
* verbatim here (unlike the origin, which drops default ports).
*
* Pure Kotlin/JVM only — `java.net.URI` is JVM stdlib parsing, not an Android import.
* Persistence (A12 host-registry) stores the [baseUrl] string and reconstructs via
* [fromBaseUrl], re-validating on load (untrusted at rest).
*/
public class HostEndpoint private constructor(
/** The URL the user dialed (`http(s)://<host>[:<port>]`). Path/query/fragment/credentials it carried are ignored by the derivations. */
public val baseUrl: String,
/** Derived WS endpoint (`ws(s)://…/term`). Read-only. */
public val wsUrl: String,
/** Derived `Origin` header value — see class doc. Never hand-assemble elsewhere. */
public val originHeader: String,
) {
override fun equals(other: Any?): Boolean =
other is HostEndpoint &&
baseUrl == other.baseUrl &&
wsUrl == other.wsUrl &&
originHeader == other.originHeader
override fun hashCode(): Int {
var result = baseUrl.hashCode()
result = 31 * result + wsUrl.hashCode()
result = 31 * result + originHeader.hashCode()
return result
}
override fun toString(): String = "HostEndpoint(baseUrl=$baseUrl, wsUrl=$wsUrl, originHeader=$originHeader)"
public companion object {
private val WS_SCHEME_BY_HTTP: Map<String, String> = mapOf("http" to "ws", "https" to "wss")
private val DEFAULT_PORT_BY_SCHEME: Map<String, Int> = mapOf("http" to 80, "https" to 443)
/**
* Validating factory: returns null unless [baseUrl] parses as an http(s) URL with a
* non-empty host. QR-scan / manual-entry payloads are untrusted external input — reject
* early (plan §5).
*/
public fun fromBaseUrl(baseUrl: String): HostEndpoint? {
// Trim ONCE, here, and store the trimmed value: downstream re-parsers (PairingProbe
// httpBaseUrl, HostClassifier.hostOf) do `URI(endpoint.baseUrl)` and would otherwise
// throw / misclassify on stray surrounding whitespace from a QR scan or manual entry.
val trimmed = baseUrl.trim()
val uri = try {
URI(trimmed)
} catch (_: Exception) {
return null
}
val scheme = uri.scheme?.lowercase() ?: return null
val wsScheme = WS_SCHEME_BY_HTTP[scheme] ?: return null // rejects non-http(s)
val host = uri.host ?: return null
if (host.isEmpty()) return null
val port: Int? = uri.port.takeIf { it != -1 }
return HostEndpoint(
baseUrl = trimmed,
wsUrl = deriveWsUrl(wsScheme, host, port),
originHeader = deriveOrigin(scheme, host.lowercase(), port),
)
}
private fun deriveOrigin(scheme: String, host: String, port: Int?): String {
val serializedHost = bracketIfIpv6(host)
val defaultPort = DEFAULT_PORT_BY_SCHEME[scheme]
return if (port == null || port == defaultPort) {
"$scheme://$serializedHost"
} else {
"$scheme://$serializedHost:$port"
}
}
private fun deriveWsUrl(wsScheme: String, host: String, port: Int?): String {
val serializedHost = bracketIfIpv6(host)
val portPart = if (port == null) "" else ":$port"
return "$wsScheme://$serializedHost$portPart${WireConstants.WS_PATH}"
}
/**
* IPv6 literals need brackets in an Origin / URL. `URI.getHost()` has returned them both
* bare and pre-bracketed across JVMs — wrap idempotently.
*/
private fun bracketIfIpv6(host: String): String =
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
}
}

View File

@@ -0,0 +1,51 @@
package wang.yaojia.webterm.wire
/**
* Thin HTTP I/O boundary (FROZEN contract, plan §3.1). The Android analogue of iOS
* `WireProtocol.HTTPTransport`. `:api-client` (A8/A9) builds [HttpRequest]s (stamping `Origin`
* IFF the route is a mutating/guarded one, plan §4.3) and sends them through this seam;
* `:transport-okhttp` (A7) implements it over OkHttp, the fake in `:test-support` (A3) queues
* canned responses. Pure interface — the request/response DTOs are OkHttp-free so both sides
* share them without an Android dependency.
*/
public interface HttpTransport {
/**
* Perform one HTTP exchange. Implementations throw on transport-level failure; a non-2xx
* status is RETURNED (not thrown) — classification (e.g. `PairingError`) is the caller's job.
*/
public suspend fun send(request: HttpRequest): HttpResponse
}
/** HTTP verbs used by the client surface (`src/http` routes: GET reads, POST/PUT/DELETE guarded). */
public enum class HttpMethod {
GET,
POST,
PUT,
DELETE,
}
/**
* A pure HTTP request DTO built by `:api-client`. [headers] carries `Origin` only for guarded
* routes (plan §4.3 铁律). [body] is opaque bytes (JSON for the mutating routes); null for GET.
*
* NOTE: [body] is a `ByteArray`, so generated equality is by reference — these are transient
* I/O carriers, not value-equality keys.
*/
public data class HttpRequest(
val method: HttpMethod,
val url: String,
val headers: Map<String, String> = emptyMap(),
val body: ByteArray? = null,
)
/**
* A pure HTTP response DTO. [status] is the HTTP status code (204/404/403/429 all meaningful
* to callers — see plan §4.2/§4.3); [body] is the raw bytes (may be empty for 204).
*
* NOTE: [body] is a `ByteArray`, so generated equality is by reference — see [HttpRequest].
*/
public data class HttpResponse(
val status: Int,
val body: ByteArray,
val headers: Map<String, String> = emptyMap(),
)

View File

@@ -0,0 +1,232 @@
package wang.yaojia.webterm.wire
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Pure codec for the WS text-frame protocol (frozen contract, plan §3.1). NEVER throws in
* either direction — the Android analogue of iOS `WireProtocol.MessageCodec`.
*
* - [encode] client → server. HAND-ROLLED so the bytes are IDENTICAL to the web
* client / server `JSON.stringify` (explicit `sessionId:null`, top-level
* `approve.mode`, JS control-char escaping). Every [ClientMessage] has a
* representation, so encode is total.
* - [decodeServer] server → client (UNTRUSTED). Tolerant kotlinx.serialization decode into a
* [ServerMessage]; a malformed / unknown / wrong-typed-required frame → null
* (drop + count, never crash — mirrors the server's own resilience).
* - [decodeClient] the boundary validator — the Android mirror of the server's
* `parseClientMessage` (type whitelist, `resize` 1..1000, `input.data`
* passthrough, `attach.sessionId` UUID-v4 / explicit-null). Invalid → null.
* Exact inverse of [encode] for round-trip verification.
*/
public object MessageCodec {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
// ─── encode (client → server) ────────────────────────────────────────────────
/**
* Encode a [ClientMessage] as a JSON text frame the server's `parseClientMessage` accepts,
* BYTE-IDENTICAL to the web client. String escaping matches `JSON.stringify` (short escapes
* for `\b \t \n \f \r`, `\u00xx` lowercase-hex for the other C0 control bytes); `attach`
* always carries an explicit `sessionId` key (null when absent); UUIDs are lowercased;
* `approve.mode` is a TOP-LEVEL key (the server re-reads the raw frame for it).
*/
public fun encode(message: ClientMessage): String = when (message) {
is ClientMessage.Attach -> encodeAttach(message.sessionId, message.cwd)
is ClientMessage.Input -> """{"type":"input","data":${jsonString(message.data)}}"""
is ClientMessage.Resize -> """{"type":"resize","cols":${message.cols},"rows":${message.rows}}"""
is ClientMessage.Approve ->
message.mode?.let { """{"type":"approve","mode":"${it.wire}"}""" } ?: """{"type":"approve"}"""
ClientMessage.Reject -> """{"type":"reject"}"""
}
private fun encodeAttach(sessionId: String?, cwd: String?): String {
// The sessionId is escaped via jsonString (like every other string field) so encode stays
// TOTAL and byte-identical to JSON.stringify — never interpolate an id raw (JSON injection).
val sid = if (sessionId != null) jsonString(sessionId.lowercase()) else "null"
val cwdPart = if (cwd != null) ",\"cwd\":${jsonString(cwd)}" else ""
return """{"type":"attach","sessionId":$sid$cwdPart}"""
}
/** JSON string literal with `JSON.stringify`-identical escaping (iterates UTF-16 units). */
private fun jsonString(value: String): String {
val sb = StringBuilder(value.length + 2)
sb.append('"')
var i = 0
while (i < value.length) {
val c = value[i]
when (c) {
'"' -> sb.append("\\\"")
'\\' -> sb.append("\\\\")
'\b' -> sb.append("\\b") // 0x08
'\t' -> sb.append("\\t") // 0x09
'\n' -> sb.append("\\n") // 0x0A
'\u000C' -> sb.append("\\f") // 0x0C
'\r' -> sb.append("\\r") // 0x0D
else ->
when {
c.code < 0x20 ->
sb.append("\\u").append(c.code.toString(16).padStart(4, '0'))
// Valid high+low surrogate pair: emit both UTF-16 units verbatim
// (JSON.stringify keeps the astral character as-is).
c.isHighSurrogate() && i + 1 < value.length && value[i + 1].isLowSurrogate() -> {
sb.append(c).append(value[i + 1])
i++ // consume the low surrogate too
}
// Lone surrogate (a high without a low, or a bare low): ES2019
// well-formed JSON.stringify emits \udXXX lowercase-hex.
c.isSurrogate() ->
sb.append("\\u").append(c.code.toString(16).padStart(4, '0'))
else -> sb.append(c)
}
}
i++
}
sb.append('"')
return sb.toString()
}
// ─── decodeServer (server → client, untrusted) ───────────────────────────────
/**
* Decode an untrusted server text frame into a [ServerMessage], or null to DROP it (bad
* JSON, non-object, unknown `type`, or a missing/wrong-typed REQUIRED field). Wrong-typed
* OPTIONAL fields are tolerated as absent (mirrors the server's tolerant parsing). Unknown
* keys are ignored. Never throws.
*/
public fun decodeServer(raw: String): ServerMessage? {
val root = parseObject(raw) ?: return null
val type = root.stringField("type") ?: return null
return when (ServerMessageType.fromWire(type)) {
ServerMessageType.ATTACHED -> decodeAttached(root)
ServerMessageType.OUTPUT -> root.stringField("data")?.let { ServerMessage.Output(it) }
ServerMessageType.EXIT -> decodeExit(root)
ServerMessageType.STATUS -> decodeStatus(root)
ServerMessageType.TELEMETRY -> decodeTelemetry(root)
null -> null
}
}
private fun decodeAttached(obj: JsonObject): ServerMessage? {
val raw = obj.stringField("sessionId") ?: return null
if (!Validation.isValidSessionId(raw)) return null
return ServerMessage.Attached(raw.lowercase())
}
private fun decodeExit(obj: JsonObject): ServerMessage? {
val code = obj.intField("code") ?: return null
return ServerMessage.Exit(code, obj.stringField("reason"))
}
private fun decodeStatus(obj: JsonObject): ServerMessage? {
val status = obj.stringField("status")?.let { ClaudeStatus.fromWire(it) } ?: return null
val detail = obj.stringField("detail")
val pending = obj.boolField("pending") ?: false
val gate = obj.stringField("gate")?.let { GateKind.fromWire(it) }
return ServerMessage.Status(status, detail, pending, gate)
}
private fun decodeTelemetry(obj: JsonObject): ServerMessage? {
val tel = obj["telemetry"] as? JsonObject ?: return null
val at = tel.longField("at") ?: return null // only required field
return ServerMessage.Telemetry(
StatusTelemetry(
contextUsedPct = tel.doubleField("contextUsedPct"),
costUsd = tel.doubleField("costUsd"),
linesAdded = tel.intField("linesAdded"),
linesRemoved = tel.intField("linesRemoved"),
model = tel.stringField("model"),
effort = tel.stringField("effort"),
pr = tel.decodeNested("pr", PrInfo.serializer()),
rate = tel.decodeNested("rate", RateInfo.serializer()),
at = at,
),
)
}
// ─── decodeClient (boundary validator — server-parseClientMessage mirror) ─────
/**
* Validate + decode a client text frame the way the server's `parseClientMessage` would
* ACCEPT/REJECT it: `type` in [ClientMessageType]; `resize` cols/rows non-string integers in
* 1..1000; `input.data` a string (passed through VERBATIM); `attach.sessionId` present as
* explicit null or a UUID-v4; `attach.cwd` (when present) an absolute path. Invalid → null.
* The exact inverse of [encode], so `decodeClient(encode(m)) == m` for canonical `m`.
*/
public fun decodeClient(raw: String): ClientMessage? {
val root = parseObject(raw) ?: return null
val type = root.stringField("type") ?: return null
return when (ClientMessageType.fromWire(type)) {
ClientMessageType.ATTACH -> decodeAttach(root)
ClientMessageType.INPUT -> root.stringField("data")?.let { ClientMessage.Input(it) }
ClientMessageType.RESIZE -> decodeResize(root)
ClientMessageType.APPROVE ->
ClientMessage.Approve(root.stringField("mode")?.let { ApproveMode.fromWire(it) })
ClientMessageType.REJECT -> ClientMessage.Reject
null -> null
}
}
private fun decodeResize(obj: JsonObject): ClientMessage? {
val cols = obj.intField("cols")?.takeIf { it in WireConstants.RESIZE_RANGE } ?: return null
val rows = obj.intField("rows")?.takeIf { it in WireConstants.RESIZE_RANGE } ?: return null
return ClientMessage.Resize(cols, rows)
}
private fun decodeAttach(obj: JsonObject): ClientMessage? {
if ("sessionId" !in obj) return null // the key is REQUIRED (present as null or uuid)
val cwd: String? =
obj["cwd"]?.let { el ->
val c = (el as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
if (!Validation.isAbsoluteCwd(c)) return null
c
}
val sidEl = obj.getValue("sessionId")
if (sidEl is JsonNull) return ClientMessage.Attach(null, cwd)
val sid = (sidEl as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
if (!Validation.isValidSessionId(sid)) return null
return ClientMessage.Attach(sid.lowercase(), cwd)
}
// ─── shared JSON helpers (tolerant, never throw) ─────────────────────────────
private fun parseObject(raw: String): JsonObject? =
runCatching { json.parseToJsonElement(raw) }.getOrNull() as? JsonObject
/** A JSON STRING value only (a number/bool/null/object → null → treated as absent). */
private fun JsonObject.stringField(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
/** A JSON non-string INTEGER only (`"80"`, `80.5`, null → null). */
private fun JsonObject.intField(key: String): Int? =
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull
/** A JSON non-string integral LONG only (for the millisecond `at` timestamp). */
private fun JsonObject.longField(key: String): Long? =
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.longOrNull
/** A JSON non-string NUMBER as Double (a quoted number/other → null → treated as absent). */
private fun JsonObject.doubleField(key: String): Double? =
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull
/** A JSON BOOLEAN only (a quoted "true" / other → null → caller defaults to false). */
private fun JsonObject.boolField(key: String): Boolean? =
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull
/** Decode a nested @Serializable object tolerantly: any decode failure → null (absent). */
private fun <T> JsonObject.decodeNested(
key: String,
deserializer: kotlinx.serialization.DeserializationStrategy<T>,
): T? = this[key]?.let { runCatching { json.decodeFromJsonElement(deserializer, it) }.getOrNull() }
}

View File

@@ -0,0 +1,85 @@
package wang.yaojia.webterm.wire
/**
* Server -> client WS frames (FROZEN contract, plan §3.1). The Android analogue of
* `src/types.ts:109-120` `ServerMessage` and iOS `WireProtocol.ServerMessage`.
*
* Every frame is UNTRUSTED input: A4's `MessageCodec.decodeServer` maps a raw text frame to
* one of these or to `null` (drop the frame) — an undecodable frame is counted and dropped,
* NEVER a crash (`src/protocol.ts` resilience). This file only freezes the SHAPES.
*/
public sealed interface ServerMessage {
/**
* Attach confirmation. ALWAYS adopt the server-issued id — attaching with an unknown UUID
* yields a fresh session id (`src/session/manager.ts`). [sessionId] is the raw string here;
* A4's decode enforces the UUID-v4 shape before constructing this.
*/
public data class Attached(val sessionId: String) : ServerMessage
/** Opaque ANSI/UTF-8 bytes; ring-buffer replay and the live stream share this shape. */
public data class Output(val data: String) : ServerMessage
/**
* Shell exit (terminal). `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) means the
* spawn never succeeded (M4) and [reason] is required server-side in that case.
*/
public data class Exit(val code: Int, val reason: String? = null) : ServerMessage
/**
* Claude Code activity derived from hooks (H2/H3/B4). [pending] = a tool approval is held
* server-side and the client may approve/reject; [gate] says which kind. An absent
* `pending` decodes as false; an unrecognized `gate` decodes as null (tolerated as absent
* so the pending signal is never lost).
*/
public data class Status(
val status: ClaudeStatus,
val detail: String? = null,
val pending: Boolean = false,
val gate: GateKind? = null,
) : ServerMessage
/** Latest statusLine telemetry broadcast (B2). */
public data class Telemetry(val telemetry: StatusTelemetry) : ServerMessage
}
/**
* Claude Code activity (`src/types.ts:97` `ClaudeStatus`). `unknown` = no hook signal yet;
* `stuck` (A5) = output silent past STUCK_TTL while not idle/exited.
*/
public enum class ClaudeStatus(public val wire: String) {
WORKING("working"),
WAITING("waiting"),
IDLE("idle"),
UNKNOWN("unknown"),
STUCK("stuck");
public companion object {
public fun fromWire(value: String): ClaudeStatus? = entries.firstOrNull { it.wire == value }
}
}
/**
* Which held approval a `status` carries (`src/types.ts:101` `PermissionGate`). `plan` = an
* ExitPlanMode three-way gate; `tool` = an ordinary tool gate.
*/
public enum class GateKind(public val wire: String) {
TOOL("tool"),
PLAN("plan");
public companion object {
public fun fromWire(value: String): GateKind? = entries.firstOrNull { it.wire == value }
}
}
/** The `type` discriminator whitelist for server frames (`src/types.ts:109-120`). */
public enum class ServerMessageType(public val wire: String) {
ATTACHED("attached"),
OUTPUT("output"),
EXIT("exit"),
STATUS("status"),
TELEMETRY("telemetry");
public companion object {
public fun fromWire(value: String): ServerMessageType? = entries.firstOrNull { it.wire == value }
}
}

View File

@@ -0,0 +1,42 @@
package wang.yaojia.webterm.wire
import kotlinx.serialization.Serializable
/**
* Per-session statusLine telemetry (B2), broadcast in a `telemetry` frame. FROZEN shape,
* mirrors `src/types.ts:412-422` `StatusTelemetry` and iOS `WireProtocol.StatusTelemetry`.
*
* Every metric is OPTIONAL; [at] (server receive time, ms since epoch) is REQUIRED — a frame
* without it is dropped by A4's decoder. Property names match the server JSON keys exactly so
* the tolerant `Json` decode configured in A4 maps them directly. `@Serializable` here only
* freezes the SHAPE; the tolerant-decode CONFIG (ignoreUnknownKeys / wrong-typed-optional ->
* absent) lives in A4.
*/
@Serializable
public data class StatusTelemetry(
val contextUsedPct: Double? = null,
val costUsd: Double? = null,
val linesAdded: Int? = null,
val linesRemoved: Int? = null,
val model: String? = null,
val effort: String? = null,
val pr: PrInfo? = null,
val rate: RateInfo? = null,
/** Server receive timestamp (ms since epoch). REQUIRED — frames without it are dropped. */
val at: Long,
)
/** Pull-request summary carried in [StatusTelemetry.pr] (`src/types.ts:419`). */
@Serializable
public data class PrInfo(
val number: Int,
val url: String,
val reviewState: String? = null,
)
/** Rate-limit summary carried in [StatusTelemetry.rate] (`src/types.ts:420`). */
@Serializable
public data class RateInfo(
val fiveHourPct: Double? = null,
val sevenDayPct: Double? = null,
)

View File

@@ -0,0 +1,73 @@
package wang.yaojia.webterm.wire
import kotlinx.coroutines.flow.Flow
/**
* The ONLY WS I/O boundary (FROZEN contract, plan §3.1/§3.2). The Android analogue of iOS
* `WireProtocol.TermTransport`.
*
* `:transport-okhttp` (A7) and the `FakeTransport` in `:test-support` (A3) both implement this;
* `SessionEngine` (A14) cannot tell them apart. Pure interface — NO OkHttp/Android imports.
*/
public interface TermTransport {
/**
* Open a WS connection to [HostEndpoint.wsUrl], stamping `Origin: endpoint.originHeader` on
* the upgrade (CSWSH defence, plan §5.1). Throws on connect-time failure (the underlying
* error is rethrown verbatim so the pairing probe, A9, can classify POSIX/TLS codes).
*/
public suspend fun connect(endpoint: HostEndpoint): TransportConnection
}
/**
* One live WS connection, as an immutable capability handle (FROZEN contract). The Android
* analogue of iOS `WireProtocol.TransportConnection` (Swift's closure struct becomes an
* interface here so both the OkHttp impl and the fake can implement it).
*/
public interface TransportConnection {
/**
* Server JSON text frames, in arrival order. Normal completion of the flow = a clean close;
* an exception thrown by the flow = a transport error. The two MUST stay distinguishable
* (the engine treats clean-close vs error differently, A14).
*/
public val frames: Flow<String>
/** Send one client JSON text frame (produced by A4's `MessageCodec.encode`). */
public suspend fun send(frame: String)
/** Close the connection (client detach — the server-side PTY keeps running). */
public suspend fun close()
}
/**
* One live connection's ping capability (see [PingableTermTransport]). Android analogue of the
* iOS SessionCore-internal `ConnectionPinger`.
*/
public interface ConnectionPinger {
/**
* Send one WS ping; returns true iff the pong arrived in time (false = a miss, counted by
* the PingScheduler against [Tunables.PONG_MISS_LIMIT]).
*/
public suspend fun ping(): Boolean
}
/**
* A [TermTransport] that also exposes a per-connection ping hook, so the pure `PingScheduler`
* (A5) can drive keep-alive pings only through transports that support it. Android analogue of
* the iOS `transport as? any PingableTermTransport` downcast: `SessionEngine` opens the
* connection via [connectPingable] when the transport implements this, otherwise falls back to
* plain [TermTransport.connect]. The `FakeTransport` (A3) intentionally does NOT implement this,
* so engine tests drive the PingScheduler through an injected closure instead.
*/
public interface PingableTermTransport : TermTransport {
/**
* Like [connect], but also hands back the ping hook for that same connection (for
* PingScheduler wiring).
*/
public suspend fun connectPingable(endpoint: HostEndpoint): PingableConnection
}
/** The frozen [TransportConnection] PLUS its ping capability (iOS: a `(connection, pinger)` tuple). */
public data class PingableConnection(
val connection: TransportConnection,
val pinger: ConnectionPinger,
)

View File

@@ -0,0 +1,33 @@
package wang.yaojia.webterm.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One activity-timeline entry from `GET /live-sessions/:id/events` (FROZEN shape, plan §3.1;
* mirrors `src/types.ts:434-439` and iOS `WireProtocol.TimelineEvent`).
*
* The server derives [eventClass] semantically (`src/types.ts:429`: tool/waiting/done/stuck/
* user) — the client never re-derives it from hook names. [eventClass] stays a raw String so
* the SHAPE decodes even for future/unknown classes; consumers drop unknowns via
* [hasKnownClass]. The tolerant list decode (per-element drop, non-array -> empty) is A8.
*/
@Serializable
public data class TimelineEvent(
/** Server ingest timestamp (ms since epoch). */
val at: Long,
/** Server-derived semantic class; see [KNOWN_CLASSES]. JSON key is `class`. */
@SerialName("class") val eventClass: String,
/** Sanitized tool name (server-side: <=200 chars, control chars stripped). */
val toolName: String? = null,
/** Server-derived human phrase ("ran Bash", "edited 3 files"). */
val label: String,
) {
/** True when [eventClass] is one the server currently emits. */
public val hasKnownClass: Boolean get() = eventClass in KNOWN_CLASSES
public companion object {
/** The server's `TimelineClass` union (`src/types.ts:429`). */
public val KNOWN_CLASSES: Set<String> = setOf("tool", "waiting", "done", "stuck", "user")
}
}

View File

@@ -0,0 +1,62 @@
package wang.yaojia.webterm.wire
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Client-side tuning constants (FROZEN contract, plan §3.2.1). The Android analogue of iOS
* `WireProtocol.Tunables`. Downstream modules (`:session-core` PingScheduler/AwayDigest/
* TitleSanitizer, `:transport-okhttp`, `:api-client`) read these — adding/changing one is a
* coordination point. All named; no magic numbers anywhere downstream.
*/
public object Tunables {
/**
* WS keep-alive ping period. OkHttp's `pingInterval` could drive this, but the miss policy
* ([PONG_MISS_LIMIT]) is driven by a `PingScheduler` on an injected clock so a half-dead
* connection ("looks connected but dead") is detected deterministically in tests.
*/
public val PING_INTERVAL: Duration = 25.seconds
/**
* Consecutive missed pongs after which the connection is declared dead: 1 miss is
* tolerated, the 2nd is a disconnect signal. Any answered ping resets the counter.
*/
public const val PONG_MISS_LIMIT: Int = 2
/** Foreground `/live-sessions` polling period (mirrors `public/launcher.ts` REFRESH_MS). */
public val LIST_POLL_INTERVAL: Duration = 5.seconds
/**
* Telemetry chips grey out when [StatusTelemetry.at] is older than this. Mirrors the server
* default STATUSLINE_TTL_MS (`src/config.ts`); the server value is env-overridable at
* runtime while the client bakes the default — accepted drift (plan §3.2.1).
*/
public const val TELEMETRY_STALE_TTL_MS: Long = 30_000L
/** Away-digest banner auto-fade delay. */
public val DIGEST_FADE_DELAY: Duration = 8.seconds
/**
* Max session-title length after sanitisation; OSC titles are host/attacker-controlled
* input (consumed by `:session-core` TitleSanitizer, A6).
*/
public const val TITLE_MAX_LENGTH: Int = 256
/**
* Overall pairing-probe deadline: if either probe step hangs past this the probe resolves
* `timeout` (`:api-client` pairing, A9).
*/
public val PAIRING_PROBE_TIMEOUT: Duration = 10.seconds
/**
* Client WS frame cap. The default (1 MiB on most stacks) is too small: ring-buffer replay
* arrives as ONE full-snapshot frame and JSON escaping inflates control bytes 1-6x, so the
* worst case is ~6 x SCROLLBACK_BYTES (default 2 MiB) plus envelope -> 16 MiB.
*
* COUPLING WARNING: SCROLLBACK_BYTES is a server env knob the client cannot discover at
* runtime. If the host raises it past ~2.7 MB (or replay is extremely escape-dense), the
* receive fails and MUST be classified as the non-retryable `replayTooLarge` failure —
* never fed into the backoff reconnect loop (plan §3.2 / §9 R6).
*/
public const val MAX_WS_MESSAGE_BYTES: Long = 16L * 1024 * 1024
}

View File

@@ -0,0 +1,51 @@
package wang.yaojia.webterm.wire
/**
* Boundary validation — the Android analogue of the server's `parseClientMessage` guards
* (`src/protocol.ts`) and iOS `WireProtocol.Validation`. Pure functions, NEVER throw.
*
* These are the primitives A4's [MessageCodec] uses at the trust boundary; they are also
* exposed so a caller can validate BEFORE sending (the server SILENTLY DISCARDS invalid
* frames — no error reply comes back, `src/protocol.ts:45-86`).
*/
public object Validation {
/**
* Byte-identical port of the server's `SESSION_ID_RE`
* (`src/protocol.ts:22-23`, exported there; housed here for the Android build):
* `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$` case-insensitive.
*
* UUID v4 — 8-4-4-4-12 hex groups, version nibble `4`, variant nibble `8/9/a/b`, `/i`
* (M7). `crypto.randomUUID()` values pass; strings like `"abc123"` are rejected.
*/
public val SESSION_ID_RE: Regex =
Regex(
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
RegexOption.IGNORE_CASE,
)
/** True when [candidate] is a valid UUID-v4 session id (whole-string match, M7). */
public fun isValidSessionId(candidate: String): Boolean = SESSION_ID_RE.matches(candidate)
/**
* Mirrors the server's `isValidDimension` range check for `resize`: both cols and rows
* must fall in [WireConstants.RESIZE_RANGE] (1..1000). Frames outside the range are
* silently dropped by the server — validate before sending (`src/protocol.ts:113-115`).
*/
public fun isValidResize(cols: Int, rows: Int): Boolean =
cols in WireConstants.RESIZE_RANGE && rows in WireConstants.RESIZE_RANGE
/**
* Mirrors the server's `attach.cwd` check: must be an absolute path (leading `/`). Deeper
* normalisation stays server-side (`src/protocol.ts:142-149`, Sec L2).
*/
public fun isAbsoluteCwd(candidate: String): Boolean = candidate.startsWith("/")
/**
* Client-frame `type` whitelist (the server's `ALLOWED_TYPES`, `src/protocol.ts:27`).
* Delegates to the frozen [ClientMessageType] enum so the whitelist has ONE source.
*/
public fun isAllowedClientType(type: String): Boolean = ClientMessageType.fromWire(type) != null
/** Server-frame `type` whitelist (`src/types.ts:109-120`), the [ServerMessageType] enum. */
public fun isAllowedServerType(type: String): Boolean = ServerMessageType.fromWire(type) != null
}

View File

@@ -0,0 +1,26 @@
package wang.yaojia.webterm.wire
/**
* Wire-level constants shared with the server (FROZEN contract, plan §3.1). The Android
* analogue of iOS `WireProtocol.WireConstants`. No magic numbers downstream.
*/
public object WireConstants {
/** WS endpoint path (`src/config.ts` DEFAULT_WS_PATH). Stamped into [HostEndpoint.wsUrl]. */
public const val WS_PATH: String = "/term"
/**
* Soft-reset prefix (ESC `[0m`) the server prepends to ring-buffer replay as a safety net
* (`src/types.ts:167-170` / M2). Clients may use it to recognise the replay boundary;
* NEVER strip it — it is valid ANSI the emulator must consume.
*/
public const val REPLAY_SOFT_RESET_PREFIX: String = "\u001B[0m"
/**
* `exit.code` value meaning the PTY spawn never succeeded (M4); `exit.reason` is required
* server-side in that case. Not a retryable state.
*/
public const val SPAWN_FAILED_EXIT_CODE: Int = -1
/** Valid `resize` cols/rows range, inclusive (`src/protocol.ts:113-115`). */
public val RESIZE_RANGE: IntRange = 1..1000
}

Some files were not shown because too many files have changed in this diff Show More