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:
42
android/.gitignore
vendored
Normal file
42
android/.gitignore
vendored
Normal 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
95
android/README.md
Normal 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.
|
||||
27
android/api-client/build.gradle.kts
Normal file
27
android/api-client/build.gradle.kts
Normal 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()
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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。")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ~150–250 chars but the ceiling is undocumented. */
|
||||
private const val MAX_LENGTH = 4096
|
||||
|
||||
/** base64url alphabet (`A–Z a–z 0–9 - _`) 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
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 迷信)")
|
||||
}
|
||||
}
|
||||
@@ -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":"[0mreplay"}""")
|
||||
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"}"""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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":"[0mhello"}""".toByteArray(),
|
||||
)
|
||||
val preview = client.preview(ID)
|
||||
assertEquals(ID, preview.id)
|
||||
assertEquals(100, preview.cols)
|
||||
assertEquals("[0mhello", 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
48
android/app/build.gradle.kts
Normal file
48
android/app/build.gradle.kts
Normal 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
8
android/build.gradle.kts
Normal 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
|
||||
}
|
||||
30
android/client-tls-android/build.gradle.kts
Normal file
30
android/client-tls-android/build.gradle.kts
Normal 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")
|
||||
}
|
||||
*/
|
||||
26
android/client-tls/build.gradle.kts
Normal file
26
android/client-tls/build.gradle.kts
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
10
android/gradle.properties
Normal 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
|
||||
37
android/gradle/libs.versions.toml
Normal file
37
android/gradle/libs.versions.toml
Normal 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" }
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
android/gradlew
vendored
Executable 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
82
android/gradlew.bat
vendored
Normal 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%
|
||||
33
android/host-registry/build.gradle.kts
Normal file
33
android/host-registry/build.gradle.kts
Normal 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")
|
||||
}
|
||||
*/
|
||||
28
android/session-core/build.gradle.kts
Normal file
28
android/session-core/build.gradle.kts
Normal 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()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
38
android/settings.gradle.kts
Normal file
38
android/settings.gradle.kts
Normal 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")
|
||||
31
android/terminal-view/build.gradle.kts
Normal file
31
android/terminal-view/build.gradle.kts
Normal 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")
|
||||
}
|
||||
*/
|
||||
28
android/test-support/build.gradle.kts
Normal file
28
android/test-support/build.gradle.kts
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
25
android/wire-protocol/build.gradle.kts
Normal file
25
android/wire-protocol/build.gradle.kts
Normal 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()
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user