Merge android-blocker-fixes: make the Android client work on a device

The client reported COMPLETE with 617 green tests and a buildable APK, but had
never run on hardware, and the first interaction on a device crashed it. Seven
real defects, four of them crashes:

  - every key press NPE'd (no TerminalViewClient was ever installed)
  - the PTY never learned the real grid (no resize was ever sent)
  - bare-LAN ws:// could not connect (a STUB network-security-config)
  - swipe-scrolling inside an alternate-screen TUI NPE'd, i.e. during ordinary
    use of Claude Code, which is one
  - any password manager crashed the app (unguarded autofill)
  - onDraw tore the terminal buffer and killed the process — the defect the docs
    had explicitly classified as 'Accepted (not a defect)', and which was dormant
    only because resize never happened
  - text selection's Copy button was an NPE, so copy-out was cut rather than
    shipped broken

Also: the queue frame and status.preview were silently dropped (remote approvals
were blind), the v0.6 git panel was unrendered, ThumbnailPipeline/QuickReply/
PointerContextMenu were tested dead code with no call sites, the WEBTERM_TOKEN
gate was inert, release builds emitted unsigned APKs, and A34/A35 had no code at
all despite being marked done.

Then the audit's own leftovers: silent certificate rotation (which surfaced a bug
that would have failed every renewal, plus a latent iOS defect Android now
avoids), /device/:id/recover, the W2 inject-queue routes, unread-watermark
persistence, host removal, and /config/ui finally honoured fail-closed.

617 -> 1150 JVM tests, plus 67 instrumented tests that actually execute on an
emulator against this repo's own server. The device crashes are reproduced as
JVM tests where possible, so they are guarded without hardware.

Still open and recorded, not papered over: S2 needs two physical handsets under
Doze; the rest of DEVICE_QA_CHECKLIST has no automated cover.

# Conflicts:
#	docs/PROGRESS_LOG.md
This commit is contained in:
Yaojia Wang
2026-07-30 16:09:22 +02:00
187 changed files with 24095 additions and 449 deletions

3
android/.gitignore vendored
View File

@@ -40,3 +40,6 @@ service-account*.json
# Kover / test reports
**/kover/
# Kotlin compiler session/error scratch (KGP 2.x writes this at the project root)
.kotlin/

View File

@@ -1,19 +1,133 @@
# Android client — device-QA checklist (A36 / plan §7)
Everything below is **device/emulator-only** it could NOT run in the build environment (no emulator,
no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests,
Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping.
Everything below is **device/emulator-only**: it cannot be proven by the JVM suite. The pure logic
underneath each item IS unit-tested (757 JVM tests across 10 modules, Kover ≥80% on the pure modules),
but the 2026-07-30 audit established the hard lesson this file now exists to enforce — **a green JVM
suite says nothing about whether the app works.** Three crash-on-first-keypress defects and a
cleartext posture that made bare-LAN connection impossible all sat behind 484 passing tests, because
no JVM test instantiates an Android `View` and no JVM test dials a socket.
**Rules for this file:** tick a box ONLY after observing the behaviour on real hardware (or, where
noted, an emulator) — and say what you observed. Never tick something because the code looks right or
a unit test covers it. Unticked is the honest default.
---
## Observed so far (the only on-device evidence that exists)
**2026-07-30, minified `benchmark` variant, `webterm` AVD (android-35, arm64, software GPU):**
- [x] The **R8-minified, resource-shrunk, non-debuggable APK installs and cold-starts.** `am start -W`
`Status: ok`, `LaunchState: COLD`, `TotalTime: 1085 ms` (software-GPU emulator — not a
meaningful performance number, only proof of a clean start). Process stayed alive;
`logcat -b crash` empty; no `FATAL EXCEPTION`, `NoClassDefFoundError` or `ClassNotFoundException`.
This is the check that R8 keep rules are correct — the class of bug that appears ONLY in release.
- [x] **Cold-start route with no paired host → pairing screen**, rendered correctly under R8
(`ColdStartPolicy`, A29): title 配对主机, the 手动输入/扫码 segmented control, the
`主机地址http(s)://…)` field and a disabled 下一步 button. Compose + Material 3 + the design-token
theme + Hilt injection therefore all survive minification.
- [x] **`POST_NOTIFICATIONS` runtime prompt appears** on first launch (API 33+ path, A30).
- [x] **ML Kit component registration** no longer fails under R8 (`ComponentDiscovery` clean). Before
the `ComponentRegistrar` keep rule it logged
`NoSuchMethodException: com.google.mlkit.common.internal.CommonComponentRegistrar.<init> []`,
which would have silently disabled **QR barcode scanning in release builds only**.
**Expected/benign in the same run:** `W FirebaseApp: Default FirebaseApp failed to initialize because
no default options were found` — there is no `google-services.json` yet (see the deploy artifacts
below). A `System UI isn't responding` dialog also appeared; that is the emulator's own `com.android.systemui`
under software GPU, not this app (our process logged no ANR and stayed `topResumedActivity`).
**Everything else in this file is unobserved.** Notably: no real WebTerm host has ever been connected
to from Android, no terminal bytes have ever been rendered on a device, and no push has ever arrived.
---
## Deploy artifacts to provide first (not built here)
- [ ] **Release signing credentials.** `:app:assembleRelease` deliberately FAILS at packaging until they
exist — it will never emit an unsigned APK. Provide `webterm.release.{storeFile,storePassword,keyAlias,keyPassword}`
in `android/keystore.properties` (see `keystore.properties.example`; confirm it is gitignored
first) or `android/local.properties`, or the `WEBTERM_RELEASE_*` env vars. Keep the keystore
out of the repo — a lost signing key cannot be recovered.
- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its
absence with `runCatching`, so the app runs without it; push just won't register).
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
omitted it — applying it without the json fails the build).
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the
`FCM_*` env group (service-account key path + project id).
SHA-256 (for the verified App Link `autoVerify`). Read it with
`keytool -list -v -keystore <ks> -alias <alias> | grep SHA256`. Server-side: run `A33`
`src/push/fcm.ts` with the `FCM_*` env group (service-account key path + project id).
---
## FIRST PRIORITY — confirm the 2026-07-30 blocker fixes on a device
These were the defects that made the app unusable on hardware. All are fixed and JVM-verified; **none
has been exercised on a device.** Until this block is ticked, nothing further in this file is worth
running, because every one of these sits on the path to the terminal.
- [ ] **Typing does not crash.** Soft-keyboard keys, Enter, Backspace via the installed
`WebTermTerminalViewClient` / `RemoteTerminalHostView` IME contract. (Was: NPE on the first
keypress — stock `TerminalView.onKeyDown` dereferences `mClient`, then `mTermSession`.)
- [ ] **Swipe-scrolling inside an alternate-screen TUI does not crash.** Claude Code IS an
alternate-screen TUI, so this is ordinary use, not an edge case: drag vertically while a Claude
session is running. Also test the fling and, on a device with a mouse/trackpad, wheel scroll
(`onGenericMotionEvent`). (Was: `doScroll``handleKeyCode``mTermSession` NPE.)
- [ ] **A password manager does not crash the app.** Open the app with an autofill service enabled and
focus the terminal. (Was: unguarded `autofill()` while the view advertised itself autofillable;
the subtree is now excluded from the autofill structure.)
- [ ] **`resize` actually reaches the PTY.** A full-screen TUI fills the screen instead of rendering
into an 80x24 box, and reflows on rotation. (Was: `updateSize` had zero call sites.) Confirm the
cols×rows the server reports match what `TerminalResizeDriver` computed from
`TerminalRenderer.getFontWidth()/getFontLineSpacing()`.
- [ ] **Bare-LAN `ws://` connects.** Pair with `http://<lan-ip>:3000` and attach. (Was:
`network_security_config.xml` was a stub denying all cleartext while `HostEndpoint` derives
`ws://` from `http://`.)
- [ ] **The tunnel domain is still TLS-only.** `http://<anything>.terminal.yaojia.wang` must be refused
(the one hostname with `cleartextTrafficPermitted="false"`), and `OkHttpClientFactory` must
refuse a plaintext dial to a non-private host even if the config would allow it.
- [ ] **§6.4 device-switch reclaim.** Attach the same session from a phone and a tablet; the device you
last touched drives the PTY size (latest-writer-wins) and reclaims full screen on
pane-show/window-focus without a re-attach.
- [ ] **`WEBTERM_TOKEN` gate end-to-end** against a server started WITH the token: REST + the WS
upgrade both carry the cookie, it survives a process restart (sealed with Tink AEAD under an
AndroidKeyStore key), and a **seal failure persists nothing** rather than falling back to
plaintext. Also verify the cookie never appears in `logcat`.
⚠️ **Blocked until the cookie jar + cipher are installed in `:app`'s DI** — until then the gate
is inert at runtime no matter what the server does.
- [ ] **Newly-wired surfaces that had ZERO call sites before this session** — each needs a first-ever
look: session-list **preview thumbnails** (`ThumbnailPipeline`), **quick-reply chips + palette**
(`QuickReply`), and the tablet **pointer context menu** (`PointerContextMenu`).
- [ ] **Newly-decoding server frames.** The `queue` frame ("N queued" badge) and `status.preview` were
both being dropped as undecodable — the latter meant remote approvals were **blind**. Confirm a
real gate now shows the command being approved, and that a malformed preview does not cost the
whole frame (the gate must still appear).
---
## KNOWN FEATURE GAP — copy-out via text selection is unavailable (deliberate)
**Not a bug to be fixed by re-enabling stock selection.** At the pinned Termux v0.118.0, the stock
`ActionMode`'s own handlers dereference the `TerminalSession` this app deliberately does not have
(`TextSelectionCursorController$1.onActionItemClicked`, offsets 89-100 for Copy and 126-136 for Paste).
`TerminalSession` is `final` and its constructor forks a local process over JNI — exactly what a remote
terminal must not do (plan §6.1). So making the selection UI reachable would put a guaranteed NPE on
the **Copy** button, which is worse than not offering it. Long press is therefore consumed and
selection never starts.
**The real fix** (not built, tracked): a first-party selection layer — hit-test to a cell, own the
selection anchors, read the text straight out of `TerminalBuffer`, and write it with `ClipboardManager`.
Paste-in is unaffected and already works.
- [ ] Confirm on a device that long press is inert and does **not** show a broken Copy affordance.
---
## A34 — instrumented E2E vs a real `npm start` host
Infrastructure now EXISTS (`:app` has androidTest deps, a Hilt-aware `testInstrumentationRunner`, and
Espresso + Compose-UI-test on the classpath) but **the tests themselves are not written**. See
`android/README.md` → "Instrumented tests" for the one runner class that must be authored first.
## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device)
- [ ] `attach → attached → output` round-trip timing.
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy.
@@ -22,15 +136,19 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
## A35 — one macrobenchmark / Espresso happy path
The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumenting `:app`'s
`benchmark` variant out-of-process); it has **no sources yet**.
- [ ] pair → attach → type → approve, on a real device.
- [ ] cold-start timing on real hardware (`StartupTimingMetric`). The 1085 ms above is a software-GPU
emulator number and must not be quoted as a baseline.
## Terminal (A16/A17/A21)
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
WebView fallback ONLY if fidelity diverges — not expected).
- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap.
- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are
package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); resize parity vs
web/iOS on the same device sizes (R5).
- [ ] IME/CJK composition; http/https link tap. (Selection→clipboard is the gap above, not a test.)
- [ ] **real font-metric → grid resize** and resize parity vs web/iOS on the same device sizes (R5).
- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
generation bump → fresh emulator replays.
@@ -47,22 +165,27 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals
on rotation / new host / removal.
- [ ] token registers to every paired host + self-heals on rotation / new host / removal.
- [ ] **In a MINIFIED build specifically** — FCM and ML Kit both discover components reflectively, so
re-verify push registration and QR scanning on the `benchmark`/release variant, not just debug.
## Pairing / cert / storage (A19/A27/A11/A12)
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry.
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. **Run this on a minified
variant too** (see the `ComponentRegistrar` note above — this path is the one R8 already broke once).
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
bypass); public host explicit-ack.
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
- [ ] **Cert summary does not crash** — see the `javax.naming` defect under "Known gaps"; the issuer-CN /
expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY.
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
## Nav / deep links / adaptive (A32/A26/A29)
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
right destination; invalid UUID ignored.
- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens.
right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and
`assetlinks.json`.)
- [ ] continue-last-session banner re-opens. (The no-host → pairing half is observed above.)
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
@@ -85,11 +208,32 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
---
## Known gaps (tracked, non-blocking for QA but must be routed to an owner)
- [ ] **`:client-tls` uses `javax.naming.ldap.LdapName`, which does not exist on Android.** Found by R8
(`Missing class javax.naming.ldap.LdapName ... referenced from CertificateSummaryReader.commonName`).
On-device that call throws `NoClassDefFoundError`, an **`Error`** — so it slips straight through the
function's `catch (_: Exception)` guard and propagates out of `summarize()`. The module is pure-JVM
and its unit tests run on a JDK where `LdapName` DOES exist, which is exactly why the green suite
never caught it. Effect: the device-cert screen's issuer-CN/expiry summary (A27) crashes.
Fix: hand-parse the RFC2253 DN (or at minimum catch `Throwable`). The `-dontwarn` in
`proguard-rules.pro` only unblocks R8; it changes nothing at runtime.
- [ ] **The auth cookie jar + cipher are not installed in `:app`'s DI**, so the `WEBTERM_TOKEN` gate is
inert at runtime even though every piece is implemented and tested.
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView`
is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to
`:app` and delete the shim.
- [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a
`dataExtractionRules` `<device-transfer>` xml resource. Until then a D2D migration could copy the
sealed auth cookie to another handset.
- [ ] AGP's lint crashes with `FirDeclaration was not found for class KtProperty, fir is null` on
`ThumbnailPipeline.kt` when run under `--rerun-tasks`. `lintVitalRelease` passes normally and from
a cold lint state; this is a lint/K2 internal bug (lint says so itself), not an app defect. Do not
use `--rerun-tasks` in a release gate.
> RESOLVED since the last revision: the A21 reflection shim (`getMethod("getTerminalView")`) is **gone** —
> `RemoteTerminalHostView` now holds the stock `com.termux.view.TerminalView` directly, so there is no
> reflective getter left to break under R8 and no keep rule is needed for one.

View File

@@ -372,6 +372,99 @@ warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid
---
## ⚠️ CORRECTION (2026-07-30): "COMPLETE" below meant COMPILES, not WORKS
The section that follows claimed all 36 tasks had landed. That was true at the level it was measured —
the modules built, the APK assembled and ~484 JVM tests were green — but NOTHING had ever run on an
Android device or emulator, and the first thing that would have happened on one is a crash. An audit on
2026-07-30 found, and a repair pass fixed, the following. Read this before trusting anything below.
**Three defects made the app unusable on real hardware.** Each was invisible to the JVM suite for the
same structural reason: no JVM test instantiates an Android `View`.
1. **Every key press crashed.** `RemoteTerminalView` bound the forked emulator but never installed a
`TerminalViewClient`. The pinned Termux `TerminalView` dereferences `mClient` with no null guard on
the first line of the paths a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
offset 0, `onKeyUp`, `onKeyPreIme`). The KDoc claimed "A17 sets it" — A17 never did; only the
`HardwareKeyRouter` and the KeyBar shipped, and the KeyBar works precisely because it bypasses the
view. Installing a client is not sufficient: returning false continues into `mTermSession.write()`
at offset 150, and `mTermSession` is null forever (`TerminalSession` is final and forking a process
is exactly what this app must not do, §6.1). Fixed by a client that consumes the key itself, with
`KeyRouting` deliberately having NO defer-to-stock branch, so the crash is unrepresentable rather
than merely avoided. `KeyHandler` remains the authority for the key tables, so DECCKM still emits
`ESC O A`.
2. **No `resize` was ever sent.** `RemoteTerminalSession.updateSize` had zero call sites and the stock
fallback is dead (`TerminalView.updateSize` early-returns without a session), so the PTY stayed at
the server's 80x24 and every full-screen TUI rendered into the wrong box. `TerminalResizeDriver`
now drives it from real cell metrics, read via the PUBLIC `TerminalRenderer.getFontWidth()` /
`getFontLineSpacing()` accessors — no reflection (silently breaks under R8) and no re-measured
`Paint` (drifts from what the renderer actually draws, the §R5 risk).
3. **Bare-LAN connections were impossible.** `network_security_config.xml` was a self-labelled STUB
denying all cleartext, while `HostEndpoint` accepts `http` and derives `ws://`. Its own comment
promised a CIDR allowlist the platform cannot express. See plan §6.9 for the corrected posture.
**Adversarial review then found three more, and got one wrong:**
- **Swipe-scrolling inside an alternate-screen TUI still crashed.** The touch path bypasses
`TerminalViewClient` entirely: `doScroll` converts a scroll to `handleKeyCode` whenever the
alternate buffer is active, which dereferences `mTermSession`. Claude Code IS an alternate-screen
TUI, so this was a crash during ordinary use on the app's main screen. `TerminalScrollGesture` now
claims the vertical drag first and reproduces all three `doScroll` branches, closing the fling
runnable and `onGenericMotionEvent` with it.
- **`autofill()` dereferences `mTermSession` unguarded** while the view advertises itself
autofillable, so any password manager crashed the app. The subtree is excluded from the autofill
structure.
- The review also demanded stock text selection be RESTORED after the focus rework made it
unreachable. It cannot be: the ActionMode's own Copy and Paste handlers dereference the absent
session (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136), so a
reachable selection UI has an NPE on its Copy button. **Long press stays consumed and copy-out is
a recorded FEATURE GAP** — a first-party selection layer over `TerminalBuffer` + `ClipboardManager`
is the real fix. This is the one place the reviewer was wrong and the builder right.
**Silently-lost server data, now decoded:** the W2 `queue` frame had no `ServerMessageType` member so
every one was dropped as undecodable (the "N queued" badge could never appear), and W1
`status.preview` was not modelled at all, which made remote approvals **BLIND** — the user was asked to
approve a `Bash` call without seeing the command. Both now decode, with the rule that a broken preview
never costs the frame (`pending` is what makes the gate appear).
**Dead code that shipped as features:** `ThumbnailPipeline` (session-list previews), `QuickReply`
(chips + palette) and `PointerContextMenu` were fully implemented and unit-tested with ZERO production
call sites. All three are now wired. `ThumbnailPipeline`'s formal cross-review — explicitly skipped at
the time, as the AW3 entry below admits — has finally been run.
**Credential handling:** the `WEBTERM_TOKEN` cookie is now carried on REST and the WS upgrade, sealed
with Tink AEAD under an AndroidKeyStore key, **fail-closed** (a seal failure persists nothing rather
than falling back to plaintext), and `allowBackup=false` keeps a 30-day shell credential out of Google
Drive and D2D transfer.
**RESOLVED LATER THE SAME DAY** (this list was accurate when written; it is not any more):
- **A34 / A35 now exist and RUN.** `:app/src/androidTest/**` holds 67 instrumented tests — the four
crash regressions, the A34 E2E set (attach→output, ring-buffer replay, spawn failure via a second
deliberately-broken server, a gate resolved through `POST /hook/decision`, and the F9 bad-Origin
rejection), and the Compose UI tests plan §7 named — plus `macrobenchmark/` for A35. All 67 execute
green on `emulator-5554` against this repo's own server. Reaching the host needs
`ALLOWED_ORIGINS=http://10.0.2.2:<port>` (the server derives allowed origins from NIC IPs, and
10.0.2.2 is the emulator's alias for the host, not an interface address).
- **Device QA is no longer ~0%.** The blocker fixes are device-verified, not just JVM-verified.
- Release signing, versioning and minification are done: `assembleRelease` runs R8 and now REFUSES to
emit an unsigned artifact instead of quietly producing one.
- **Copy-out is done** — a first-party selection layer over `TerminalBuffer.getSelectedText` +
`ClipboardManager`, since the stock ActionMode's Copy handler dereferences the absent session.
- The six audit leftovers are closed too: silent certificate rotation (+ `/device/:id/recover`), the W2
inject-queue routes, unread-watermark persistence, host-removal UI, and `/config/ui` finally honoured
(fail-closed).
**Still genuinely open:**
- **S2** — the FCM real-handset delivery spike. Needs ≥2 physical devices under Doze/OEM battery
managers; an emulator cannot answer it.
- One instrumented test (`wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch`) was seen to kill the
test process when the suite was driven WITHOUT the host arguments on a heavily-recycled emulator. It
passes reproducibly under the documented invocation on a clean install. Re-check on real hardware
before trusting it either way.
- Everything else device-observable in `DEVICE_QA_CHECKLIST.md` that no automated test covers.
---
## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1A36 + S1) landed
Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure

View File

@@ -9,6 +9,13 @@ This directory is a **Gradle multi-module** project. The module set mirrors the
SPM package set and inherits its rule: *dependencies only flow down; nothing points
upward* (ARCHITECTURE §1).
> **State, honestly:** the app builds, minifies, signs (given a keystore) and cold-starts
> to the pairing screen on an emulator. It has **never talked to a real WebTerm host from
> a device**, and almost nothing in
> [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md) is ticked. Version is
> `0.1.0-alpha01` for exactly that reason. Read that checklist before calling anything
> here done.
## Build environment (SDK installed — all modules build)
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
@@ -19,10 +26,12 @@ against SDK 35/36.
`:client-tls`, `:test-support`, `:transport-okhttp`.
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
- **Instrumented-only:** `:macrobenchmark` (`com.android.test`; assembles here, runs only
on a device/emulator).
Setup: `local.properties``sdk.dir=/usr/local/share/android-commandlinetools`;
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
`./gradlew test :app:assembleDebug koverVerify`.
`./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest koverVerify`.
## Module map (mirror of the iOS SPM packages — plan §3)
@@ -37,10 +46,15 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
| — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources |
> 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).
`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a
`com.android.test` module — a **separate APK** that drives `:app` out of process via
UiAutomator, which is the only way startup/frame timings are real. It therefore cannot
violate "dependencies only flow down": nothing depends on it, and it reaches `:app`
through `targetProjectPath`, not a `project()` dependency. It instruments `:app`'s
`benchmark` variant (see "Build types" below). The benchmark sources themselves are not
written yet.
### Dependency graph (arrows = "depends on")
@@ -82,15 +96,124 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
```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)
./gradlew help # sanity: the build configures
./gradlew projects # lists every module
./gradlew test # JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
./gradlew :app:assembleDebug # the installable debug APK
./gradlew koverVerify # the ≥80% gate on the pure modules
# Release path (needs a keystore — see "Release signing")
./gradlew :app:assembleRelease # R8 + resource shrinking + signing
./gradlew :app:lintVitalRelease # the release-blocking lint subset; must be clean
# Minified build WITHOUT a keystore — the practical way to test R8 keep rules
./gradlew :app:assembleBenchmark # same shrinking as release, debug-signed → installable
```
> 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.
>
> **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug
> (`FirDeclaration was not found for class KtProperty, fir is null`, on
> `ThumbnailPipeline.kt`). `lintVitalRelease` passes normally and from a cold lint state.
## Build types
| Type | Minified | Shrunk res | Debuggable | Signed with | Purpose |
|---|---|---|---|---|---|
| `debug` | no | no | yes | debug key | development; stable applicationId for deep-link tests (A32) |
| `release` | **yes** | **yes** | no | **release key (required)** | the shipping artifact |
| `benchmark` | **yes** | **yes** | no | debug key | `initWith(release)` + `isProfileable`; what `:macrobenchmark` measures, and the only way to exercise R8 without a keystore |
`benchmark` exists because a benchmark must measure the code that actually ships. It sets
`isProfileable = true`, which makes AGP inject `<profileable android:shell="true"/>` into
the merged manifest — done there rather than in `AndroidManifest.xml` so the shipping
manifest carries no benchmark-only tag.
## Versioning
Current: `versionCode = 1`, `versionName = "0.1.0-alpha01"`.
- **`versionCode`** is a plain monotonic counter — **+1 for every artifact handed to anyone**
(a Play track, an APK sent to a tester, an archived benchmark build). It is deliberately
NOT derived from `versionName`; keeping them independent is what lets a hotfix ship
without renumbering. It is still `1` because no artifact has ever left the build machine;
the first distributed build takes `2`.
- **`versionName`** is `<server-line>-alphaNN`. The client tracks the server's `0.1.x` line
(root `package.json` is `0.1.0`). The `-alpha01` suffix is a factual claim about device
verification, not marketing:
- `-alphaNN` — builds, minifies, JVM-tested; **device QA essentially unstarted**. ← today
- `-betaNN` — the A34/A35 blocks of `DEVICE_QA_CHECKLIST.md` pass on real hardware.
- `0.1.0` — the whole checklist is ticked.
Bump the suffix on any user-visible change while still in alpha; move to `0.1.1-alphaNN`
only when the server line moves.
## Release signing
There is **no keystore in this repository and there must never be one.** Credentials are
read at configuration time from the first of these that has them:
1. `android/keystore.properties` — the conventional path. Copy
[`keystore.properties.example`](keystore.properties.example).
⚠️ **Confirm `keystore.properties` is listed in `android/.gitignore` before creating it.**
The existing rules cover `*.jks` / `*.keystore` / `*.p12` but not this filename.
2. `android/local.properties` — already gitignored, so it needs no new rule. Same four keys.
3. `WEBTERM_RELEASE_STORE_FILE` / `_STORE_PASSWORD` / `_KEY_ALIAS` / `_KEY_PASSWORD` — for CI.
Keys: `webterm.release.storeFile` (absolute, or relative to `android/`),
`.storePassword`, `.keyAlias`, `.keyPassword`.
**Degradation contract:** with no credentials, `debug`, `benchmark`, `assembleDebugAndroidTest`
and every test still work, and `:app:assembleRelease` **fails at `packageRelease`** with
instructions. It fails at *packaging*, deliberately after R8, so an unconfigured machine
still gets a full, verifiable R8 run. It never silently emits `app-release-unsigned.apk`
(which is exactly what it used to do).
**Archive `app/build/outputs/mapping/release/mapping.txt` with every distributed artifact**
release builds keep line numbers but obfuscate names, so without the mapping file a crash
report cannot be retraced.
### R8 / keep rules
`app/proguard-rules.pro` is deliberately short and every rule is justified in place; most
of the stack (kotlinx.serialization, Hilt, Tink, Firebase, OkHttp, Compose, CameraX)
ships its own consumer rules and must not be re-declared. Two rules are load-bearing and
were both derived from **observed** failures, not guesswork:
- `com.termux.**` is kept whole, because `:terminal-view` binds to non-API internals of the
pinned v0.118.0 (the public `TerminalView.mEmulator` field, `TerminalRenderer.getFontWidth()`)
and the JitPack artifacts ship no consumer rules.
- `implements com.google.firebase.components.ComponentRegistrar { <init>(); }`, because
without it R8 strips the reflectively-invoked constructors of ML Kit's registrars and
**QR scanning silently stops working in release builds only**.
Before adding a rule, read the merged configuration R8 actually used:
`app/build/outputs/mapping/<variant>/configuration.txt`. To validate rules, install the
`benchmark` APK and exercise the feature — a wrong keep rule is invisible in debug.
## Instrumented tests
`:app` has an androidTest classpath (androidx.test + Espresso + Compose UI test +
`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to
`wang.yaojia.webterm.HiltTestRunner`.
⚠️ **That runner class does not exist yet** — it is the first file the A34 author must write,
into `app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt`:
```kotlin
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
```
A custom runner is **required**, not stylistic: `newApplication` is the only hook that can
replace the app-under-test's `Application` with the generated `HiltTestApplication`. Setting
`android:name` in the androidTest manifest cannot do it — that manifest is merged into the
*test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the
class (the runner name is just a manifest value); an actual instrumentation *run* needs it.
## Android SDK setup (proven working)
@@ -127,5 +250,32 @@ AGP library module compiled against SDK 35 and produced an AAR):
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
To add more SDK pieces later (e.g. an emulator image for instrumented tests):
- **A `com.android.test` module cannot use a versioned plugin alias.** The root build
script already puts AGP on the classpath via the `android.library`/`android.application`
aliases, so a third versioned request for the same artifact fails with *"plugin is already
on the classpath with an unknown version"*. `:macrobenchmark` therefore applies
`id("com.android.test")` bare — same as `:terminal-view` with `com.android.library`. The
version is still pinned once, as `agp` in the catalog.
## Emulator (installed)
An AVD named **`webterm`** exists (android-35, arm64, software GPU) and is what produced the
on-device evidence recorded in [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md):
```bash
export ANDROID_HOME=/usr/local/share/android-commandlinetools
$ANDROID_HOME/emulator/emulator -avd webterm -gpu swiftshader_indirect &
$ANDROID_HOME/platform-tools/adb devices -l
# validate the MINIFIED build (this is what catches bad keep rules)
./gradlew :app:assembleBenchmark
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk
adb logcat -c && adb shell am start -W -n wang.yaojia.webterm/.MainActivity
adb logcat -d --pid=$(adb shell pidof wang.yaojia.webterm) | grep -E "FATAL|NoSuchMethod|NoClassDefFound"
```
Note it is a **software-GPU** emulator: it is fine for crash/keep-rule/route validation and
useless for performance numbers. Real macrobenchmark figures need physical hardware.
To add more SDK pieces later:
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.

View File

@@ -15,8 +15,11 @@ import wang.yaojia.webterm.wire.HttpTransport
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
* notBefore, notAfter, renewAfter }`
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The
* server schema is `.strict()`; NO keyAlg/subdomain/deviceName.
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 `{ cert, caChain,
* notAfter }`. The server schema is `.strict()`; NO
* keyAlg/subdomain/deviceName.
* `POST /device/:id/recover` [NO client cert — plain HTTPS] `{ cert, csr }` → 201 `{ cert,
* caChain, notAfter }`. The EXPIRED leaf's escape hatch.
*
* Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is
* sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
@@ -29,8 +32,12 @@ public class DeviceEnrollmentClient(
baseUrl: String,
private val http: HttpTransport,
) {
/** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */
private val base: String = baseUrl.trim().trimEnd('/')
/**
* Base control-plane URL with any trailing slash removed, so `baseUrl + path` is well-formed.
* Readable so a rotation driver can persist WHICH control plane a device enrolled with and renew
* against that same one (a URL is not a credential).
*/
public val baseUrl: String = baseUrl.trim().trimEnd('/')
/**
* One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is
@@ -98,7 +105,38 @@ public class DeviceEnrollmentClient(
)
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
// The renew 201 is `{ cert, caChain, notAfter }` — it does NOT echo deviceId, so the id we
// addressed the request to is the authoritative one.
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
}
/**
* Recover an EXPIRED leaf: `POST /device/:id/recover` carrying the lapsed [expiredCertDer] in the
* BODY plus a fresh [csrDer] over the SAME hardware key.
*
* **This call must NOT ride an mTLS transport.** `/renew` is authenticated by the very leaf it
* renews, so a lapsed leaf cannot renew itself — and no TLS terminator will even forward an expired
* client certificate (nginx answers a bare 400 under `ssl_verify_client optional`; `optional_no_ca`
* tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). The caller therefore supplies a
* PLAIN [HttpTransport] with no client identity; possession is proven by the CSR self-signature,
* which the control plane checks together with `CSR key == registered key`.
*
* No bearer is accepted on this path at all — the body is the whole credential story. Everything
* else the server enforces is unchanged: real X.509 path validation to the device-CA, SPIFFE parse,
* `notBefore` (never graced), `:id` must match the cert, and revocation still applies. Only
* `notAfter` is graced (30 days), so a leaf lapsed beyond that answers 401.
*/
public suspend fun recover(deviceId: String, expiredCertDer: ByteArray, csrDer: ByteArray): EnrollmentResult {
if (deviceId.isEmpty() || expiredCertDer.isEmpty() || csrDer.isEmpty()) {
throw DeviceEnrollmentError.InvalidRequest
}
val body = EnrollJson.encodeToString(
RecoverRequestBody.serializer(),
RecoverRequestBody(cert = base64(expiredCertDer), csr = base64(csrDer)),
)
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/recover"
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearer = null))
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
}
// ── Request/response plumbing ────────────────────────────────────────────────────────────
@@ -114,7 +152,7 @@ public class DeviceEnrollmentClient(
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
// string must never emit a bare "Bearer " header.
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody)
return HttpRequest(method = method, url = baseUrl + path, headers = headers, body = jsonBody)
}
/** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
@@ -127,6 +165,24 @@ public class DeviceEnrollmentClient(
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
}
/**
* Map a re-issuance (renew / recover) 201, whose body carries no `deviceId`, using the [deviceId]
* the request was addressed to. `renewAfter` is normally absent here — the rotation policy
* re-derives it from the leaf's own validity window (`RotationPolicy.renewAfterFor`).
*/
private fun toReissueResult(dto: RenewResponseDto, deviceId: String): EnrollmentResult =
EnrollmentResult(
deviceId = deviceId,
certificate = decodeDerOrThrow(dto.cert),
caChain = dto.caChain.map { decodeDerOrThrow(it) },
notBefore = parseInstantOrNull(dto.notBefore),
notAfter = parseInstantOrNull(dto.notAfter),
renewAfter = parseInstantOrNull(dto.renewAfter),
)
private fun decodeDerOrThrow(base64Der: String): ByteArray =
decodeBase64OrNull(base64Der) ?: throw DeviceEnrollmentError.MalformedResponse
private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
val chain = dto.caChain.map { entry ->

View File

@@ -97,6 +97,19 @@ internal data class EnrollRequestBody(
@Serializable
internal data class RenewRequestBody(val csr: String)
/**
* The `/device/:id/recover` request body — the EXPIRED leaf plus a fresh CSR over the same key.
*
* The lapsed certificate travels in the BODY (not as an mTLS client cert) because no TLS terminator
* forwards an expired client certificate: nginx answers a bare 400 under `ssl_verify_client optional`,
* and `optional_no_ca` tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`. Nothing is
* lost: the CSR is self-signed by the same private key and the control-plane signer enforces CSR
* proof-of-possession plus `CSR key == registered key`, so possession is proven exactly as the
* handshake used to prove it. The server schema is `.strict()` on these two keys.
*/
@Serializable
internal data class RecoverRequestBody(val cert: String, val csr: String)
@Serializable
internal data class LoginResponseDto(
val enrollToken: String,
@@ -114,6 +127,25 @@ internal data class EnrollResponseDto(
val renewAfter: String? = null,
)
/**
* The `/device/:id/renew` and `/device/:id/recover` 201 body — `{ cert, caChain, notAfter }` ONLY.
*
* Deliberately NOT [EnrollResponseDto]: the re-issuance routes do not echo `deviceId` (nor
* `notBefore`/`renewAfter`) — only `/device/enroll` does (`control-plane/src/api/renew.ts` vs
* `device-enroll.ts`). Decoding a renew 201 against the enroll DTO, whose `deviceId` is required,
* failed EVERY silent rotation with `MalformedResponse`. The device id is supplied by the caller (it
* is the path segment the request was addressed to, which is the authoritative value anyway), and the
* absent `renewAfter` is re-derived from the leaf by `RotationPolicy.renewAfterFor`.
*/
@Serializable
internal data class RenewResponseDto(
val cert: String,
val caChain: List<String> = emptyList(),
val notBefore: String? = null,
val notAfter: String? = null,
val renewAfter: String? = null,
)
@Serializable
internal data class ErrorDto(val error: String? = null)

View File

@@ -0,0 +1,150 @@
package wang.yaojia.webterm.api.enroll
import java.time.Duration
import java.time.Instant
/**
* Rotation timing of the installed device identity — the input to [RotationPolicy]. Android analogue
* of iOS `DeviceRenewalState`.
*
* [notAfter] is the leaf's HARD expiry (past it the TLS stack rejects the cert outright) and
* [renewAfter] is the advisory "renew from the same key now" instant. Only non-secret timing plus the
* non-secret [deviceId] (which is a URL path segment) surface here: the private key never leaves
* AndroidKeyStore and the cert bytes are never carried through this type, so it is safe to log.
*/
public data class DeviceRenewalState(
/** The enrolled device id — the `:id` in `POST /device/:id/renew` and `/device/:id/recover`. */
val deviceId: String,
val notAfter: Instant?,
val renewAfter: Instant?,
) {
/**
* Is the leaf due for renewal as of [now]? A missing [renewAfter] NEVER triggers (fail-safe — the
* TLS stack is the real gate; the scheduler only pre-empts expiry). Mirrors
* [EnrollmentResult.isRenewalDue] and iOS `DeviceRenewalState.isRenewalDue`.
*/
public fun isRenewalDue(now: Instant): Boolean {
val due = renewAfter ?: return false
return !now.isBefore(due) // now >= renewAfter
}
}
/** What a scheduler must do for one rotation pass — the total answer of [RotationPolicy.decide]. */
public enum class RotationDecision {
/** Nothing to do: the renew window has not opened (the cheap, common case). */
NOT_DUE,
/** An attempt IS due but the previous one failed too recently — wait, do not hammer. */
BACKING_OFF,
/** Still valid: re-CSR from the same key and renew over mTLS (`POST /device/:id/renew`). */
RENEW,
/**
* EXPIRED but inside the recovery grace: the leaf can no longer authenticate its own re-issuance,
* so recover over PLAIN HTTPS with the lapsed cert in the body (`POST /device/:id/recover`).
*/
RECOVER,
/** TERMINAL — expired past the grace window. No request can succeed; only a fresh enroll can. */
RE_ENROLL_REQUIRED,
}
/**
* The pure, JVM-testable rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
* `TerminalGridMath`). Given "now", the leaf's timing and the last failed attempt it answers
* renew / recover / wait / re-enroll — no clock, no keystore, no network, so every branch is a unit
* test rather than a device observation.
*
* Ported from iOS `CertificateRotationScheduler`'s timing rules, plus the two branches the phone
* track needs and iOS lacks (it can only renew, so an iOS device whose leaf lapses is stranded):
* - [RotationDecision.RECOVER] — the `/device/:id/recover` escape hatch, because no TLS terminator
* forwards an expired client certificate, so a lapsed leaf can never authenticate its own renewal.
* - [RotationDecision.RE_ENROLL_REQUIRED] — reported ONCE as terminal instead of retrying forever
* (the host-track agent logged the same failure 6380 times before this rule existed).
*/
public object RotationPolicy {
/**
* The control plane issues `renewAfter = notBefore + 2/3 · lifetime`
* (`control-plane/src/api/device-enroll.ts` `DEFAULT_RENEW_FRACTION`). The client mirrors the
* fraction as exact integer duration math so [renewAfterFor] lands on the same instant.
*/
private const val RENEW_FRACTION_NUMERATOR: Long = 2L
private const val RENEW_FRACTION_DENOMINATOR: Long = 3L
/**
* How long after `notAfter` a lapsed leaf may still be swapped via `/device/:id/recover`
* (30 days) — the same window the control plane's `DEFAULT_EXPIRED_RENEW_GRACE_MS` allows. Asking
* outside it is pointless: the server answers 401.
*/
public val DEFAULT_EXPIRED_RECOVERY_GRACE: Duration = Duration.ofDays(30)
/**
* Minimum gap after a FAILED attempt before another is made. A pass runs on every app
* foreground, and the control plane rate-limits renewals to 30/hour per identity, so an
* un-throttled retry converts one transient failure into a 429 storm.
*/
public val DEFAULT_FAILURE_BACKOFF: Duration = Duration.ofMinutes(15)
/**
* Derive the advisory renew instant from the leaf's own validity window.
*
* This derivation is LOAD-BEARING, not a convenience: `POST /device/:id/renew` and
* `POST /device/:id/recover` answer `{cert, caChain, notAfter}` only — neither returns
* `renewAfter` (only `/device/enroll` does). A client that persisted the enroll-time value and
* never recomputed it would rotate exactly once and then report "not due" until the cert died.
*
* Returns null when either bound is unknown or the window is not a lifetime (notAfter <=
* notBefore) — fail-safe, because [decide] treats a null [DeviceRenewalState.renewAfter] as
* never-due rather than inventing a past instant that would renew-storm.
*/
public fun renewAfterFor(notBefore: Instant?, notAfter: Instant?): Instant? {
if (notBefore == null || notAfter == null) return null
if (!notBefore.isBefore(notAfter)) return null
val lifetime = Duration.between(notBefore, notAfter)
return notBefore.plus(
lifetime.multipliedBy(RENEW_FRACTION_NUMERATOR).dividedBy(RENEW_FRACTION_DENOMINATOR),
)
}
/**
* The whole decision table, in priority order:
* 1. expired past [expiredRecoveryGrace] → [RotationDecision.RE_ENROLL_REQUIRED] (terminal; it
* outranks the backoff because no amount of waiting can help),
* 2. otherwise pick the desired action — expired → RECOVER, [DeviceRenewalState.isRenewalDue] →
* RENEW, else NOT_DUE,
* 3. an idle leaf stays [RotationDecision.NOT_DUE] (there is no attempt to throttle),
* 4. a desired action within [failureBackoff] of [lastFailureAt] → [RotationDecision.BACKING_OFF].
*
* @param lastFailureAt when the last attempt FAILED (null = none, or the last one succeeded).
*/
public fun decide(
state: DeviceRenewalState,
now: Instant,
lastFailureAt: Instant? = null,
expiredRecoveryGrace: Duration = DEFAULT_EXPIRED_RECOVERY_GRACE,
failureBackoff: Duration = DEFAULT_FAILURE_BACKOFF,
): RotationDecision {
require(!expiredRecoveryGrace.isNegative) { "expiredRecoveryGrace must not be negative" }
require(!failureBackoff.isNegative) { "failureBackoff must not be negative" }
val notAfter = state.notAfter
// Terminal first: past `notAfter + grace` nothing can succeed. A non-negative grace makes this
// strictly stronger than "expired", so it needs no separate expiry guard.
if (notAfter != null && now.isAfter(notAfter.plus(expiredRecoveryGrace))) {
return RotationDecision.RE_ENROLL_REQUIRED
}
val isExpired = notAfter != null && now.isAfter(notAfter)
val desired = when {
isExpired -> RotationDecision.RECOVER
state.isRenewalDue(now) -> RotationDecision.RENEW
else -> RotationDecision.NOT_DUE
}
if (desired == RotationDecision.NOT_DUE) return RotationDecision.NOT_DUE
if (lastFailureAt != null && now.isBefore(lastFailureAt.plus(failureBackoff))) {
return RotationDecision.BACKING_OFF
}
return desired
}
}

View File

@@ -14,6 +14,15 @@ public data class CommitLogEntry(
val hash: String,
val at: Long,
val subject: String = "",
/**
* w6/G4: reachable from HEAD but NOT from `@{u}` — i.e. this commit has not been pushed.
*
* Server-computed from `rev-list`, and deliberately NOT "the first N rows": `git log` is
* date-ordered, so merging an older branch interleaves unpushed commits BELOW pushed ones. The
* row-count shortcut fails in the dangerous direction — it would call an unpushed commit pushed.
* Absent ⇒ unknown (no upstream to compare against), which must not render as "pushed".
*/
val unpushed: Boolean? = null,
)
/**
@@ -25,7 +34,29 @@ public data class GitLogResult(
@Serializable(with = CommitLogEntryListSerializer::class)
val commits: List<CommitLogEntry> = emptyList(),
val truncated: Boolean = false,
)
/**
* w6/G4: upstream short name used to label the pushed/unpushed boundary. Null ⇒ nothing to compare
* against, so **no boundary may be drawn at all** — not a boundary drawn at position zero.
*/
val upstream: String? = null,
) {
/**
* Index of the LAST unpushed commit, or null when no boundary may be drawn.
*
* The boundary is drawn ONCE, after this index — never once per unpushed commit, and never at all
* when [upstream] is absent. Returns null rather than -1 so a caller cannot accidentally treat
* "no boundary" as "boundary before everything".
*/
public val upstreamBoundaryIndex: Int?
get() {
if (upstream == null) return null
val last = commits.indexOfLast { it.unpushed == true }
return last.takeIf { it >= 0 }
}
/** How many commits are waiting to be pushed; 0 when unknown or nothing is pending. */
public val unpushedCount: Int get() = commits.count { it.unpushed == true }
}
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
internal object CommitLogEntryListSerializer :

View File

@@ -42,6 +42,16 @@ public data class CommitResult(val commit: String = "")
@Serializable
public data class PushResult(val branch: String? = null, val remote: String? = null)
/**
* `POST /projects/git/fetch` 200 payload (w6/G2).
*
* [lastFetchMs] is FETCH_HEAD's mtime AFTER the fetch, so the UI can stop saying "stale" without
* re-probing. On FAILURE the server deliberately leaves the old value alone, which is why this is
* nullable: a fetch that did not happen must keep reading as stale rather than pretending it refreshed.
*/
@Serializable
public data class FetchResult(val lastFetchMs: Long? = null)
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
@Serializable
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)

View File

@@ -42,4 +42,9 @@ public data class LiveSessionInfo(
/** 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,
/** W2 pending prompt-queue depth (`src/types.ts` `queueLength?`) — what the "N queued" badge
* renders. Additive OPTIONAL: `null` on a pre-W2 server (queue unsupported / unknown), `0` when
* the queue is empty; the badge shows only for a positive value. Decoded tolerantly
* ([LossyIntSerializer]) so a garbled value can never hide a running session. */
@Serializable(with = LossyIntSerializer::class) val queueLength: Int? = null,
)

View File

@@ -17,6 +17,12 @@ public data class ProjectSessionRef(
val clientCount: Int,
val createdAt: Long,
val exited: Boolean,
/**
* w6/G7: the session's working directory, needed to attribute it to a worktree. Matching is
* DEEPEST-first, because `.claude/worktrees/<name>` lives INSIDE the main checkout and a prefix
* match would count every worktree session against the parent repo too.
*/
val cwd: String? = null,
)
/**
@@ -40,6 +46,8 @@ public data class ProjectInfo(
val behind: Int? = null,
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
val lastCommitMs: Long? = null,
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
val dirtyCount: Int? = null,
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
)
@@ -65,6 +73,10 @@ public data class ProjectDetail(
val isGit: Boolean,
val branch: String? = null,
val dirty: Boolean? = null,
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
val dirtyCount: Int? = null,
/** w6/G1: git repos only; null for a non-git dir. See [SyncState] for what may be trusted. */
val sync: SyncState? = null,
@Serializable(with = WorktreeInfoListSerializer::class)
val worktrees: List<WorktreeInfo> = emptyList(),
@Serializable(with = ProjectSessionRefListSerializer::class)

View File

@@ -0,0 +1,77 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* W2 prompt queue — the server-side FIFO of follow-up prompts injected into a session's PTY the next
* time Claude goes idle (`src/server.ts` ~605/644/654, `SessionManager.enqueueFollowup`/`clearQueue`).
*
* A queued entry is **raw keyboard input for a shell** (invariant #9 / byte-shuttle): it is stored and
* later written to the PTY verbatim. Nothing on this path may trim, escape, re-encode or normalise it —
* the only transformation is the server appending `\r` when the caller passed `appendEnter = true`.
*/
/**
* `GET /live-sessions/:id/queue` 200 → `{ length, items }` — the pending depth plus the queued texts
* (verbatim keystroke bytes). Both fields default, so a missing/garbled key degrades to an empty queue
* instead of throwing; a non-object body yields `null` from `LossyDecode` (→ `InvalidResponseBody`).
*/
@Serializable
public data class QueueSnapshot(
/** Pending entries on the server. Equals `items.size` for a healthy server; trust [items] for
* rendering and [length] only as the badge number the server itself broadcasts. */
val length: Int = 0,
/** The queued prompts in FIFO order, verbatim. */
val items: List<String> = emptyList(),
)
/**
* Outcome of the two GUARDED queue writes — `POST` (enqueue) and `DELETE` (cancel-all). One union for
* both ops (the `GitWriteOutcome` precedent: one union, six routes); [Full], [TooLarge] and [Disabled]
* are simply unreachable for the DELETE, whose handler neither validates text nor consults the
* `QUEUE_ENABLED` kill-switch.
*
* Every variant maps to different UI behaviour, which is why they are typed rather than folded into a
* status code:
* - [Ok] — the server accepted it; [length] is the authoritative new depth for the "N queued" badge.
* - [Full] — 409, the bounded FIFO is at `QUEUE_MAX_ITEMS` (default 10). Cancel something first;
* the server never silently drops.
* - [TooLarge] — 413, over `QUEUE_ITEM_MAX_BYTES` (default 4 KiB, server-configurable — the client
* deliberately does NOT pre-validate size, which would invent a policy it cannot know).
* - [Disabled] — 503, `QUEUE_ENABLED=0` on this host: hide the affordance, do not retry.
* - [SessionGone] — 404, unknown or already-exited session.
* - [RateLimited] — 429 from the shared `QUEUE_RATE_MAX = 20`/minute/IP bucket. **Never auto-retry**;
* a retry loop just burns the same budget a real enqueue needs.
* - [Rejected] — any other 4xx/5xx, carrying the server's SAFE `error` string verbatim (`message` may
* be `null` when the body is empty, e.g. the Origin guard's bare 403).
*/
public sealed interface QueueWriteOutcome {
/** 200 — accepted; [length] is the new pending depth (0 for a cleared queue). */
public data class Ok(val length: Int) : QueueWriteOutcome
/** 409 — the bounded FIFO is full (`QUEUE_MAX_ITEMS`). */
public data object Full : QueueWriteOutcome
/** 413 — the prompt exceeds the server's per-item byte cap. */
public data object TooLarge : QueueWriteOutcome
/** 503 — the queue feature is switched off on this host. */
public data object Disabled : QueueWriteOutcome
/** 404 — the session is unknown or has already exited. */
public data object SessionGone : QueueWriteOutcome
/** 429 — shared per-IP queue bucket. Do NOT auto-retry. */
public data object RateLimited : QueueWriteOutcome
/** Any other failure, with the server's inert message (null when unparseable/empty). */
public data class Rejected(val status: Int, val message: String?) : QueueWriteOutcome
}
/**
* Read the new depth out of a queue write's 200 body (`{ length }`). The status already says it
* succeeded, so a missing/garbled body degrades to `0` rather than throwing — the next `queue` read or
* `queue` WS frame re-establishes the true depth.
*/
internal fun decodeQueueDepth(bytes: ByteArray): Int =
LossyDecode.objectOrNull(bytes, QueueSnapshot.serializer())?.length ?: 0

View File

@@ -2,6 +2,8 @@ package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
@@ -9,7 +11,9 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
@@ -36,6 +40,23 @@ internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
}
/**
* A tolerant `Int?` field decoder: a wrong-typed/garbled value degrades to `null` instead of failing
* the whole enclosing object. Used for ADDITIVE OPTIONAL numeric fields (e.g.
* `LiveSessionInfo.queueLength`) where dropping the entry would hide a running session for the sake of
* a badge. Required numeric fields deliberately keep the strict built-in behaviour.
*/
internal object LossyIntSerializer : KSerializer<Int?> {
private val delegate: KSerializer<Int?> = Int.serializer().nullable
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: Int?) = delegate.serialize(encoder, value)
override fun deserialize(decoder: Decoder): Int? {
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
val primitive = json.decodeJsonElement() as? JsonPrimitive ?: return null
return if (primitive.isString) primitive.content.toIntOrNull() else primitive.intOrNull
}
}
/**
* 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`,

View File

@@ -0,0 +1,88 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* w6/G1 — how far the checked-out branch has drifted from its upstream (`src/types.ts` `SyncState`).
*
* ## The one rule this whole feature turns on
* `ahead`/`behind` are computed against `@{u}`, a **locally cached** remote ref that only a `fetch`
* moves. That asymmetry decides what may be trusted:
*
* - [ahead] needs only local refs, so it is always trustworthy.
* - [behind] is only meaningful if [lastFetchMs] is recent. A stale FETCH_HEAD reports `behind = 0`
* for a branch that is in fact many commits behind — the server's own commit message records finding
* exactly that (`↓0` while FETCH_HEAD had not moved in 19 days).
* - **No upstream leaves [ahead] and [behind] undefined**, which is NOT the same as zero. A fresh
* worktree branch normally has no upstream, and rendering it as "in sync" is the single easiest bug
* to ship here. [isFullySynced] is the only sanctioned way to ask "may I render the all-clear?" and
* it answers false whenever anything is unknown. Do not re-derive that test at a call site.
*
* Every field is optional because every one of them degrades independently on the server (no upstream,
* detached HEAD, never fetched, not a git repo).
*/
@Serializable
public data class SyncState(
/** e.g. `origin/develop`; null ⇒ the branch tracks nothing. */
val upstream: String? = null,
/** Commits on HEAD not on `@{u}`. Null ⇒ unknown (no upstream), NOT zero. */
val ahead: Int? = null,
/** Commits on `@{u}` not on HEAD. Null ⇒ unknown. Trust only when [isFetchFresh]. */
val behind: Int? = null,
/** FETCH_HEAD mtime in ms; null ⇒ never fetched. */
val lastFetchMs: Long? = null,
/** HEAD is not on a branch ⇒ no branch, no ahead/behind, and fetch/push are not offerable. */
val detached: Boolean = false,
) {
/** True when there is an upstream to compare against at all. */
public val hasUpstream: Boolean get() = upstream != null
/**
* True when [lastFetchMs] is recent enough for [behind] to mean anything.
*
* @param nowMs caller-supplied clock so this stays pure and testable.
*/
public fun isFetchFresh(nowMs: Long): Boolean {
val fetched = lastFetchMs ?: return false
val age = nowMs - fetched
// A clock skew that puts the fetch in the future is not evidence of freshness.
return age in 0..FETCH_FRESH_WINDOW_MS
}
/** True when [behind] should be shown with a "stale — I have not checked" qualifier. */
public fun isBehindStale(nowMs: Long): Boolean = hasUpstream && !isFetchFresh(nowMs)
/**
* The ONLY state that may render as the green all-clear: nothing to push, nothing to pull, and a
* fresh enough fetch to justify saying so.
*
* Green means "I checked, you can ignore this". Getting it wrong is lying to the user, so anything
* unknown — no upstream, detached, never fetched, stale fetch, null counts — is false.
*/
public fun isFullySynced(nowMs: Long): Boolean =
hasUpstream && !detached && ahead == 0 && behind == 0 && isFetchFresh(nowMs)
public companion object {
/**
* How long a fetch stays "fresh". Mirrors the server rule that `behind` is flagged stale once
* FETCH_HEAD is older than an hour (w6/G1).
*/
public const val FETCH_FRESH_WINDOW_MS: Long = 60L * 60L * 1000L
}
}
/**
* w6/G7 — the git state of ONE worktree, fetched lazily per row via `GET /projects/worktree/state`
* (`src/types.ts` `WorktreeState`).
*
* Deliberately narrower than `ProjectDetail`: N rows must not each pay for a worktree listing and a
* CLAUDE.md read that nothing renders.
*/
@Serializable
public data class WorktreeState(
val path: String,
val branch: String? = null,
val sync: SyncState? = null,
val dirtyCount: Int? = null,
)

View File

@@ -11,12 +11,17 @@ import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.QueueSnapshot
import wang.yaojia.webterm.api.models.QueueWriteOutcome
import wang.yaojia.webterm.api.models.decodeQueueDepth
import wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.WorktreeState
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.HostEndpoint
@@ -135,6 +140,19 @@ public class ApiClient(
}
}
/**
* `GET /live-sessions/:id/queue` (W2) — the pending prompt queue: depth + the queued texts,
* verbatim. READ-ONLY, so no Origin (the server guards only the two writes). 404 → the session is
* gone; a non-object body → [ApiClientError.InvalidResponseBody] rather than a fake empty queue
* (claiming "nothing queued" when entries exist would mislead a cancel-all decision).
*/
public suspend fun queue(id: UUID): QueueSnapshot {
val response = perform(Endpoints.queue(id))
requireOk(response)
return LossyDecode.objectOrNull(response.body, QueueSnapshot.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `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 {
@@ -180,6 +198,52 @@ public class ApiClient(
}
}
// ── G: W2 prompt queue (enqueue / cancel-all) → QueueWriteOutcome ──────────────────────
/**
* `POST /live-sessions/:id/queue` (W2) — queue a follow-up prompt the server injects into the PTY
* the next time Claude goes idle. Works with zero tabs attached, which is the whole point.
*
* [text] travels VERBATIM (invariant #9 — raw keyboard bytes for a shell); [appendEnter] is a flag
* the SERVER turns into a trailing `\r`, so callers must not append one. An empty prompt is
* rejected as [ApiClientError.QueueTextEmpty] before any network I/O; size is NOT pre-checked
* client-side (the byte cap is server config) — an over-cap prompt comes back as
* [QueueWriteOutcome.TooLarge].
*/
public suspend fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean = true): QueueWriteOutcome {
if (text.isEmpty()) throw ApiClientError.QueueTextEmpty
return queueWrite(Endpoints.enqueueFollowup(id, text, appendEnter))
}
/**
* `DELETE /live-sessions/:id/queue` (W2) — cancel every pending entry (escape hatch). 200 →
* [QueueWriteOutcome.Ok] with depth 0.
*/
public suspend fun clearQueue(id: UUID): QueueWriteOutcome = queueWrite(Endpoints.clearQueue(id))
/**
* Shared status mapping for the two guarded queue writes. Distinct typed outcomes because each one
* demands different UI behaviour (see [QueueWriteOutcome]); in particular a 429 from the shared
* `QUEUE_RATE_MAX = 20`/min/IP bucket is surfaced, NEVER auto-retried. Only 409/413/503 are
* POST-specific — the DELETE handler cannot produce them.
*/
private suspend fun queueWrite(route: ApiRoute): QueueWriteOutcome {
val response = perform(route)
return when (response.status) {
HttpStatus.OK -> QueueWriteOutcome.Ok(decodeQueueDepth(response.body))
HttpStatus.CONFLICT -> QueueWriteOutcome.Full
HttpStatus.PAYLOAD_TOO_LARGE -> QueueWriteOutcome.TooLarge
HttpStatus.SERVICE_UNAVAILABLE -> QueueWriteOutcome.Disabled
HttpStatus.NOT_FOUND -> QueueWriteOutcome.SessionGone
HttpStatus.TOO_MANY_REQUESTS -> QueueWriteOutcome.RateLimited
// `decodeGitError` reads the generic `{ error }` failure shape the queue routes share with
// the git ones (400/403 here) — reused rather than duplicated.
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
QueueWriteOutcome.Rejected(response.status, decodeGitError(response.body))
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
@@ -206,6 +270,36 @@ public class ApiClient(
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
/**
* `POST /projects/git/fetch` (w6/G2) — refresh `refs/remotes` so `behind` becomes trustworthy.
*
* Shares the guarded-write dispatch, so a disabled kill-switch or an Origin failure both surface as
* [GitWriteOutcome.Rejected] with the server's safe message. Fetch has its OWN rate-limit bucket on
* the server precisely so refreshes cannot eat the budget a real push needs — surface a 429 rather
* than retrying.
*/
public suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> =
gitWrite(Endpoints.gitFetch(path), FetchResult.serializer())
/**
* `GET /projects/worktree/state?path=` (w6/G7) — the per-row git state of ONE worktree.
*
* Mapped like the other project reads. A malformed body throws rather than degrading to an empty
* state: a row that silently claims "clean, in sync" would be a lie, and the caller is expected to
* isolate this failure to its own row (the panel must still render).
*/
public suspend fun worktreeState(path: String): WorktreeState {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.worktreeState(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, WorktreeState.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/**
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]

View File

@@ -44,6 +44,11 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** An empty follow-up prompt was passed to `POST /live-sessions/:id/queue` (which the server 400s
* as `text must be a non-empty string`). Raised client-side, before any network I/O — mirrors the
* [ProjectPathInvalid] precedent. */
public data object QueueTextEmpty : ApiClientError("要排队的内容为空——请先输入要发送的提示词。")
/** 500 from `GET /projects/log` — the server failed reading the git log. */
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")

View File

@@ -12,8 +12,11 @@ internal object HttpStatus {
const val BAD_REQUEST = 400
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val CONFLICT = 409
const val PAYLOAD_TOO_LARGE = 413
const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500
const val SERVICE_UNAVAILABLE = 503
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
const val CLIENT_ERROR_MIN = 400

View File

@@ -12,10 +12,10 @@ import java.util.UUID
* `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`
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue`
* · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue`
* · `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` +
@@ -54,9 +54,29 @@ internal object Endpoints {
percentEncodedQuery = "path=${percentEncode(path)}",
)
/**
* `GET /projects/worktree/state?path=` — RO (w6/G7). Deliberately NARROWER than
* `/projects/detail`: it is fetched once per worktree row, so N rows must not each pay for a
* worktree listing and a CLAUDE.md read that nothing renders.
*/
fun worktreeState(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/worktree/state",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
/**
* `GET /live-sessions/:id/queue` — RO (W2). Same threat model as `GET /live-sessions`, so the
* server stamps no Origin guard on it and neither may we.
*/
fun queue(id: UUID): ApiRoute =
ApiRoute(HttpMethod.GET, queuePath(id), OriginPolicy.READ_ONLY)
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
fun projectPr(path: String): ApiRoute =
ApiRoute(
@@ -148,10 +168,50 @@ internal object Endpoints {
fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
/**
* `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates
* `refs/remotes`: it is state-changing and spawns a network git process, so it goes through the
* single Origin-stamping point like every other write. The remote is derived SERVER-side and no
* remote or refspec is ever read from the body, so a client cannot aim it at an arbitrary URL.
* It is NOT a pull: no working tree, no index, no merge.
*/
fun gitFetch(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
// ── G: W2 prompt queue (enqueue / cancel-all) ──────────────────────────────────────────
/**
* `POST /live-sessions/:id/queue` — G. Body is exactly `{ text, appendEnter }` (express.json,
* 16 kb limit).
*
* [text] is RAW KEYBOARD INPUT for a shell and is carried VERBATIM (invariant #9): JSON string
* encoding is the only transformation, and it round-trips byte-exactly through `express.json`.
* Never trim/escape/normalise it here. [appendEnter] is a FLAG — the server materialises the
* trailing `\r` so the stored entry is byte-identical to a keystroke; the client must not append
* one itself.
*/
fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean): ApiRoute =
jsonBodyRoute(
HttpMethod.POST,
queuePath(id),
EnqueueBody.serializer(),
EnqueueBody(text, appendEnter),
)
/**
* `DELETE /live-sessions/:id/queue` — G, cancel-all escape hatch. Carries **no** body: unlike
* `DELETE /projects/worktree` (the body-carrying DELETE precedent), this handler reads only `:id`
* and clears the whole FIFO, so a body would be dead weight the server never parses.
*/
fun clearQueue(id: UUID): ApiRoute =
ApiRoute(HttpMethod.DELETE, queuePath(id), OriginPolicy.GUARDED)
private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue"
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
private fun <T> jsonBodyRoute(
method: HttpMethod,
@@ -216,4 +276,10 @@ internal object Endpoints {
@Serializable
private data class PushBody(val path: String)
@kotlinx.serialization.Serializable
private data class FetchBody(val path: String)
@Serializable
private data class EnqueueBody(val text: String, val appendEnter: Boolean)
}

View File

@@ -253,6 +253,137 @@ class DeviceEnrollmentClientTest {
assertTrue(transport.recordedRequests.isEmpty())
}
@Test
fun renewDecodesTheRealServerResponseWhichCarriesNoDeviceId() = runTest {
// THE REAL WIRE SHAPE: `/device/:id/renew` answers `{cert, caChain, notAfter}` — it does NOT
// echo deviceId/notBefore/renewAfter (only `/device/enroll` does; see
// control-plane/src/api/renew.ts). Decoding it against the enroll DTO (deviceId required)
// made every silent renewal fail as MalformedResponse. The device id comes from the path we
// called, which is authoritative anyway.
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201,
body = """{"cert":"MAEC","caChain":["MAED"],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
)
val result = client.renew("dev-9", byteArrayOf(0x02))
assertEquals("dev-9", result.deviceId, "the device id comes from the path segment we called")
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
assertNull(result.notBefore, "the renew 201 carries no notBefore")
assertNull(result.renewAfter, "the renew 201 carries no renewAfter — the client derives it")
assertEquals(1, result.caChain.size)
}
// ── recover (EXPIRED-leaf escape hatch — plain HTTPS, cert in the BODY, never mTLS) ──────────
@Test
fun recoverPostsTheExpiredLeafAndAFreshCsrWithNoAuthorizationHeader() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
body = """{"cert":"MAEC","caChain":[],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
)
val expiredLeaf = byteArrayOf(0x30, 0x11)
val csr = byteArrayOf(0x30, 0x22)
val result = client.recover("dev-9", expiredLeaf, csr)
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/device/dev-9/recover", request.url)
// Recovery is NOT mTLS-authenticated and carries no bearer: an expired client certificate
// cannot authenticate its own re-issuance (no TLS terminator will even forward one), so the
// lapsed cert travels in the BODY and proof-of-possession comes from the CSR self-signature.
assertNull(request.headers["Authorization"], "recovery carries no bearer")
val obj = bodyObject(request)
assertEquals(setOf("cert", "csr"), obj.keys, "the /recover schema is .strict() on {cert, csr}")
assertEquals(Base64.getEncoder().encodeToString(expiredLeaf), obj["cert"]!!.jsonPrimitive.content)
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
assertEquals("dev-9", result.deviceId)
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
}
@Test
fun recoverPercentEncodesTheDeviceIdPathSegment() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/a%2Fb/recover", status = 201,
body = """{"cert":"MAEC","caChain":[]}""".toByteArray(),
)
client.recover("a/b", byteArrayOf(1), byteArrayOf(2))
assertEquals("$BASE/device/a%2Fb/recover", transport.recordedRequests.single().url)
}
@Test
fun recoverRejectsEmptyArgumentsBeforeAnyNetworkIo() = runTest {
assertEquals(
DeviceEnrollmentError.InvalidRequest,
runCatching { client.recover("", byteArrayOf(1), byteArrayOf(1)) }.exceptionOrNull(),
)
assertEquals(
DeviceEnrollmentError.InvalidRequest,
runCatching { client.recover("d", ByteArray(0), byteArrayOf(1)) }.exceptionOrNull(),
)
assertEquals(
DeviceEnrollmentError.InvalidRequest,
runCatching { client.recover("d", byteArrayOf(1), ByteArray(0)) }.exceptionOrNull(),
)
assertTrue(transport.recordedRequests.isEmpty(), "no I/O for a request that cannot succeed")
}
@Test
fun recoverSurfacesA401BeyondTheGraceWindowWithoutLeakingTheCert() = runTest {
// Past `DEFAULT_EXPIRED_RENEW_GRACE_MS` the control plane answers a uniform 401. The raised
// error must carry the STATUS + the server's `error` code only — never the submitted cert or
// CSR bytes, which would put credential material into a crash log.
val expiredLeaf = byteArrayOf(0x30, 0x11, 0x22, 0x33)
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 401,
body = """{"error":"rejected"}""".toByteArray(),
)
val error = runCatching { client.recover("dev-9", expiredLeaf, byteArrayOf(0x30, 0x44)) }.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
val rendered = "${error!!.message} $error"
assertFalse(
rendered.contains(Base64.getEncoder().encodeToString(expiredLeaf)),
"the raised error must not carry the certificate bytes",
)
assertFalse(rendered.contains("MDE"), "no base64 credential material in the error rendering")
}
@Test
fun recoverSurfacesA403ForARevokedDevice() = runTest {
// Recovery NEVER bypasses revocation (renew.test.ts CP6e) — surface the 403 verbatim.
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 403,
body = """{"error":"rejected"}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.Http(403, "rejected"),
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
)
}
@Test
fun recoverThrowsMalformedResponseOnAnUndecodable201() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
body = """{"cert":"@@not-base64@@"}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.MalformedResponse,
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
)
}
@Test
fun theBaseUrlIsReadableSoTheRotationTargetCanBePersisted() = runTest {
// The rotation driver must renew against the SAME control plane the device enrolled with; the
// enroller persists this alongside the enrollment record.
assertEquals(BASE, client.baseUrl)
assertEquals(BASE, DeviceEnrollmentClient("$BASE/", transport).baseUrl, "a trailing slash is trimmed")
}
// ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
@Test

View File

@@ -0,0 +1,228 @@
package wang.yaojia.webterm.api.enroll
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.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Duration
import java.time.Instant
/**
* The PURE rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
* `TerminalGridMath` — a policy is a function, so it is exhaustively testable with no clock, no
* keystore and no network). Ports the iOS `CertificateRotationSchedulerTests` timing cases and adds
* the two the phone track needs and iOS lacks: the EXPIRED-leaf `/device/:id/recover` window and the
* terminal beyond-grace state.
*/
class RotationPolicyTest {
private companion object {
val NOT_BEFORE: Instant = Instant.parse("2026-08-06T00:00:00Z")
val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z")
// notBefore + 2/3 · 61d lifetime — the value the control plane itself computes.
val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z")
fun state(
notAfter: Instant? = NOT_AFTER,
renewAfter: Instant? = RENEW_AFTER,
): DeviceRenewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter)
}
// ── renewAfter derivation (the client MUST derive it: renew/recover 201s carry no renewAfter) ──
@Test
fun renewAfterIsTwoThirdsThroughTheLeafLifetime() {
// The control plane computes `notBefore + floor(2/3 · lifetime)` (device-enroll.ts
// DEFAULT_RENEW_FRACTION); the client must land on the same instant from the leaf alone,
// because `/device/:id/renew` and `/device/:id/recover` return `{cert, caChain, notAfter}`
// ONLY — no renewAfter. Without this derivation rotation would fire exactly once, ever.
assertEquals(RENEW_AFTER, RotationPolicy.renewAfterFor(NOT_BEFORE, NOT_AFTER))
}
@Test
fun renewAfterIsNullWhenEitherBoundIsUnknown() {
assertNull(RotationPolicy.renewAfterFor(null, NOT_AFTER))
assertNull(RotationPolicy.renewAfterFor(NOT_BEFORE, null))
}
@Test
fun renewAfterIsNullForANonsensicalWindow() {
// notAfter before notBefore is not a lifetime — degrade to "unknown" (fail-safe: the TLS
// stack is the real gate) instead of inventing a past instant that would renew-storm.
assertNull(RotationPolicy.renewAfterFor(NOT_AFTER, NOT_BEFORE))
}
// ── the wait branch ────────────────────────────────────────────────────────────────────────
@Test
fun aLeafInsideItsRenewWindowIsNotDue() {
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(state(), now = RENEW_AFTER.minusSeconds(1)),
)
}
@Test
fun aMissingRenewAfterNeverTriggers() {
// iOS fail-safe, ported verbatim: absent timing NEVER renews — the TLS stack is the real gate
// and the scheduler only pre-empts expiry.
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(state(renewAfter = null), now = NOT_AFTER.minusSeconds(1)),
)
assertFalse(state(renewAfter = null).isRenewalDue(NOT_AFTER.minusSeconds(1)))
}
@Test
fun anUnknownNotAfterFallsBackToTheRenewAfterBranch() {
// A cert whose expiry could not be read must not be treated as expired (that would push a
// working device into recovery); it is judged solely on the advisory renew timing.
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER.minusSeconds(1)),
)
assertEquals(
RotationDecision.RENEW,
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER),
)
}
// ── the renew branch ───────────────────────────────────────────────────────────────────────
@Test
fun renewFiresAtTheRenewAfterBoundary() {
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = RENEW_AFTER))
assertTrue(state().isRenewalDue(RENEW_AFTER), "the >= boundary renews (iOS parity)")
}
@Test
fun renewStillFiresAtTheLastInstantBeforeExpiry() {
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = NOT_AFTER))
}
// ── the recover branch (expired leaf — the phone-track deadlock escape hatch) ───────────────
@Test
fun anExpiredLeafInsideTheGraceWindowRecovers() {
// mTLS `/renew` cannot authenticate an expired leaf (nginx refuses to forward one at all), so
// the only route left is the plain-HTTPS `/device/:id/recover`.
assertEquals(
RotationDecision.RECOVER,
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(8))),
)
}
@Test
fun recoveryIsStillAvailableAtTheExactGraceBoundary() {
val grace = Duration.ofDays(30)
assertEquals(
RotationDecision.RECOVER,
RotationPolicy.decide(state(), now = NOT_AFTER.plus(grace), expiredRecoveryGrace = grace),
)
}
@Test
fun aLeafExpiredBeyondTheGraceWindowRequiresAFreshEnroll() {
assertEquals(
RotationDecision.RE_ENROLL_REQUIRED,
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(31))),
)
}
@Test
fun aZeroGraceDisablesRecoveryEntirely() {
// Mirrors the control plane's `expiredRenewGraceMs = 0` posture (renew.test.ts CP6e).
assertEquals(
RotationDecision.RE_ENROLL_REQUIRED,
RotationPolicy.decide(
state(),
now = NOT_AFTER.plusMillis(1),
expiredRecoveryGrace = Duration.ZERO,
),
)
}
@Test
fun theTerminalStateWinsOverTheFailureBackoff() {
// Beyond grace nothing can ever succeed, so the answer must be the terminal one even while a
// backoff is armed — otherwise the user is told "retrying" forever (the exact failure mode
// the agent's 6380 identical warnings came from).
assertEquals(
RotationDecision.RE_ENROLL_REQUIRED,
RotationPolicy.decide(
state(),
now = NOT_AFTER.plus(Duration.ofDays(31)),
lastFailureAt = NOT_AFTER.plus(Duration.ofDays(31)).minusSeconds(1),
),
)
}
// ── the backoff branch (a failed attempt must not hammer the control plane) ─────────────────
@Test
fun aRecentFailureBacksOffInsteadOfRetryingImmediately() {
// Every foreground fires a pass; the control plane rate-limits renewals to 30/hour/identity,
// so an un-throttled retry turns one failure into a 429 storm.
val now = RENEW_AFTER.plus(Duration.ofMinutes(1))
assertEquals(
RotationDecision.BACKING_OFF,
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minus(Duration.ofMinutes(1))),
)
}
@Test
fun theBackoffExpiresAndTheRenewIsRetried() {
val now = RENEW_AFTER.plus(Duration.ofHours(1))
assertEquals(
RotationDecision.RENEW,
RotationPolicy.decide(
state(),
now = now,
lastFailureAt = now.minus(RotationPolicy.DEFAULT_FAILURE_BACKOFF),
),
)
}
@Test
fun aRecentFailureAlsoThrottlesTheRecoveryPath() {
val now = NOT_AFTER.plus(Duration.ofDays(8))
assertEquals(
RotationDecision.BACKING_OFF,
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minusSeconds(30)),
)
}
@Test
fun aFailureNeverTurnsAnIdleLeafIntoAnAttempt() {
// NOT_DUE has no attempt to throttle — reporting BACKING_OFF there would be a lie.
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(
state(),
now = RENEW_AFTER.minusSeconds(1),
lastFailureAt = RENEW_AFTER.minusSeconds(2),
),
)
}
// ── boundary validation ────────────────────────────────────────────────────────────────────
@Test
fun negativeDurationsAreRejectedAtTheBoundary() {
assertThrows(IllegalArgumentException::class.java) {
RotationPolicy.decide(state(), now = RENEW_AFTER, expiredRecoveryGrace = Duration.ofDays(-1))
}
assertThrows(IllegalArgumentException::class.java) {
RotationPolicy.decide(state(), now = RENEW_AFTER, failureBackoff = Duration.ofMinutes(-1))
}
}
@Test
fun theStateCarriesNoCredentialInItsToString() {
// The rotation state is logged/surfaced; it must never be able to carry key or cert bytes.
val rendered = state().toString()
assertTrue(rendered.contains("dev-1"), "the non-secret device id is the only identifier here")
assertFalse(rendered.contains("MII"), "no DER/PEM material may appear in a loggable rendering")
}
}

View File

@@ -0,0 +1,74 @@
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.DisplayName
import org.junit.jupiter.api.Test
/**
* Tolerant decode for the W2 queue read (`GET /live-sessions/:id/queue` → `{length, items}`) and for
* the additive `LiveSessionInfo.queueLength` the badge reads. The server is UNTRUSTED here: unknown
* keys, missing keys and wrong-typed items must degrade, never crash — and the queued strings, which
* are raw keyboard bytes, must come back verbatim.
*/
@DisplayName("W2 queue models — tolerant decode + verbatim items")
class PromptQueueTest {
private fun snapshot(json: String): QueueSnapshot? =
LossyDecode.objectOrNull(json.toByteArray(), QueueSnapshot.serializer())
@Test
fun `decodes length and items`() {
val s = snapshot("""{"length":2,"items":["first","second"]}""")!!
assertEquals(2, s.length)
assertEquals(listOf("first", "second"), s.items)
}
@Test
fun `queued items keep control characters and CJK verbatim`() {
//  + TAB + CR + CJK, exactly as the server stored the keystroke bytes.
val s = snapshot("""{"length":1,"items":["\t你好\r"]}""")!!
assertEquals("\t你好\r", s.items.single())
}
@Test
fun `an empty queue and unknown keys both decode`() {
val s = snapshot("""{"length":0,"items":[],"futureField":true}""")!!
assertEquals(0, s.length)
assertTrue(s.items.isEmpty())
}
@Test
fun `missing fields fall back to an empty queue and a non-object body is null`() {
val s = snapshot("{}")!!
assertEquals(0, s.length)
assertTrue(s.items.isEmpty())
assertNull(snapshot("[]"))
assertNull(snapshot("garbage"))
}
// ── LiveSessionInfo.queueLength (additive optional, src/types.ts:318) ─────────────────────
private val base =
"""{"id":"11111111-2222-3333-4444-555555555555","createdAt":1,"clientCount":0,"exited":false,"cols":80,"rows":24"""
private fun info(extra: String): LiveSessionInfo? =
LossyDecode.objectOrNull("$base$extra}".toByteArray(), LiveSessionInfo.serializer())
@Test
fun `queueLength decodes when present`() {
assertEquals(4, info(""","queueLength":4""")!!.queueLength)
}
@Test
fun `queueLength is null on a pre-W2 server that omits it`() {
assertNull(info("")!!.queueLength)
}
@Test
fun `a wrong-typed queueLength does not drop the whole session`() {
// A garbled value must not make a running session invisible — the field degrades to null.
assertNull(info(",\"queueLength\":\"lots\"")!!.queueLength)
}
}

View File

@@ -0,0 +1,141 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.json.Json
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.DisplayName
import org.junit.jupiter.api.Test
/**
* w6/G1 sync state.
*
* The load-bearing rule, quoted from the server commit that introduced the feature: *"Exactly ONE state
* may render green: ↑0 ↓0 AND a fresh fetch. Green means 'I checked, ignore this'; getting it wrong is
* lying to the user."* Most of this file exists to pin the ways that could go wrong — above all the
* no-upstream fall-through, which the server's own commit message calls "the easiest bug to ship here".
*/
@DisplayName("SyncState — when the all-clear may be rendered")
class SyncStateTest {
private val json = Json { ignoreUnknownKeys = true }
private val now = 1_700_000_000_000L
private fun fresh() = now - 60_000L // a minute ago
private fun stale() = now - (SyncState.FETCH_FRESH_WINDOW_MS + 60_000L)
@Test
@DisplayName("decodes the full server shape")
fun decodesFull() {
val s = json.decodeFromString<SyncState>(
"""{"upstream":"origin/develop","ahead":9,"behind":0,"lastFetchMs":$now,"detached":false}""",
)
assertEquals("origin/develop", s.upstream)
assertEquals(9, s.ahead)
assertEquals(0, s.behind)
}
@Test
@DisplayName("every field is optional — each degrades independently on the server")
fun decodesEmpty() {
val s = json.decodeFromString<SyncState>("{}")
assertNull(s.upstream)
assertNull(s.ahead)
assertNull(s.behind)
assertNull(s.lastFetchMs)
assertFalse(s.detached)
assertFalse(s.hasUpstream)
}
// ── the green state ──────────────────────────────────────────────────────────────────────────────
@Test
@DisplayName("↑0 ↓0 with a fresh fetch is the ONLY green state")
fun theOneGreenState() {
val s = SyncState(upstream = "origin/main", ahead = 0, behind = 0, lastFetchMs = fresh())
assertTrue(s.isFullySynced(now))
}
@Test
@DisplayName("NO UPSTREAM is never green — the fall-through the server calls the easiest bug here")
fun noUpstreamIsNeverGreen() {
// The normal state of a fresh worktree branch. ahead/behind are UNDEFINED, not zero.
val s = SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh())
assertFalse(s.isFullySynced(now), "a branch tracking nothing has not been verified as in sync")
assertFalse(s.hasUpstream)
}
@Test
@DisplayName("undefined counts are not zero, even with an upstream and a fresh fetch")
fun undefinedCountsAreNotZero() {
assertFalse(SyncState("origin/main", ahead = null, behind = 0, lastFetchMs = fresh()).isFullySynced(now))
assertFalse(SyncState("origin/main", ahead = 0, behind = null, lastFetchMs = fresh()).isFullySynced(now))
}
@Test
@DisplayName("a stale fetch is not green — behind=0 from a stale FETCH_HEAD proves nothing")
fun staleFetchIsNotGreen() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = stale())
assertFalse(s.isFullySynced(now))
assertTrue(s.isBehindStale(now))
}
@Test
@DisplayName("never fetched is not green")
fun neverFetchedIsNotGreen() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = null)
assertFalse(s.isFullySynced(now))
assertFalse(s.isFetchFresh(now))
}
@Test
@DisplayName("a detached HEAD is not green")
fun detachedIsNotGreen() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = fresh(), detached = true)
assertFalse(s.isFullySynced(now))
}
@Test
@DisplayName("anything unpushed or unpulled is not green")
fun pendingWorkIsNotGreen() {
assertFalse(SyncState("origin/main", ahead = 1, behind = 0, lastFetchMs = fresh()).isFullySynced(now))
assertFalse(SyncState("origin/main", ahead = 0, behind = 1, lastFetchMs = fresh()).isFullySynced(now))
}
// ── freshness edges ──────────────────────────────────────────────────────────────────────────────
@Test
@DisplayName("a fetch timestamp in the future is not treated as fresh")
fun futureFetchIsNotFresh() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now + 60_000L)
assertFalse(s.isFetchFresh(now), "clock skew is not evidence that we checked")
assertFalse(s.isFullySynced(now))
}
@Test
@DisplayName("the freshness boundary is inclusive at exactly the window")
fun boundaryInclusive() {
val atEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS)
assertTrue(atEdge.isFetchFresh(now))
val pastEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS - 1)
assertFalse(pastEdge.isFetchFresh(now))
}
@Test
@DisplayName("staleness is only claimed when there is an upstream to be stale about")
fun noUpstreamIsNotStale() {
assertFalse(SyncState(upstream = null, lastFetchMs = null).isBehindStale(now))
}
@Test
@DisplayName("WorktreeState decodes the narrow per-row shape")
fun worktreeStateDecodes() {
val w = json.decodeFromString<WorktreeState>(
"""{"path":"/repo/.claude/worktrees/x","branch":"wt-x","dirtyCount":3,
"sync":{"upstream":"origin/wt-x","ahead":2}}""",
)
assertEquals("wt-x", w.branch)
assertEquals(3, w.dirtyCount)
assertEquals(2, w.sync?.ahead)
}
}

View File

@@ -0,0 +1,141 @@
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.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.QueueWriteOutcome
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* Status → outcome mapping for the W2 prompt queue, read off the server handlers
* (`src/server.ts` ~605/644/654):
*
* POST: 200 `{length}` → [QueueWriteOutcome.Ok] · 409 `{error:"full"}` → `Full` ·
* 413 → `TooLarge` · 503 `{error:"queue disabled"}` → `Disabled` ·
* 404 `{error:"unknown"|"exited"}` → `SessionGone` · 429 (empty) → `RateLimited` ·
* 400/403/anything else → `Rejected(status, safe error)`.
* DELETE: 200 `{length:0}` → `Ok(0)` · 404 → `SessionGone` · 403 → `Rejected`.
* GET: 200 → snapshot · 404 → [ApiClientError.SessionNotFound].
*
* 429 comes from the shared `QUEUE_RATE_MAX = 20`/min/IP bucket and is a DISTINCT outcome precisely
* so no caller auto-retries into it (the `GitWriteOutcome.RateLimited` precedent).
*/
@DisplayName("W2 queue — status mapping")
class ApiClientQueueTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555")
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private val queueUrl = "$BASE/live-sessions/$ID/queue"
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
private fun queuePost(status: Int, body: String = "") =
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, status = status, body = body.toByteArray())
private fun queueDelete(status: Int, body: String = "") =
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, status = status, body = body.toByteArray())
private suspend fun enqueue(text: String = "do the thing") = client.enqueueFollowup(ID, text, appendEnter = true)
// ── POST ──────────────────────────────────────────────────────────────────────────────────
@Test
fun `a 200 yields Ok carrying the new depth`() = runTest {
queuePost(200, """{"length":3}""")
assertEquals(QueueWriteOutcome.Ok(3), enqueue())
}
@Test
fun `a 200 with a garbled body still succeeds with an unknown depth of zero`() = runTest {
queuePost(200, "not json")
assertEquals(QueueWriteOutcome.Ok(0), enqueue())
}
@Test
fun `409 full 413 too-large and 503 disabled are distinct typed outcomes`() = runTest {
queuePost(409, """{"error":"full"}""")
assertEquals(QueueWriteOutcome.Full, enqueue())
queuePost(413, """{"error":"text too large"}""")
assertEquals(QueueWriteOutcome.TooLarge, enqueue())
queuePost(503, """{"error":"queue disabled"}""")
assertEquals(QueueWriteOutcome.Disabled, enqueue())
}
@Test
fun `404 unknown or exited is SessionGone`() = runTest {
queuePost(404, """{"error":"unknown"}""")
assertEquals(QueueWriteOutcome.SessionGone, enqueue())
queuePost(404, """{"error":"exited"}""")
assertEquals(QueueWriteOutcome.SessionGone, enqueue())
}
@Test
fun `429 from the shared 20-per-minute bucket is RateLimited and nothing is retried`() = runTest {
queuePost(429)
assertEquals(QueueWriteOutcome.RateLimited, enqueue())
// Exactly ONE request went out — a rate-limited enqueue must never auto-retry.
assertEquals(1, transport.recordedRequests.size)
}
@Test
fun `400 and 403 surface Rejected with the server's safe message`() = runTest {
queuePost(400, """{"error":"text must be a non-empty string"}""")
assertEquals(QueueWriteOutcome.Rejected(400, "text must be a non-empty string"), enqueue())
// The Origin guard 403s with an EMPTY body — message degrades to null, never throws.
queuePost(403)
assertEquals(QueueWriteOutcome.Rejected(403, null), enqueue())
}
@Test
fun `an odd non-error status is UnexpectedStatus`() = runTest {
queuePost(302)
assertTrue(errorOf { enqueue() } is ApiClientError.UnexpectedStatus)
}
@Test
fun `an empty prompt is rejected before any network IO`() = runTest {
assertEquals(ApiClientError.QueueTextEmpty, errorOf { client.enqueueFollowup(ID, "", appendEnter = true) })
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty prompt")
}
// ── DELETE ────────────────────────────────────────────────────────────────────────────────
@Test
fun `clearQueue maps 200 404 403 and 429`() = runTest {
queueDelete(200, """{"length":0}""")
assertEquals(QueueWriteOutcome.Ok(0), client.clearQueue(ID))
queueDelete(404)
assertEquals(QueueWriteOutcome.SessionGone, client.clearQueue(ID))
queueDelete(403)
assertEquals(QueueWriteOutcome.Rejected(403, null), client.clearQueue(ID))
queueDelete(429)
assertEquals(QueueWriteOutcome.RateLimited, client.clearQueue(ID))
}
// ── GET ───────────────────────────────────────────────────────────────────────────────────
@Test
fun `queue maps 404 to SessionNotFound and a garbled 200 to InvalidResponseBody`() = runTest {
transport.queueSuccess(url = queueUrl, status = 404)
assertEquals(ApiClientError.SessionNotFound, errorOf { client.queue(ID) })
transport.queueSuccess(url = queueUrl, body = "[]".toByteArray())
assertEquals(ApiClientError.InvalidResponseBody, errorOf { client.queue(ID) })
}
}

View File

@@ -0,0 +1,162 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
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.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
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 wang.yaojia.webterm.wire.HttpRequest
import java.util.UUID
/**
* Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the W2 prompt-queue trio
* (`src/server.ts` ~605/644/654):
*
* - `GET /live-sessions/:id/queue` — read-only ⇒ MUST NOT carry Origin;
* - `POST /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin + JSON body;
* - `DELETE /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin, and (unlike
* `DELETE /projects/worktree`, the precedent that DOES carry a body) MUST NOT carry a body — the
* server handler reads nothing but `:id` and clears the whole queue.
*
* A route reclassified read↔write turns this red instead of silently shipping either a CSWSH hole or a
* write the server 403s.
*
* The enqueued text is RAW KEYBOARD INPUT for a shell (invariant #9): it must survive the JSON body
* **verbatim** — no trim, no escape-rewrite, no Unicode normalisation, and no client-side `\r`
* (`appendEnter` is a flag the SERVER materialises).
*/
@DisplayName("W2 queue routes — shape, Origin policy, verbatim body")
class QueueRouteShapeTest {
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-3333-4444-555555555555")
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private val queueUrl = "$BASE/live-sessions/$ID/queue"
private fun last(): HttpRequest = transport.recordedRequests.last()
private fun assertGuarded(r: HttpRequest) =
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
private fun assertReadOnly(r: HttpRequest) =
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
/** Parse the recorded request body back out of JSON — proves round-trip byte-exactness without
* pinning any particular escaping strategy. */
private fun bodyText(r: HttpRequest): String? =
Json.parseToJsonElement(r.body!!.decodeToString()).jsonObject["text"]?.jsonPrimitive?.content
// ── GET — read-only, no Origin ────────────────────────────────────────────────────────────
@Test
fun `queue is a read-only GET with no Origin and no body`() = runTest {
transport.queueSuccess(url = queueUrl, body = """{"length":2,"items":["a","b"]}""".toByteArray())
val snapshot = client.queue(ID)
val r = last()
assertEquals(HttpMethod.GET, r.method)
assertEquals(queueUrl, r.url)
assertReadOnly(r)
assertNull(r.body, "a GET must not carry a body")
assertEquals(2, snapshot.length)
assertEquals(listOf("a", "b"), snapshot.items)
}
// ── POST — guarded, JSON body ─────────────────────────────────────────────────────────────
@Test
fun `enqueueFollowup is a guarded POST with a text-appendEnter body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
client.enqueueFollowup(ID, "run the tests", appendEnter = true)
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals(queueUrl, r.url)
assertGuarded(r)
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
assertEquals("""{"text":"run the tests","appendEnter":true}""", r.body?.decodeToString())
}
@Test
fun `appendEnter false is sent verbatim and the client never appends CR itself`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
client.enqueueFollowup(ID, "no enter", appendEnter = false)
assertEquals("""{"text":"no enter","appendEnter":false}""", last().body?.decodeToString())
assertEquals("no enter", bodyText(last()))
}
@Test
fun `control characters and CJK survive the body byte-exactly`() = runTest {
// ESC[A (up-arrow), a literal TAB, ETX (Ctrl-C), CR, a stray NUL, CJK, an emoji, and
// deliberate surrounding whitespace that must NOT be trimmed.
val raw = " \u001B[A\t\u0003 \u0000 \u4F60\u597D\uFF0C\u4E16\u754C \uD83C\uDF0F caf\u00E9\r\n "
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
client.enqueueFollowup(ID, raw, appendEnter = false)
assertEquals(raw, bodyText(last()), "queued prompt bytes must travel verbatim (invariant #9)")
}
// ── DELETE — guarded, and (unlike DELETE /projects/worktree) body-less ────────────────────
@Test
fun `clearQueue is a guarded DELETE with NO body`() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray())
client.clearQueue(ID)
val r = last()
assertEquals(HttpMethod.DELETE, r.method)
assertEquals(queueUrl, r.url)
assertGuarded(r)
assertNull(r.body, "DELETE /live-sessions/:id/queue takes no body (server reads only :id)")
assertFalse(r.headers.containsKey(HeaderName.CONTENT_TYPE), "no body ⇒ no Content-Type")
}
// ── the batch invariant ───────────────────────────────────────────────────────────────────
@Test
fun `only the two writes carry Origin (batch invariant)`() = runTest {
transport.queueSuccess(url = queueUrl, body = """{"length":0,"items":[]}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray())
client.queue(ID)
client.enqueueFollowup(ID, "x", appendEnter = true)
client.clearQueue(ID)
assertReadOnly(transport.recordedRequests[0])
assertGuarded(transport.recordedRequests[1])
assertGuarded(transport.recordedRequests[2])
assertNotNull(transport.recordedRequests[1].body)
}
@Test
fun `session id is serialized lowercase in the path`() = runTest {
val upper = UUID.fromString("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")
val url = "$BASE/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue"
transport.queueSuccess(url = url, body = """{"length":0,"items":[]}""".toByteArray())
client.queue(upper)
assertTrue(last().url.endsWith("/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue"))
}
}

View File

@@ -0,0 +1,171 @@
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.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
/**
* Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the two routes the v0.6 git panel added
* after the last Android commit: `GET /projects/worktree/state` (read) and `POST /projects/git/fetch`
* (write). A route reclassified read↔write turns this red rather than silently shipping either a CSWSH
* hole or a write the server will 403.
*
* Also pins the commit-log boundary arithmetic, which the server's own commit warns "fails in the
* dangerous direction" if shortcut to "the first N rows".
*/
@DisplayName("w6 routes — shape, Origin policy, and the unpushed boundary")
class W6RouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private fun last(): HttpRequest = transport.recordedRequests.last()
// ── GET /projects/worktree/state — read-only, no Origin ──────────────────────────────────────────
@Test
fun `worktreeState is a read-only GET with a strict-encoded path and no Origin`() = runTest {
val path = "/home/me/my repo/a+b&c"
transport.queueSuccess(
HttpMethod.GET,
"$BASE/projects/worktree/state?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c",
200,
body = """{"path":"$path","branch":"wt","dirtyCount":2}""".toByteArray(),
)
val state = client.worktreeState(path)
val r = last()
assertEquals(HttpMethod.GET, r.method)
assertFalse(
r.headers.containsKey(HeaderName.ORIGIN),
"a read must NOT stamp Origin — Origin is the CSWSH marker for state-changing routes only",
)
assertTrue(r.url.contains("%2Bb"), "a bare + would decode to a SPACE in Express's qs parser")
assertEquals(2, state.dirtyCount)
}
// ── POST /projects/git/fetch — guarded, byte-equal Origin, body carries only the path ────────────
@Test
fun `gitFetch is a guarded POST that stamps a byte-equal Origin`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true,"lastFetchMs":42}""".toByteArray())
val outcome = client.gitFetch("/repo")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals(
ORIGIN,
r.headers[HeaderName.ORIGIN],
"fetch is state-changing (it moves refs/remotes and spawns a network git), so it must be guarded",
)
assertNotNull(r.body, "the path travels in a JSON body")
val body = r.body!!.decodeToString()
assertTrue(body.contains("\"path\""))
assertFalse(
body.contains("remote") || body.contains("refspec"),
"the remote is derived SERVER-side; a client must never be able to aim a fetch at an arbitrary URL",
)
assertEquals(42L, (outcome as GitWriteOutcome.Ok).payload.lastFetchMs)
}
@Test
fun `a fetch that fails leaves lastFetchMs unknown rather than claiming it refreshed`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true}""".toByteArray())
val outcome = client.gitFetch("/repo")
assertNull(
(outcome as GitWriteOutcome.Ok).payload.lastFetchMs,
"the server leaves the old value alone on failure, so the UI must keep saying stale",
)
}
@Test
fun `fetch surfaces its own rate limit instead of retrying`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 429, body = """{"error":"slow down"}""".toByteArray())
val outcome = client.gitFetch("/repo")
assertTrue(
outcome is GitWriteOutcome.RateLimited,
"fetch has its OWN bucket so refreshes cannot eat the budget a real push needs",
)
}
@Test
fun `a disabled or Origin-rejected fetch surfaces the server's safe message`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 403, body = """{"error":"git ops disabled"}""".toByteArray())
val outcome = client.gitFetch("/repo")
// 403 is overloaded — kill-switch AND Origin failure both return it — so the message is
// surfaced rather than guessed at with a typed variant.
assertEquals("git ops disabled", (outcome as GitWriteOutcome.Rejected).message)
}
// ── the unpushed boundary (w6/G4) ────────────────────────────────────────────────────────────────
@Test
@DisplayName("the boundary sits after the LAST unpushed commit, even when they interleave")
fun boundaryHandlesInterleaving() {
// A backdated merge puts an unpushed commit BELOW a pushed one, because git log is date-ordered.
// "The first N rows" would mark the wrong set and call an unpushed commit pushed.
val log = GitLogResult(
commits = listOf(
commit("aaa", unpushed = true),
commit("bbb", unpushed = false),
commit("ccc", unpushed = true),
commit("ddd", unpushed = false),
),
upstream = "origin/main",
)
assertEquals(2, log.upstreamBoundaryIndex, "after index 2 — the last unpushed one")
assertEquals(2, log.unpushedCount)
}
@Test
@DisplayName("no upstream means NO boundary may be drawn at all")
fun noUpstreamNoBoundary() {
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = true)), upstream = null)
assertNull(
log.upstreamBoundaryIndex,
"null rather than -1, so a caller cannot mistake 'no boundary' for 'boundary before everything'",
)
}
@Test
@DisplayName("nothing unpushed means no boundary")
fun nothingUnpushed() {
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = false)), upstream = "origin/main")
assertNull(log.upstreamBoundaryIndex)
assertEquals(0, log.unpushedCount)
}
@Test
@DisplayName("an unknown unpushed flag is not counted as pushed")
fun unknownIsNotPushed() {
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = null)), upstream = "origin/main")
assertEquals(0, log.unpushedCount, "unknown is not 'unpushed'…")
assertNull(log.upstreamBoundaryIndex, "…and it must not invent a boundary either")
}
private fun commit(hash: String, unpushed: Boolean?) =
wang.yaojia.webterm.api.models.CommitLogEntry(hash = hash, at = 1L, subject = "s", unpushed = unpushed)
}

View File

@@ -10,6 +10,8 @@
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
// ─────────────────────────────────────────────────────────────────────────────
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
@@ -17,6 +19,83 @@ plugins {
alias(libs.plugins.hilt)
}
// ─────────────────────────────────────────────────────────────────────────────
// Release signing credentials — NEVER committed.
//
// Looked up in this order, first non-blank wins:
// 1. `android/keystore.properties` (the conventional path; see keystore.properties.example)
// 2. `android/local.properties` (already gitignored — the zero-setup local option)
// 3. `WEBTERM_RELEASE_*` env vars (CI)
//
// DEGRADATION CONTRACT: absent credentials leave debug (and the `benchmark` variant,
// which signs with the debug key) fully working, and make the RELEASE package task fail
// with RELEASE_SIGNING_HELP — never a silent `app-release-unsigned.apk`.
// ─────────────────────────────────────────────────────────────────────────────
val signingPropertyFiles = listOf("keystore.properties", "local.properties")
val signingProperties = Properties().apply {
// Later files must not override earlier ones, so load in reverse and let the
// higher-priority file overwrite.
signingPropertyFiles.reversed()
.map(rootProject::file)
.filter(File::exists)
.forEach { file -> file.inputStream().use(::load) }
}
fun signingCredential(propertyKey: String, envKey: String): String? =
(signingProperties.getProperty(propertyKey) ?: System.getenv(envKey))?.takeIf(String::isNotBlank)
val releaseStoreFile = signingCredential("webterm.release.storeFile", "WEBTERM_RELEASE_STORE_FILE")
val releaseStorePassword = signingCredential("webterm.release.storePassword", "WEBTERM_RELEASE_STORE_PASSWORD")
val releaseKeyAlias = signingCredential("webterm.release.keyAlias", "WEBTERM_RELEASE_KEY_ALIAS")
val releaseKeyPassword = signingCredential("webterm.release.keyPassword", "WEBTERM_RELEASE_KEY_PASSWORD")
// `storeFile` may be absolute or relative to `android/` — rootProject.file handles both.
val releaseKeystore = releaseStoreFile?.let(rootProject::file)
val hasAllReleaseCredentials =
releaseStoreFile != null &&
releaseStorePassword != null &&
releaseKeyAlias != null &&
releaseKeyPassword != null
val isReleaseSigningConfigured = hasAllReleaseCredentials && releaseKeystore?.exists() == true
// Two distinct failures deserve two distinct messages: "you have not set this up" and
// "you set it up but pointed it at a file that isn't there" have completely different fixes.
val MISSING_KEYSTORE_HELP = """
|:app release signing credentials were found, but the keystore file does not exist:
| ${releaseKeystore?.absolutePath}
|
|Fix `webterm.release.storeFile` (absolute, or relative to the `android/` directory), or
|point WEBTERM_RELEASE_STORE_FILE at the real file. Nothing was signed, and no unsigned
|APK was emitted.
""".trimMargin()
val UNCONFIGURED_SIGNING_HELP = """
|:app release signing is NOT configured — refusing to emit an unsigned release artifact.
|
|Provide the four credentials in ONE of these places (first match wins):
| 1. android/keystore.properties — copy android/keystore.properties.example.
| MAKE SURE `keystore.properties` is in android/.gitignore first.
| 2. android/local.properties — already gitignored; zero extra setup.
| 3. env: WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI)
|
|Keys (property form):
| webterm.release.storeFile=<path, relative to android/ or absolute>
| webterm.release.storePassword=<...>
| webterm.release.keyAlias=<...>
| webterm.release.keyPassword=<...>
|
|No keystore yet? Generate one OUTSIDE the repo:
| keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \
| -alias webterm -keyalg RSA -keysize 4096 -validity 10000
|Then record its SHA-256 in the App Links assetlinks.json (plan §8).
|
|Unaffected by this failure: `:app:assembleDebug`, `:app:assembleBenchmark`,
|`:app:assembleDebugAndroidTest`, every unit test, and R8 itself (which already ran).
""".trimMargin()
android {
namespace = "wang.yaojia.webterm"
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
@@ -28,24 +107,87 @@ android {
applicationId = "wang.yaojia.webterm"
minSdk = 29
targetSdk = 35
// ── Versioning policy ────────────────────────────────────────────────
// versionCode: a plain monotonic counter, +1 for EVERY artifact handed to
// anyone (Play track, internal APK, benchmark run that gets archived).
// Never derived from versionName — decoupling them is what lets a hotfix
// ship without renumbering. This is still 1 because no artifact has ever
// left this machine; the first distributed build takes 2.
// versionName: `<server-line>-alphaNN`. The Android client tracks the server's
// 0.1.x line (root package.json is 0.1.0). `-alpha01` is the honest state:
// the P0+P1 surface from ANDROID_CLIENT_PLAN §1 is implemented and JVM-tested,
// but NOTHING in DEVICE_QA_CHECKLIST.md has been signed off on real hardware.
// Drop to `-beta01` when the checklist's A34/A35 blocks pass on a device, and
// to plain `0.1.0` when the whole checklist is ticked.
versionCode = 1
versionName = "0.1.0"
versionName = "0.1.0-alpha01"
// Hilt instrumented testing REQUIRES a custom runner: AndroidJUnitRunner is the
// only hook that can swap the app-under-test's Application for the generated
// `HiltTestApplication` (the androidTest manifest cannot — it is merged into the
// TEST apk, not into the app under test). See android/README.md → "Instrumented tests".
testInstrumentationRunner = "wang.yaojia.webterm.HiltTestRunner"
}
buildFeatures {
compose = true
}
signingConfigs {
// Populated ONLY when credentials were found; an empty config would make AGP
// fall back to emitting `app-release-unsigned.apk`, which is exactly the silent
// failure this block exists to prevent (the release buildType leaves
// `signingConfig` null instead, and the guard task below fails the build).
if (isReleaseSigningConfigured) {
create("release") {
storeFile = rootProject.file(releaseStoreFile!!)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
// v1 (JAR signing) is only needed below API 24 and is the weakest scheme —
// off. v2/v3 are left enabled and apksigner picks what the minSdk range
// actually requires: verified output for minSdk 29 is v1=false, v2=false,
// v3=true (every device that can install this understands v3, so v2 is
// redundant). Do not read v2=false as a misconfiguration.
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes {
debug {
// No applicationId suffix — keep it stable for deep-link testing (A32).
}
release {
isMinifyEnabled = false
isMinifyEnabled = true
// Resource shrinking requires minification; it is what strips the unused
// CameraX/ML-Kit/Firebase resources the 56 MB unminified APK carried.
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.findByName("release")
}
// `benchmark` — what :macrobenchmark (A35) measures against. It must match the
// shipping variant in everything that affects performance (non-debuggable, R8'd,
// resource-shrunk) while needing NO release keystore, so it signs with the debug
// key. It is therefore also the variant that proves the R8 pipeline end-to-end
// (`:app:assembleBenchmark` produces a real, installable, minified APK).
create("benchmark") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("debug")
// Library modules only publish debug/release; resolve `benchmark` to release.
matchingFallbacks += listOf("release")
isDebuggable = false
// Injects <profileable android:shell="true"/> into the merged manifest, which
// macrobenchmark needs to read traces on API 29-30 (implicit from API 31).
// Done here BECAUSE AndroidManifest.xml must not carry a benchmark-only tag.
isProfileable = true
isMinifyEnabled = true
isShrinkResources = true
}
}
@@ -114,9 +256,41 @@ dependencies {
testImplementation(libs.bundles.unit.test)
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
testRuntimeOnly(libs.junit.platform.launcher)
// ── Instrumented tests (androidTest — device/emulator only; plan §7) ──────────
// Homes A34 (E2E against a real `npm start` host) and the Espresso/Compose half of
// A35. JUnit4 only: AndroidJUnitRunner cannot drive the JUnit5 platform.
androidTestImplementation(libs.bundles.android.instrumented.test)
// Compose UI assertions (gate banner / plan sheet / reconnect-banner precedence /
// session rows / continue-last banner). BOM-managed, same BOM as main.
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
// Supplies the empty host Activity that `createComposeRule()` launches into. Must be
// debugImplementation (it contributes a manifest entry to the app under test).
debugImplementation(libs.androidx.compose.ui.test.manifest)
// Hilt instrumented testing: HiltAndroidRule + the generated HiltTestApplication.
androidTestImplementation(libs.hilt.android.testing)
kspAndroidTest(libs.hilt.compiler)
}
// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules.
// Instrumented (androidTest) tasks are NOT of type Test, so they keep JUnit4.
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
// ─────────────────────────────────────────────────────────────────────────────
// Release-signing guard.
//
// Hooked as a doFirst on the PACKAGE task (not as a dependency) on purpose: packaging
// runs after dexing, so R8/resource-shrinking have fully completed and been reported by
// the time this fires. An unconfigured machine therefore still gets a real, verifiable
// R8 run — it just cannot produce a distributable artifact.
// ─────────────────────────────────────────────────────────────────────────────
if (!isReleaseSigningConfigured) {
val help = if (hasAllReleaseCredentials) MISSING_KEYSTORE_HELP else UNCONFIGURED_SIGNING_HELP
tasks.matching { it.name == "packageRelease" || it.name == "packageReleaseBundle" }
.configureEach {
doFirst { throw GradleException(help) }
}
}

View File

@@ -1,4 +1,117 @@
# WebTerm :app R8/ProGuard rules.
# Release is not minified yet (isMinifyEnabled = false); this file exists so the
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later.
# ─────────────────────────────────────────────────────────────────────────────
# WebTerm :app R8 keep rules (release + benchmark; both isMinifyEnabled = true).
#
# DISCIPLINE: every rule below is justified, and rules that turned out to be
# UNNECESSARY are recorded as such rather than added "just in case". A superfluous
# -keep silently costs size and hides real reachability problems; a MISSING one
# crashes only in production. Both were checked against the merged configuration
# R8 actually used, which is dumped to:
# app/build/outputs/mapping/<variant>/configuration.txt
# Read that file before adding anything here most of our stack already ships its
# own consumer rules, and duplicating them is how this file rots.
#
# ALREADY COVERED BY EMBEDDED CONSUMER RULES do NOT re-declare here:
# · kotlinx.serialization kotlinx-serialization-core-jvm ships
# META-INF/com.android.tools/r8/kotlinx-serialization-{common,r8}.pro, which keeps
# @Serializable classes' `Companion` fields, `serializer(...)`, object `INSTANCE`,
# the `$$serializer.descriptor` field, and RuntimeVisibleAnnotations (the R8
# full-mode fix). Verified present in 1.9.0. Our @Serializable models live in
# :wire-protocol / :api-client; the rules are global, so they are covered.
# · Hilt / Dagger — hilt-android + dagger AARs ship proguard.txt; all injection is
# generated code with compile-time references, no runtime reflection to preserve.
# · Tink — tink-android ships META-INF/proguard/protobuf.pro, keeping <fields> on
# subclasses of the shaded protobuf GeneratedMessageLite (the reflective lite
# runtime). Tink's key managers are registered explicitly by AeadConfig.register(),
# not discovered reflectively, so nothing else needs keeping.
# · FCM firebase-messaging ships consumer rules, AND `.push.FcmService`,
# `.push.DenyBroadcastReceiver`, `.push.AllowTrampolineActivity`, `.MainActivity`
# and `.WebTermApp` are all declared in AndroidManifest.xml, for which AGP
# auto-generates keep rules (build/intermediates/aapt_proguard_file/.../aapt_rules.txt).
# Manifest-declared components therefore need no rule here ever.
# · OkHttp / Okio, Compose, CameraX, ML Kit barcode, DataStore, navigation-compose,
# biometric all ship their own consumer rules.
# · JNI the default proguard-android-optimize.txt already contains
# `-keepclasseswithmembernames class * { native <methods>; }`, which covers the
# Termux emulator's native entry points even though we never fork a subprocess.
# ─────────────────────────────────────────────────────────────────────────────
# ── Termux terminal emulator + view (the ONE thing that genuinely needs a rule) ──
#
# Why this is not "just in case":
# 1. The JitPack artifacts (com.github.termux.termux-app:terminal-{emulator,view},
# v0.118.0) ship NO consumer rules at all — nobody upstream has reasoned about R8
# for these modules, because Termux itself ships unminified.
# 2. :terminal-view deliberately binds to NON-API internals of the pinned version
# (plan §6.1 — TerminalView is `public final`, so we fork rather than subclass):
# · RemoteTerminalHostView writes and reads the public field
# `com.termux.view.TerminalView.mEmulator` directly (installEmulator /
# the TerminalViewClient callbacks / computeVerticalScrollRange).
# · TerminalResizeDriver calls `TerminalRenderer.getFontWidth()` and
# `getFontLineSpacing()` — public accessors verified with `javap -p`, but not
# part of any documented API surface.
# · TerminalScrollGesture reads `mEmulator` and drives KeyHandler.
# Field/method RENAMING alone would survive that (R8 rewrites both sides), but R8
# full mode also does class merging, member inlining and access relaxation across
# a library whose invariants we are already stretching. A wrong outcome here is a
# crash in the app's single core surface the terminal visible ONLY in release.
# 3. The cost is bounded and small: two Apache-2.0 modules, ~120 KB of classes.
#
# So: keep them whole. Revisit only with a device-verified minified build in hand.
-keep class com.termux.terminal.** { *; }
-keep class com.termux.view.** { *; }
# ── ML Kit / Firebase component discovery (reflective no-arg constructors) ───────
#
# OBSERVED, not speculative. Installing the minified `benchmark` APK on an API-35
# emulator and cold-starting it produced, with no rule here:
#
# W ComponentDiscovery: Invalid component registrar.
# Could not instantiate com.google.mlkit.common.internal.CommonComponentRegistrar
# ... at com.google.mlkit.common.internal.MlKitInitProvider.onCreate
# Caused by: java.lang.NoSuchMethodException:
# com.google.mlkit.common.internal.CommonComponentRegistrar.<init> []
# (and the same for com.google.mlkit.vision.common.internal.VisionCommonRegistrar)
#
# Firebase's ComponentDiscovery instantiates registrars named in <meta-data> by
# `getDeclaredConstructor()`. The class names survive, but R8 removed the no-arg
# constructors because nothing in the app calls them. The failure is a WARNING, not a
# crash: the app starts fine and the ML Kit component graph is simply never registered,
# so **QR pairing (A19 barcode scanning) silently stops working in minified builds
# only**. Exactly the release-only breakage this file has to prevent.
#
# `<init>();` (no access modifier) matches any visibility, which is what
# getDeclaredConstructor needs. Scoped to registrars, so it keeps ~a dozen tiny classes.
-keep class * implements com.google.firebase.components.ComponentRegistrar {
<init>();
}
# ── javax.naming does not exist on Android ──────────────────────────────────────
#
# R8 refuses to run without these two, so they are load-bearing for ANY minified build.
# They are NOT a benign suppression — read this before deleting them:
#
# :client-tls CertificateSummary.kt uses `javax.naming.ldap.LdapName` to pull the CN
# out of an RFC2253 DN. That package is JDK-only; it is absent from android.jar and
# from the ART bootclasspath. So on a real device `CertificateSummaryReader.commonName`
# throws NoClassDefFoundError, which is an Error and therefore slips straight through
# that function's `catch (_: Exception)` guard.
#
# The module is pure-JVM and its unit tests run on a real JDK where LdapName DOES
# exist, which is why the green JVM suite never caught it. Tracked as a handoff to
# :client-tls's owner (hand-parse the DN, or catch Throwable). This -dontwarn only
# stops R8 from failing the build over an unresolvable reference; it does not and
# cannot fix the device behaviour.
-dontwarn javax.naming.ldap.LdapName
-dontwarn javax.naming.ldap.Rdn
# ── Crash triage ────────────────────────────────────────────────────────────────
# Keep line numbers so release stack traces are usable, and rename the source-file
# attribute to a single constant so no original filenames leak in the APK. Retrace
# release traces with app/build/outputs/mapping/release/mapping.txt (ARCHIVE IT with
# every distributed artifact without it a crash report is unreadable).
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,28 @@
package wang.yaojia.webterm
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
/**
* The instrumentation runner named by `:app`'s `testInstrumentationRunner`.
*
* A custom runner is **required**, not stylistic: [newApplication] is the only hook that can replace the
* app-under-test's [Application] with the generated [HiltTestApplication]. Setting `android:name` in the
* androidTest manifest cannot do it — that manifest is merged into the *test* APK, not into the app under
* test, so the real [WebTermApp] would still be installed and `@HiltAndroidTest` injection would fail.
*
* `assembleDebugAndroidTest` builds fine without this class (the runner name is only a manifest value);
* an actual instrumentation *run* would fail with `ClassNotFoundException`. See `android/README.md` →
* "Instrumented tests".
*
* Note that installing [HiltTestApplication] replaces [WebTermApp], so anything [WebTermApp] does in
* `onCreate` (push registration, notification channels) does NOT happen under instrumentation. That is
* deliberate — a test must opt into those via Hilt test modules rather than inherit them.
*/
public class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

View File

@@ -0,0 +1,106 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.ServerMessage
/**
* **F9 — bad `Origin` on the WS upgrade is rejected. The one non-skippable defence.**
*
* This app hands a full shell to anyone who can reach the port, and Origin validation on the WebSocket
* handshake is the single thing standing between that shell and a malicious page open in the user's
* browser (Cross-Site WebSocket Hijacking). TECH_DOC §7 and plan §8 both call it non-negotiable, and it is
* the only security control in this codebase that cannot be compensated for elsewhere.
*
* A unit test cannot verify it: the check lives in the *server's* upgrade handler, so the only honest proof
* is a real socket carrying a real foreign `Origin` and being refused. That is what this class does, from
* the device, against the real server. It also pins the two neighbouring cases the check must not get
* wrong — a MISSING Origin (default-deny) and the correct Origin (must still work, or the "defence" is
* just a broken client).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class CswshDefenceE2eTest {
/**
* The attack: a page on another origin opens `ws://<lan-ip>:3000/term`. The browser stamps ITS origin,
* which is not in the server's allow-list, and the handshake must be refused with 401 — before any
* frame can be exchanged.
*/
@Test
fun aForeignOriginIsRefusedOnTheWsUpgrade() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host, origin = FOREIGN_ORIGIN)
try {
val failure = socket.failureOrNull()
assertNotNull(
"the handshake must FAIL with a foreign Origin — it opened instead, which is a CSWSH hole",
failure,
)
assertEquals(
"the refusal must be a 401 (src/server.ts writes it before destroying the socket)",
HTTP_UNAUTHORIZED,
socket.handshakeStatusOrNull(),
)
} finally {
socket.cancel()
}
}
/**
* Default-deny: a non-browser client that sends no `Origin` at all must also be refused. `curl` and
* scripts land here, and `isOriginAllowed(undefined, …)` returning false is the single central policy
* point — an allow-if-absent would make the whole check trivially bypassable.
*/
@Test
fun aMissingOriginIsRefusedOnTheWsUpgrade() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host, origin = null)
try {
assertNotNull(
"an absent Origin must be refused (default-deny), not treated as trusted",
socket.failureOrNull(),
)
assertEquals(HTTP_UNAUTHORIZED, socket.handshakeStatusOrNull())
} finally {
socket.cancel()
}
}
/**
* The control: the SAME dial with the endpoint-derived `Origin` (byte-equal to what
* `HostEndpoint.originHeader` produces, and to what `src/http/origin.ts` expects) must open and attach.
* Without this, the two refusals above would also be satisfied by a server that rejects everything.
*/
@Test
fun theEndpointDerivedOriginIsAccepted() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
assertTrue(
"the production Origin must be accepted, got failure=${socket.failureOrNull()}",
socket.failureOrNull() == null,
)
socket.send(ClientMessage.Attach(sessionId = null))
sessionId = (socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached)
.sessionId
} finally {
socket.close()
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
}
}
private companion object {
/** Deliberately not a real host: the point is that it is not in the server's allow-list. */
const val FOREIGN_ORIGIN = "http://evil.example"
const val HTTP_UNAUTHORIZED = 401
}
}

View File

@@ -0,0 +1,185 @@
package wang.yaojia.webterm.e2e
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.MessageCodec
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.WireConstants
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* One live WS connection to a real WebTerm server, driven synchronously so an E2E test reads as a
* transcript.
*
* It deliberately does NOT reuse `:transport-okhttp`'s [wang.yaojia.webterm.transport.OkHttpTermTransport]:
* that class stamps `Origin` from the endpoint by construction, which is exactly the thing the F9 test has
* to be able to falsify. So the upgrade request is built here, and `Origin` is a parameter. Everything
* else that matters IS production code — [MessageCodec] encodes and decodes every frame, so these tests
* are a check of the frozen wire contract against the real server, not of a parallel test codec.
*/
internal class E2eSocket private constructor(
private val opened: CountDownLatch,
private val closed: CountDownLatch,
) : WebSocketListener() {
private val frames = LinkedBlockingQueue<String>()
/**
* Every `output` payload, accumulated as frames ARRIVE rather than as a test pulls them.
*
* This is not an optimisation, it is a correctness requirement. On a re-attach the server replays the
* ring buffer INSIDE `manager.handleAttach`, i.e. it puts the replay `output` frame on the wire BEFORE
* the `attached` confirmation. A reader that scanned the queue for `attached` first would consume and
* discard the replay on the way past — which is exactly how the first run of the replay test reported
* "0 chars" against a server that had sent hundreds of kilobytes.
*/
private val output = StringBuilder()
private val lastFrameAt = java.util.concurrent.atomic.AtomicLong(System.nanoTime())
private val socket = AtomicReference<WebSocket?>(null)
private val failure = AtomicReference<Throwable?>(null)
private val handshakeStatus = AtomicReference<Int?>(null)
private val closeCode = AtomicReference<Int?>(null)
override fun onOpen(webSocket: WebSocket, response: Response) {
handshakeStatus.set(response.code)
socket.set(webSocket)
opened.countDown()
}
override fun onMessage(webSocket: WebSocket, text: String) {
lastFrameAt.set(System.nanoTime())
frames.put(text)
(MessageCodec.decodeServer(text) as? ServerMessage.Output)?.let { frame ->
synchronized(output) { output.append(frame.data) }
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
handshakeStatus.set(response?.code)
failure.set(t)
opened.countDown()
closed.countDown()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
closeCode.set(code)
closed.countDown()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
closeCode.set(code)
webSocket.close(code, reason)
}
// ── driving ──────────────────────────────────────────────────────────────────────────────────
fun send(message: ClientMessage) {
val live = socket.get()
assertNotNull("cannot send $message — the WS never opened", live)
assertTrue("the WS refused to enqueue $message", live!!.send(MessageCodec.encode(message)))
}
/** The next decodable server frame matching [predicate], or fail with what actually arrived. */
fun awaitFrame(what: String, timeoutMs: Long = FRAME_TIMEOUT_MS, predicate: (ServerMessage) -> Boolean): ServerMessage {
val seen = ArrayList<String>()
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
while (System.nanoTime() < deadline) {
val remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()).coerceAtLeast(1)
val raw = frames.poll(remaining, TimeUnit.MILLISECONDS) ?: break
seen += raw.take(RAW_FRAME_LOG_CHARS)
val decoded = MessageCodec.decodeServer(raw)
if (decoded != null && predicate(decoded)) return decoded
}
throw AssertionError(
"no frame matching \"$what\" within $timeoutMs ms. Frames seen: $seen. " +
"failure=${failure.get()} closeCode=${closeCode.get()}",
)
}
/**
* Wait for the wire to go quiet for [quietMs], then take everything `output` has carried since the last
* call. Bounded by [DRAIN_CEILING_MS] so a session that never stops talking cannot hang the suite.
*/
fun drainOutput(quietMs: Long = QUIET_MS): String {
val ceiling = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DRAIN_CEILING_MS)
while (System.nanoTime() < ceiling) {
val idleMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastFrameAt.get())
if (idleMs >= quietMs) break
Thread.sleep(DRAIN_POLL_MS)
}
return synchronized(output) {
val text = output.toString()
output.setLength(0)
text
}
}
fun awaitClosed(timeoutMs: Long = FRAME_TIMEOUT_MS): Boolean =
closed.await(timeoutMs, TimeUnit.MILLISECONDS)
fun failureOrNull(): Throwable? = failure.get()
fun handshakeStatusOrNull(): Int? = handshakeStatus.get()
fun close() {
socket.get()?.close(NORMAL_CLOSURE, null)
}
fun cancel() {
socket.get()?.cancel()
}
companion object {
const val NORMAL_CLOSURE: Int = 1000
private const val OPEN_TIMEOUT_MS = 8_000L
private const val FRAME_TIMEOUT_MS = 10_000L
private const val QUIET_MS = 700L
private const val DRAIN_CEILING_MS = 30_000L
private const val DRAIN_POLL_MS = 50L
private const val RAW_FRAME_LOG_CHARS = 200
/**
* Dial the host's `/term`. Returns as soon as the handshake resolves either way, so a rejection
* (the F9 case) is observable rather than a hang.
*
* @param origin what to put in the `Origin` header. Defaults to the endpoint-derived value, i.e.
* exactly what production sends; a foreign value is how the CSWSH defence is falsified.
*/
fun dial(
host: WebTermTestHost.ReachableHost,
origin: String? = host.endpoint.originHeader,
): E2eSocket {
val opened = CountDownLatch(1)
val listener = E2eSocket(opened, CountDownLatch(1))
val request = Request.Builder()
.url(host.endpoint.wsUrl)
.apply { origin?.let { header("Origin", it) } }
.build()
host.client.newWebSocket(request, listener)
assertTrue(
"the WS handshake to ${host.endpoint.wsUrl} neither opened nor failed within $OPEN_TIMEOUT_MS ms",
opened.await(OPEN_TIMEOUT_MS, TimeUnit.MILLISECONDS),
)
return listener
}
/** `attach` must be the FIRST frame on every (re)connect (plan §4.1). */
fun attach(host: WebTermTestHost.ReachableHost, sessionId: String?, cwd: String? = null): Pair<E2eSocket, String> {
val socket = dial(host)
socket.send(ClientMessage.Attach(sessionId = sessionId, cwd = cwd))
val attached = socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached
return socket to attached.sessionId
}
/** The soft-reset prefix the server puts in front of a ring-buffer replay — never stripped (§4.1). */
const val REPLAY_PREFIX: String = WireConstants.REPLAY_SOFT_RESET_PREFIX
}
}

View File

@@ -0,0 +1,115 @@
package wang.yaojia.webterm.e2e
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.junit.Assert.assertTrue
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* A permission gate held open on a real server, created exactly the way Claude Code creates one.
*
* `POST /hook/permission` is the hook side-channel: the server holds the HTTP request open until a decision
* arrives (or it times out), then writes the decision JSON as the response — which the hook's `curl` feeds
* back to Claude on stdout. So the *held request itself* is the observable for "is the gate still held", and
* that is the only way to tell a real resolution from a UI that merely stopped showing the banner.
*
* Two server preconditions are worth knowing before reading a failure here:
* - **Loopback only.** `POST /hook/permission` 403s a non-loopback peer. From an emulator this is satisfied
* for free: `10.0.2.2` is forwarded to the host's loopback, so the server sees `127.0.0.1`. Over a LAN
* address it will 403, and [hold] says so rather than timing out mysteriously.
* - **Someone must be watching.** The server only HOLDS when the session has an attached client or a
* registered push target; otherwise it answers `{}` immediately and lets Claude prompt locally. The
* caller therefore attaches first.
*/
internal class HeldGate private constructor(
private val call: Call,
private val completed: CountDownLatch,
private val body: AtomicReference<String?>,
private val failure: AtomicReference<IOException?>,
) {
/** Whether the server has already answered — i.e. the gate is no longer held. */
fun hasResponded(): Boolean = completed.count == 0L
/** The decision body the server finally wrote, or fail if the hold never resolved. */
fun awaitResponse(timeoutMs: Long = RESPONSE_TIMEOUT_MS): String {
val done = completed.await(timeoutMs, TimeUnit.MILLISECONDS)
if (!done) {
call.cancel()
throw AssertionError("the held /hook/permission request never resolved within $timeoutMs ms")
}
failure.get()?.let { throw AssertionError("the held /hook/permission request failed: $it") }
return body.get() ?: ""
}
companion object {
private const val RESPONSE_TIMEOUT_MS = 15_000L
private const val HOLD_SETTLE_MS = 600L
private val JSON = "application/json; charset=utf-8".toMediaType()
/** The tool name the server maps to a plain two-way `tool` gate (anything but `ExitPlanMode`). */
private const val TOOL_NAME = "Bash"
/**
* Start holding a gate for [sessionId]. Returns once the request is in flight and the server has
* had time to either hold it or refuse it — and fails loudly if it refused, because a test that
* proceeded from here would be asserting against a gate that does not exist.
*/
fun hold(host: WebTermTestHost.ReachableHost, sessionId: String): HeldGate {
val completed = CountDownLatch(1)
val body = AtomicReference<String?>(null)
val failure = AtomicReference<IOException?>(null)
val request = Request.Builder()
.url(host.endpoint.baseUrl + "/hook/permission")
.header("x-webterm-session", sessionId)
// `tool_input` is untrusted by contract; a harmless command keeps the derived preview real.
.post(
"""{"tool_name":"$TOOL_NAME","tool_input":{"command":"echo webterm-e2e-gate"}}"""
.toByteArray()
.toRequestBody(JSON),
)
.build()
// Its own client: the hold occupies the connection for as long as the gate lives, and the read
// timeout has to outlast that, which must not be imposed on the rest of the suite.
val holdingClient = host.client.newBuilder()
.readTimeout(RESPONSE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
.build()
val call = holdingClient.newCall(request)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
failure.set(e)
completed.countDown()
}
override fun onResponse(call: Call, response: Response) {
response.use { body.set(it.body?.string() ?: "") }
completed.countDown()
}
})
// Give the server a moment to register the hold (or refuse it outright).
Thread.sleep(HOLD_SETTLE_MS)
val gate = HeldGate(call, completed, body, failure)
if (gate.hasResponded()) {
val answered = body.get().orEmpty().replace(" ", "")
assertTrue(
"""
|POST /hook/permission did not HOLD, so there is no gate to test. It answered immediately
|with "$answered" (failure=${failure.get()}). The two reasons this happens:
| 1. the peer was not loopback (403) — use the emulator's http://10.0.2.2:<port> alias,
| which the host sees as 127.0.0.1, rather than a LAN address;
| 2. nothing was watching the session ({}) — attach a client before holding.
""".trimMargin(),
false,
)
}
return gate
}
}
}

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.ServerMessage
import java.util.UUID
/**
* **A34 — a held permission gate, and what may and may not resolve it.**
*
* A gate is created the way Claude Code creates one: `POST /hook/permission` from the host's own loopback
* (which is what the emulator's `10.0.2.2` alias resolves to on the host side, so the loopback-only guard
* is satisfied without any special setup). The server holds that HTTP request open until a decision
* arrives, and pushes a `status` frame with `pending: true` to every attached client — which is the frame
* the cockpit's gate card renders.
*
* ### An honest boundary: the `/hook/decision` HAPPY path is not automatable
* `/hook/decision` requires the per-decision capability token, and that token leaves the server **only**
* inside a push payload (`pushService.notify(session, 'needs-input', token)`) — never over the WS, never in
* `/live-sessions`. That is deliberate payload minimisation (plan §8), and it means no automated client can
* legitimately obtain it. So this class proves the parts that ARE provable and does not fake the rest:
*
* - a gate really is held, and the held-gate `status` frame really reaches an attached device;
* - `/hook/decision` **refuses** a well-formed but wrong token (403) and a malformed body (400), and the
* gate is still held afterwards — the security half, and the half a bug would most likely break;
* - the gate resolves through the in-app path (`approve` on the WS), observed by the held
* `POST /hook/permission` request finally returning.
*
* The remaining step — a real notification-action POST with a real token — stays on the device-QA checklist
* under Push, where it belongs, because it needs a real FCM delivery. It is NOT quietly asserted here.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class HeldGateE2eTest {
/**
* A wrong capability token must be refused and must leave the gate held. This is the SEC-C1/M1
* contract: the token is the whole authorisation for a decision made from a lock screen, so a
* mismatched, stale or absent one has to be a hard 403.
*/
@Test
fun hookDecisionRefusesAWrongTokenAndLeavesTheGateHeld() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
val gate = HeldGate.hold(host, sessionId)
try {
// The gate must actually be held before the refusal means anything.
val status = socket.awaitFrame("the held-gate status frame") {
it is ServerMessage.Status && it.pending
} as ServerMessage.Status
assertEquals("a plain tool gate must be reported as such", GateKind.TOOL, status.gate)
assertEquals(ClaudeStatus.WAITING, status.status)
val wrongToken = UUID.randomUUID().toString()
val (refused, _) = host.postJson(
"/hook/decision",
"""{"sessionId":"$sessionId","decision":"allow","token":"$wrongToken"}""",
origin = host.endpoint.originHeader,
)
assertEquals(
"a mismatched capability token must 403 — it is the only thing authorising a remote decision",
WebTermTestHost.ReachableHost.FORBIDDEN,
refused,
)
val (malformed, _) = host.postJson(
"/hook/decision",
"""{"sessionId":"$sessionId","decision":"maybe","token":"$wrongToken"}""",
origin = host.endpoint.originHeader,
)
assertEquals(
"an unknown decision verb must 400, never be coerced into allow",
HTTP_BAD_REQUEST,
malformed,
)
assertTrue(
"the refusals must not have resolved the gate — the hook request must still be held",
!gate.hasResponded(),
)
} finally {
socket.send(ClientMessage.Reject) // release the hold so the server is left clean
gate.awaitResponse()
socket.close()
runCatching { host.delete("/live-sessions/$sessionId") }
}
}
/**
* `/hook/decision` is a GUARDED route, so a foreign `Origin` must 403 before the token is even looked
* at. Otherwise a malicious page could spend a token it somehow observed.
*/
@Test
fun hookDecisionRefusesAForeignOrigin() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
val (status, _) = host.postJson(
"/hook/decision",
"""{"sessionId":"$sessionId","decision":"allow","token":"${UUID.randomUUID()}"}""",
origin = FOREIGN_ORIGIN,
)
assertEquals(
"the Origin guard must run before the token check on /hook/decision",
WebTermTestHost.ReachableHost.FORBIDDEN,
status,
)
} finally {
socket.close()
runCatching { host.delete("/live-sessions/$sessionId") }
}
}
/**
* The in-app resolution path, end to end: the attached device sends `approve` (with the mode as a
* TOP-LEVEL key, per plan §4.1) and the held `POST /hook/permission` request returns — which is the
* server telling Claude Code to proceed. Observing that return is the only proof the hold was really
* released rather than merely hidden in the UI.
*/
@Test
fun approveOnTheWireResolvesTheHeldGate() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
val gate = HeldGate.hold(host, sessionId)
try {
socket.awaitFrame("the held-gate status frame") { it is ServerMessage.Status && it.pending }
assertTrue("the hook request must still be held before we decide", !gate.hasResponded())
socket.send(ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS))
val body = gate.awaitResponse()
assertTrue(
"the held hook request must return a decision body, got: $body",
body.isNotEmpty() && body.trimStart().startsWith("{"),
)
assertTrue(
"an approval must not come back as a plain empty object (that is the timeout fallback)",
body.replace(" ", "") != "{}",
)
} finally {
socket.close()
runCatching { host.delete("/live-sessions/$sessionId") }
}
}
private companion object {
const val HTTP_BAD_REQUEST = 400
const val FOREIGN_ORIGIN = "http://evil.example"
}
}

View File

@@ -0,0 +1,256 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.Validation
import wang.yaojia.webterm.wire.WireConstants
import java.util.UUID
/**
* **A34 — instrumented E2E against a real `npm start` host.**
*
* Every test here runs on the device, over a real socket, against this repo's own server. The frames are
* encoded and decoded by production's [wang.yaojia.webterm.wire.MessageCodec], so a drift between the
* frozen client contract and the actual server surfaces here and nowhere else in the suite.
*
* If no host is reachable each test SKIPS with the command needed to make it reachable — see
* [WebTermTestHost]. It never quietly passes.
*
* Every session this class spawns is killed in a `finally`, via the guarded
* `DELETE /live-sessions/:id`, so a run leaves no orphan PTYs behind on the developer's machine.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class SessionRoundTripE2eTest {
/**
* The core loop the whole product rests on: `attach(null)` → `attached` with a server-issued id →
* typed bytes → the shell's own output comes back. Also pins the two contract details a client gets
* wrong most easily: the id must be adopted from the server (never the one we asked for), and Enter is
* `\r`.
*/
@Test
fun attachThenTypeThenOutput_roundTripsThroughARealPty() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
assertTrue(
"the server-issued session id must be a lowercase v4 UUID: $sessionId",
Validation.isValidSessionId(sessionId),
)
assertEquals("…and must parse as a UUID", sessionId, UUID.fromString(sessionId).toString())
// A marker the shell will echo back through the PTY. `\r` (never `\n`) is what Enter sends.
val marker = "webterm-e2e-${UUID.randomUUID()}"
socket.send(ClientMessage.Input("printf '%s\\n' $marker\r"))
val output = socket.awaitFrame("the marker echoed back by the shell") {
it is ServerMessage.Output && it.data.contains(marker)
}
assertTrue("expected an output frame, got $output", output is ServerMessage.Output)
} finally {
socket.close()
host.killQuietly(sessionId)
}
}
/**
* `attach` MUST be the first frame, with the `sessionId` key ALWAYS present (JSON `null` for a new
* session — the server rejects a missing key). Sending something else first must not bind a session:
* the server logs a protocol violation and keeps waiting, so the later `attach` still works. This is
* the ordering guarantee the whole reconnect ladder depends on.
*/
@Test
fun aNonAttachFirstFrameBindsNothing_andTheLaterAttachStillWorks() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
socket.send(ClientMessage.Input("this must be ignored\r"))
socket.send(ClientMessage.Resize(cols = 100, rows = 40))
socket.send(ClientMessage.Attach(sessionId = null))
val attached = socket.awaitFrame("attached after an out-of-order first frame") {
it is ServerMessage.Attached
} as ServerMessage.Attached
sessionId = attached.sessionId
assertTrue(Validation.isValidSessionId(attached.sessionId))
} finally {
socket.close()
sessionId?.let { host.killQuietly(it) }
}
}
/**
* **F5/F6 — reconnect replays the ring buffer, with no dropped bytes on a multi-MB replay.**
*
* The session is made to produce a large volume of output, the client detaches (which must NOT kill
* the PTY), then re-attaches by id. Every byte of the replay has to arrive: the assertion is not "some
* output came back" but that a *numbered sequence* is present, in order, with no gap — the only way to
* catch a truncated or interleaved replay. `MessageCodec` decodes the replay frame, so this also
* exercises the client's ability to receive a single very large text frame.
*/
@Test
fun reconnectReplaysTheRingBufferWithNoDroppedBytes() {
val host = WebTermTestHost.require()
val (first, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
// seq prints REPLAY_LINES numbered lines; awk pads each to ~PAD_BYTES so the ring holds
// hundreds of KB to a few MB of real PTY output rather than a handful of lines.
first.send(
ClientMessage.Input(
"seq 1 $REPLAY_LINES | awk '{ printf \"%s:\", \$1; " +
"for (i = 0; i < $PAD_REPEATS; i++) printf \"$PAD_CHUNK\"; printf \"\\n\" }'\r",
),
)
first.awaitFrame("the last line of the generated output") {
it is ServerMessage.Output && it.data.contains("$REPLAY_LINES:")
}
first.drainOutput()
// Detach only. The PTY must survive — that is the product's central design point.
first.close()
assertTrue("the first connection never closed", first.awaitClosed())
val stillRunning = host.get("/live-sessions").second
assertTrue(
"the session must survive the detach (PTY lifecycle != WS lifecycle)",
stillRunning.contains(sessionId),
)
val (second, adopted) = E2eSocket.attach(host, sessionId = sessionId)
try {
assertEquals("re-attach must adopt the same id", sessionId, adopted)
val replay = second.drainOutput(quietMs = REPLAY_QUIET_MS)
assertTrue(
"a replay must arrive at all; got ${replay.length} chars",
replay.length > MIN_REPLAY_CHARS,
)
assertTrue(
"the ESC[0m soft-reset prefix must be passed through, not stripped",
replay.contains(WireConstants.REPLAY_SOFT_RESET_PREFIX),
)
// The ring is a fixed-size window, so the OLDEST lines are legitimately gone. What must
// never happen is a HOLE: from the first line present to the last, every number must be
// there. That is what a dropped or reordered chunk would break.
// Anchored at a line start: an unanchored `contains("234:")` would also match inside
// "1234:", which would silently inflate the set and make the contiguity check vacuous.
val present = (1..REPLAY_LINES).filter { replay.contains("\n$it:") }
assertTrue("no numbered line survived the replay at all", present.isNotEmpty())
assertEquals(
"the replay has a hole — lines ${present.first()}..${present.last()} should be contiguous",
(present.first()..present.last()).toList(),
present,
)
assertTrue(
"the newest line must always be in the ring",
replay.contains("\n$REPLAY_LINES:"),
)
} finally {
second.close()
}
} finally {
host.killQuietly(sessionId)
}
}
/**
* A shell that exits ends the session: `exit` is terminal, and the row disappears from
* `/live-sessions`. This is the ordinary exit path — the `-1` spawn-failure path needs a deliberately
* broken server and lives in [SpawnFailureE2eTest].
*/
@Test
fun aShellThatExitsProducesATerminalExitFrame() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
socket.send(ClientMessage.Input("exit $EXPECTED_EXIT_CODE\r"))
val exit = socket.awaitFrame("the exit frame") { it is ServerMessage.Exit } as ServerMessage.Exit
assertEquals("the shell's own exit status must be reported verbatim", EXPECTED_EXIT_CODE, exit.code)
} finally {
socket.close()
host.killQuietly(sessionId)
}
}
/**
* **Kill via the guarded `DELETE /live-sessions/:id`** (the swipe-to-kill action). 204 the first time,
* and **404 the second time counts as success** — "already gone" is the desired end state, which is
* why the client must not treat it as an error (plan §4.3).
*/
@Test
fun killingASessionRemovesItAndASecondKillIsAlreadyGone() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
socket.close()
val (firstStatus, _) = host.delete("/live-sessions/$sessionId")
assertEquals("the first kill must be a 204", WebTermTestHost.ReachableHost.NO_CONTENT, firstStatus)
assertTrue(
"the killed session must be gone from /live-sessions",
!host.get("/live-sessions").second.contains(sessionId),
)
val (secondStatus, _) = host.delete("/live-sessions/$sessionId")
assertEquals(
"a second kill must be 404 (already gone = success), not an error the UI would surface",
WebTermTestHost.ReachableHost.NOT_FOUND,
secondStatus,
)
}
/**
* The Origin 铁律, positive half: the guarded kill route must be REFUSED without a byte-equal `Origin`.
* The negative half (a foreign origin on the WS upgrade, F9) is [CswshDefenceE2eTest].
*/
@Test
fun theGuardedKillRouteRefusesAForeignOrigin() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
socket.close()
try {
val (status, _) = host.delete("/live-sessions/$sessionId", origin = FOREIGN_ORIGIN)
assertEquals(
"a foreign Origin must 403 on a state-changing route",
WebTermTestHost.ReachableHost.FORBIDDEN,
status,
)
assertTrue(
"…and the session must still be alive, i.e. the refusal actually refused",
host.get("/live-sessions").second.contains(sessionId),
)
} finally {
host.killQuietly(sessionId)
}
}
/** Best-effort cleanup: 204 and 404 are both fine, and a transport error must not mask a real failure. */
private fun WebTermTestHost.ReachableHost.killQuietly(sessionId: String) {
runCatching { delete("/live-sessions/$sessionId") }
}
private companion object {
/** ~REPLAY_LINES × (PAD_REPEATS × 64) bytes of PTY output — comfortably past a single WS frame. */
const val REPLAY_LINES = 4_000
const val PAD_REPEATS = 8
const val PAD_CHUNK = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
const val MIN_REPLAY_CHARS = 100_000
const val REPLAY_QUIET_MS = 2_500L
/** An arbitrary non-zero status, chosen so a coincidental 0 cannot pass the assertion. */
const val EXPECTED_EXIT_CODE = 42
/** Not a real host — the point is that it is not in the server's allow-list. */
const val FOREIGN_ORIGIN = "http://evil.example"
}
}

View File

@@ -0,0 +1,139 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.WireConstants
/**
* **A34 — a host whose shell cannot start, and the spawn-failure banner.**
*
* ## What was expected, and what a real server actually does — MEASURED, 2026-07-30
* Plan §4.1 and `src/server.ts` (M4) say a spawn failure surfaces as `exit(-1)` with a required `reason`:
* `manager.handleAttach` throws, the server sends `exit(-1)` and closes that connection. The client turns
* that into the non-retryable "会话启动失败" banner instead of entering the reconnect ladder.
*
* **That path did not fire.** A server started with `SHELL_PATH=/nonexistent/shell` (port 3112, run against
* this suite on 2026-07-30) answered `attached` and then `exit` with **code 1, not -1**. The reason is
* structural, not a configuration slip: node-pty's `pty.fork()` returns successfully and the `execvp`
* failure happens in the FORKED CHILD, which `_exit(1)`s (`node_modules/node-pty/src/unix/pty.cc`, and the
* same in `spawn-helper.cc` for `chdir`). So nothing throws synchronously in `createSession`, `handleAttach`
* returns a live session, and the failure arrives later as an ordinary child exit. On POSIX, `exit(-1)`
* therefore looks **unreachable through any client-visible route** — which is reported to the plan owner
* rather than papered over here.
*
* ## So this class asserts what is true, and never pretends
* - Over the wire, against the deliberately-broken host: a session whose shell cannot be executed produces
* a **terminal, non-zero `exit`** and does not linger in `/live-sessions`. That is the behaviour a client
* must actually handle, and asserting `-1` here would be asserting a fiction.
* - The `-1` → banner reduction is asserted separately and is labelled as a MODEL assertion, not an
* over-the-wire one, because no reachable server input produces `-1` today. If the server ever gains a
* real `-1` path, the wire test above starts distinguishing the two and this stays correct.
*
* Without `-e webtermSpawnFailureHost` the wire test SKIPS with the exact command to provide one — it is
* never a silent pass.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class SpawnFailureE2eTest {
/**
* A host whose `SHELL_PATH` does not exist must not leave the user with a half-alive session: the
* `exit` frame has to arrive, be non-zero, and the session must be gone afterwards. Anything less and a
* walked-away user would sit on a "connecting…" spinner against a shell that can never run.
*/
@Test
fun aHostWhoseShellCannotStartProducesATerminalNonZeroExit() {
val host = WebTermTestHost.requireSpawnFailureHost()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
socket.send(ClientMessage.Attach(sessionId = null))
val frame = socket.awaitFrame("either attached or a terminal exit") {
it is ServerMessage.Attached || it is ServerMessage.Exit
}
(frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId }
val exit = frame as? ServerMessage.Exit
?: socket.awaitFrame("the exit that follows a failed exec") { it is ServerMessage.Exit }
as ServerMessage.Exit
assertTrue(
"a shell that cannot be executed must produce a NON-ZERO exit, got ${exit.code}",
exit.code != CLEAN_EXIT,
)
sessionId?.let { id ->
assertTrue(
"the dead session must not linger in /live-sessions",
!host.get("/live-sessions").second.contains(id),
)
}
} finally {
socket.close()
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
}
}
/**
* The CLIENT half of the `exit(-1)` contract, asserted on the model rather than over the wire — see the
* class doc for why no reachable server input produces `-1`. It stays here, next to the wire test, so
* the two are read together and the gap is visible instead of forgotten: if the sentinel ever does
* arrive, this is the behaviour it must drive.
*/
@Test
fun theMinusOneSentinelReducesToTheNonRetryableSpawnFailureBanner() {
val banner = BannerModel.Exited(WireConstants.SPAWN_FAILED_EXIT_CODE, "spawn /nonexistent/shell ENOENT")
assertTrue("code -1 must be recognised as a spawn failure", banner.isSpawnFailure)
assertTrue("a spawn failure must never show a retry spinner", !banner.showsSpinner)
assertTrue("…and must offer a new session instead", banner.offersNewSession)
assertNotNull("`reason` is what the banner shows the user; it is required on -1", banner.reason)
}
/**
* The neighbouring case a client must NOT confuse with a spawn failure: a bad `cwd` kills the child
* (node-pty's `spawn-helper` `chdir` → `_exit(1)`), which is an ordinary exit. Reporting it as `-1`
* would stop the client offering a retry in a case where retrying elsewhere works.
*/
@Test
fun aBadCwdIsAnOrdinaryExitNotASpawnFailure() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
socket.send(
ClientMessage.Attach(sessionId = null, cwd = "/webterm-e2e/definitely-does-not-exist"),
)
val frame = socket.awaitFrame("either attached or exit") {
it is ServerMessage.Attached || it is ServerMessage.Exit
}
(frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId }
val exit = frame as? ServerMessage.Exit
?: socket.awaitFrame("the child exiting after chdir failed") { it is ServerMessage.Exit }
as ServerMessage.Exit
assertEquals(
"a bad cwd must not be reported as the spawn-failure sentinel — it is the child that died",
false,
exit.code == WireConstants.SPAWN_FAILED_EXIT_CODE,
)
} finally {
socket.close()
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
}
}
private companion object {
const val CLEAN_EXIT = 0
}
}

View File

@@ -0,0 +1,219 @@
package wang.yaojia.webterm.e2e
import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.Assume
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Locating (or honestly declining to locate) a real WebTerm server for the A34 instrumented E2E suite.
*
* ### The rule this file exists to enforce
* **A test that cannot tell "passed" from "never ran" is worse than no test.** Every E2E test therefore
* goes through [require], which either hands back a *proven-reachable* host or raises a JUnit assumption
* failure carrying the exact command needed to make it reachable. It never returns a host it has not
* spoken to, and it never lets a test body no-op its way to green.
*
* ### Where the address comes from (never hardcoded, never personal)
* 1. `-e webtermHost <base-url>` — an instrumentation argument. This is how a LAN address is supplied; it
* is read at run time so no host of anyone's ever enters the source tree.
* 2. Otherwise [EMULATOR_HOST_ALIAS], which is not a personal address at all: `10.0.2.2` is the Android
* emulator's fixed platform alias for the host machine's loopback, documented by Google, and it is the
* right default because the server under test is this repo's own `npm start`.
*
* ### Two things that will bite whoever runs this
* - **`Origin`.** The server derives its allow-list from the host's NIC addresses (`src/config.ts`
* `deriveAllowedOrigins`), and `10.0.2.2` is a *guest-side* alias that never appears among them. So the
* three guarded routes 403 unless the server is started with that origin allowed:
* `ALLOWED_ORIGINS=http://10.0.2.2:3000 npm start`. [require] proves this up front rather than letting
* individual tests fail obscurely.
* - **`WEBTERM_TOKEN`.** If the server is token-gated, every route (and the WS upgrade) needs the
* `webterm_auth` cookie. Pass the token with `-e webtermToken <t>` and it is exchanged via `POST /auth`
* into the in-memory jar installed on the one [OkHttpClient] both the REST and WS halves use —
* the same "one client, one jar" shape production relies on. The token is never logged and never written
* anywhere.
*/
internal object WebTermTestHost {
const val ARG_HOST: String = "webtermHost"
const val ARG_TOKEN: String = "webtermToken"
const val ARG_SPAWN_FAILURE_HOST: String = "webtermSpawnFailureHost"
/** The emulator's fixed alias for the host machine's loopback (an Android platform constant). */
const val EMULATOR_HOST_ALIAS: String = "http://10.0.2.2:3000"
private const val CONNECT_TIMEOUT_S = 3L
private const val READ_TIMEOUT_S = 10L
private const val HTTP_OK = 200
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404
private val JSON = "application/json; charset=utf-8".toMediaType()
fun argument(name: String): String? =
InstrumentationRegistry.getArguments().getString(name)?.takeIf { it.isNotBlank() }
/**
* A reachable host, or a JUnit assumption failure (reported as SKIPPED, never as a pass) explaining
* exactly how to provide one.
*/
fun require(): ReachableHost {
val baseUrl = argument(ARG_HOST) ?: EMULATOR_HOST_ALIAS
val endpoint = HostEndpoint.fromBaseUrl(baseUrl)
Assume.assumeTrue(
"`-e $ARG_HOST $baseUrl` is not a usable http(s) base URL, so no E2E host could be built.",
endpoint != null,
)
return probe(endpoint!!, argument(ARG_TOKEN))
}
/**
* The optional second host for the "shell cannot start" case. It needs its own deliberately-broken
* instance because no client input can make a healthy host fail to spawn.
*
* MEASURED 2026-07-30: such a host answers `exit` with code **1**, not the documented `-1` sentinel —
* node-pty's `pty.fork()` succeeds and the `execvp` failure happens in the forked child, which
* `_exit(1)`s. See [SpawnFailureE2eTest] for the full finding.
*/
fun requireSpawnFailureHost(): ReachableHost {
val baseUrl = argument(ARG_SPAWN_FAILURE_HOST)
Assume.assumeTrue(
"""
|SKIPPED — no broken-shell host was supplied, so the failed-spawn path could not be exercised.
|Start a second server whose shell does not exist and point the suite at it:
| SHELL_PATH=/nonexistent/shell PORT=3112 ALLOWED_ORIGINS=http://10.0.2.2:3112 npm start
| ./gradlew :app:connectedDebugAndroidTest \
| -Pandroid.testInstrumentationRunnerArguments.$ARG_SPAWN_FAILURE_HOST=http://10.0.2.2:3112
""".trimMargin(),
baseUrl != null,
)
val endpoint = HostEndpoint.fromBaseUrl(baseUrl!!)
Assume.assumeTrue("`-e $ARG_SPAWN_FAILURE_HOST $baseUrl` is not a usable http(s) base URL.", endpoint != null)
return probe(endpoint!!, argument(ARG_TOKEN))
}
private fun probe(endpoint: HostEndpoint, token: String?): ReachableHost {
val cookieJar = MemoryCookieJar()
val client = OkHttpClient.Builder()
.cookieJar(cookieJar)
.connectTimeout(CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_S, TimeUnit.SECONDS)
.pingInterval(0, TimeUnit.MILLISECONDS)
.build()
val host = ReachableHost(endpoint, client)
val firstStatus = try {
host.get("/live-sessions").first
} catch (e: IOException) {
Assume.assumeNoException(
"""
|SKIPPED — no WebTerm server answered at ${endpoint.baseUrl} (${e.javaClass.simpleName}: ${e.message}).
|Start this repo's own server on the machine hosting the emulator, allowing the guest-side
|origin so the three guarded routes are usable:
| ALLOWED_ORIGINS=${endpoint.originHeader} npm start
|or point the suite at a LAN address instead:
| -Pandroid.testInstrumentationRunnerArguments.$ARG_HOST=http://<lan-ip>:3000
""".trimMargin(),
e,
)
error("unreachable") // assumeNoException always throws
}
if (firstStatus == HTTP_UNAUTHORIZED) {
Assume.assumeTrue(
"""
|SKIPPED — ${endpoint.baseUrl} is gated by WEBTERM_TOKEN and no token was supplied. Pass it with
| -Pandroid.testInstrumentationRunnerArguments.$ARG_TOKEN=<the token>
|(the token is only exchanged for the webterm_auth cookie; it is never logged or persisted).
""".trimMargin(),
token != null,
)
val authStatus = host.postJson("/auth", """{"token":${quote(token!!)}}""").first
Assume.assumeTrue(
"SKIPPED — POST /auth at ${endpoint.baseUrl} rejected the supplied token (HTTP $authStatus).",
authStatus == HTTP_NO_CONTENT,
)
}
val (status, body) = host.get("/live-sessions")
Assume.assumeTrue(
"SKIPPED — GET /live-sessions at ${endpoint.baseUrl} answered HTTP $status, so this is not a usable WebTerm host.",
status == HTTP_OK,
)
Assume.assumeTrue(
"SKIPPED — ${endpoint.baseUrl} answered GET /live-sessions with a non-array body, so it is not WebTerm.",
body.trimStart().startsWith("["),
)
return host
}
private fun quote(value: String): String = buildString {
append('"')
for (ch in value) {
when (ch) {
'"' -> append("\\\"")
'\\' -> append("\\\\")
else -> append(ch)
}
}
append('"')
}
/**
* A host that has been *proven* to answer. Exposes only what the E2E tests need, over the one shared
* client (so the auth cookie covers REST and the WS upgrade alike).
*/
internal class ReachableHost(val endpoint: HostEndpoint, val client: OkHttpClient) {
fun get(path: String): Pair<Int, String> = exchange(
Request.Builder().url(endpoint.baseUrl + path).get().build(),
)
/** A GUARDED route: `Origin` stamped byte-equal to [HostEndpoint.originHeader] (plan §4.3). */
fun postJson(path: String, json: String, origin: String? = null): Pair<Int, String> = exchange(
Request.Builder()
.url(endpoint.baseUrl + path)
.post(json.toByteArray().toRequestBody(JSON))
.apply { origin?.let { header("Origin", it) } }
.build(),
)
fun delete(path: String, origin: String = endpoint.originHeader): Pair<Int, String> = exchange(
Request.Builder().url(endpoint.baseUrl + path).delete().header("Origin", origin).build(),
)
private fun exchange(request: Request): Pair<Int, String> =
client.newCall(request).execute().use { it.code to (it.body?.string() ?: "") }
companion object {
const val OK: Int = HTTP_OK
const val NO_CONTENT: Int = HTTP_NO_CONTENT
const val UNAUTHORIZED: Int = HTTP_UNAUTHORIZED
const val FORBIDDEN: Int = HTTP_FORBIDDEN
const val NOT_FOUND: Int = HTTP_NOT_FOUND
}
}
/** Minimal in-memory jar — enough to carry `webterm_auth` across REST calls and the WS upgrade. */
private class MemoryCookieJar : CookieJar {
private val cookies = mutableMapOf<String, Cookie>()
override fun loadForRequest(url: HttpUrl): List<Cookie> = synchronized(cookies) {
cookies.values.filter { it.matches(url) }
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = synchronized(this.cookies) {
cookies.forEach { this.cookies[it.name] = it }
}
}
}

View File

@@ -0,0 +1,188 @@
package wang.yaojia.webterm.terminal
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **the CRITICAL one: one finger-swipe inside an alternate-screen TUI killed the process.**
*
* Stock `TerminalView.doScroll` (offsets 5679 of the pinned `terminal-view-v0.118.0.aar`) turns a scroll
* into `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` **whenever the alternate screen buffer is active**, and
* `handleKeyCode` offsets 1519 are `mTermSession.getEmulator()` with NO null guard. `mEmulator` is
* guarded; `mTermSession` is not, and it is null forever in this app (plan §6.1). No `TerminalViewClient`
* callback sits anywhere on that path, so installing a client could not help — only taking the gesture
* away from the stock view could.
*
* **Claude Code is an alternate-screen TUI.** That makes this ordinary use, not an edge case: scrolling
* back through a running Claude session was a guaranteed crash. Every test below therefore enters the
* alternate buffer with `ESC [ ? 1 0 4 9 h` FIRST, then drives the real gesture through
* [android.view.View.dispatchTouchEvent] / `dispatchGenericMotionEvent` — the three stock entry points
* (`TerminalView$1.onScroll`, the `TerminalView$1$1` fling runnable, `onGenericMotionEvent`).
*
* "It did not crash" is necessary but not sufficient, so each test also pins the bytes: the alternate-
* buffer branch must emit the DECCKM-correct arrow form, the mouse-tracking branch must emit a mouse
* report and NOT arrows, and the fling must be silent (proof the stock runnable never started).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class AlternateScreenScrollRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
harness.awaitAttachSettled()
// ESC [ ? 1 0 4 9 h — enter the alternate screen buffer. This is the state Claude Code, vim,
// htop, less and every git pager run in, and the state that made stock scrolling fatal.
harness.feedAndAwait("\u001b[?1049h", "the alternate screen buffer to be active") {
harness.emulator.isAlternateBufferActive
}
harness.clearSent()
}
@After
fun tearDown() {
harness.close()
}
@Test
fun swipeUpInsideTheAlternateBuffer_doesNotCrashAndEmitsDownArrows() {
val claimed = harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX)
assertTrue(
"the frame must claim the vertical drag — that is what makes stock doScroll unreachable",
claimed,
)
assertTrue("the alternate buffer must still be active", harness.emulator.isAlternateBufferActive)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
assertEquals(
"a finger moving UP the screen scrolls the TUI forward — DPAD_DOWN in CSI form",
emptyList<ClientMessage.Input>(),
frames.filterNot { it.data == "\u001b[B" },
)
}
@Test
fun swipeDownInsideTheAlternateBuffer_doesNotCrashAndEmitsUpArrows() {
val claimed = harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX)
assertTrue("the frame must claim the vertical drag", claimed)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
assertEquals(
"a finger moving DOWN the screen scrolls back — DPAD_UP in CSI form",
emptyList<ClientMessage.Input>(),
frames.filterNot { it.data == "\u001b[A" },
)
}
/**
* The arrow FORM has to follow the emulator's live DECCKM bit, because that is what stock
* `handleKeyCode` would have read off `mTermSession.getEmulator()`. A TUI in application-cursor mode
* that received `ESC [ A` instead of `ESC O A` would mis-handle the scroll.
*/
@Test
fun scrollUnderApplicationCursorMode_emitsTheSs3ArrowForm() {
harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") {
harness.emulator.isCursorKeysApplicationMode
}
harness.clearSent()
harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
assertEquals(
"application-cursor mode must emit the SS3 form",
emptyList<ClientMessage.Input>(),
frames.filterNot { it.data == "\u001bOA" },
)
}
/**
* `doScroll`'s FIRST branch (offsets 2653) is mouse tracking, and it must win over the alternate-
* buffer branch. Getting the order wrong would send arrow keys to a program that asked for mouse
* reports — the terminal would appear to ignore the wheel.
*/
@Test
fun wheelUnderMouseTracking_reportsTheWheelAndSendsNoArrowKeys() {
// ESC [ ? 1 0 0 0 h — button-press mouse tracking (what a mouse-aware TUI enables).
harness.feedAndAwait("\u001b[?1000h", "mouse tracking to be active") {
harness.emulator.isMouseTrackingActive
}
harness.clearSent()
val handled = harness.scrollWheel(up = true)
assertTrue("the frame must claim the mouse wheel", handled)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("the wheel must be reported to the program", frames.isNotEmpty())
assertEquals(
"mouse tracking must suppress the arrow-key branch entirely",
emptyList<ClientMessage.Input>(),
frames.filter { it.data == "\u001b[A" || it.data == "\u001bOA" },
)
assertTrue(
"a wheel report must be an escape sequence, got ${frames.map { it.data.toCharList() }}",
frames.all { it.data.startsWith("\u001b[") },
)
}
/** Without mouse tracking the wheel takes the same alternate-buffer branch a finger drag does. */
@Test
fun wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch() {
val handled = harness.scrollWheel(up = true)
assertTrue("the frame must claim the mouse wheel", handled)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
// Stock onGenericMotionEvent scrolls MOUSE_WHEEL_ROWS (3) rows per notch, one key per row.
assertEquals(
"one wheel notch is three rows",
List(WHEEL_ROWS_PER_NOTCH) { ClientMessage.Input("\u001b[A") },
frames,
)
}
/**
* The third stock entry point is the fling runnable `TerminalView$1$1.run`, which keeps calling
* `doScroll` after the finger leaves. It can only start if `mGestureRecognizer` observed the scroll —
* which is exactly what the intercept prevents. So the observable is silence after the drag ends: a
* live fling would keep emitting arrows (and, before the fix, would crash a beat after the gesture,
* which is what made this bug look intermittent).
*/
@Test
fun theStockFlingNeverStarts_soNoBytesArriveAfterTheFingerLeaves() {
harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX, steps = 2)
harness.drainSent() // the drag's own rows
val afterTheGesture = harness.drainSent(settleMs = FLING_SETTLE_MS)
assertEquals(
"no fling may continue scrolling after ACTION_UP",
emptyList<ClientMessage>(),
afterTheGesture,
)
}
private fun String.toCharList(): List<Int> = map { it.code }
private companion object {
/** Comfortably past the touch slop and several cell heights on any density. */
const val DRAG_DISTANCE_PX = 600f
/** `TerminalScrollGesture.MOUSE_WHEEL_ROWS` — stock scrolls three rows per notch. */
const val WHEEL_ROWS_PER_NOTCH = 3
/** Long enough for a real fling to have produced several frames of scrolling. */
const val FLING_SETTLE_MS = 800L
}
}

View File

@@ -0,0 +1,109 @@
package wang.yaojia.webterm.terminal
import android.view.View
import android.view.autofill.AutofillValue
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **a password manager crashed the app just by offering to fill the terminal.**
*
* Stock `TerminalView.autofill(AutofillValue)` (offsets 720) writes the filled text straight into
* `mTermSession`, which is null forever here, AND the view advertises itself as fillable
* (`getAutofillType()` = `AUTOFILL_TYPE_TEXT`, `getAutofillValue()` non-null). So on any device with an
* autofill service enabled — the default state for most users — focusing the terminal was enough to arm a
* crash, with no user action beyond accepting the fill.
*
* The fix is not a try/catch around `autofill`: it is keeping the whole subtree OUT of the autofill
* structure, so the framework never builds a fillable node for it. `View.isImportantForAutofill()` walks
* the parent chain and stops at the first `*_EXCLUDE_DESCENDANTS`, and that is precisely the gate
* `AutofillManager` consults before it will notify a service about a view — so it is the gate these tests
* assert, on both the frame and the stock child.
*
* **Deliberately NOT tested here:** the end-to-end loop with a real autofill service (which needs an
* installed, user-selected `AutofillService`) stays on the device-QA checklist. What IS provable on an
* emulator is the framework contract that makes the crash unreachable, plus the belt-and-braces
* assertion that a fill delivered anyway puts nothing on the wire — shell input is exactly the content
* that must never enter an autofill structure (plan §8).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalAutofillRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
harness.awaitAttachSettled()
}
@After
fun tearDown() {
harness.close()
}
@Test
fun theTerminalFrameIsExcludedFromTheAutofillStructure() {
val flag = harness.onMain { harness.hostView.importantForAutofill }
val important = harness.onMain { harness.hostView.isImportantForAutofill }
assertEquals(
"the frame must answer IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS",
View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS,
flag,
)
assertFalse("the framework must consider the frame unimportant for autofill", important)
}
/**
* The child is the view that actually carries the crashing `autofill` override, and it is reached
* through the parent-chain walk — which is why the frame overrides the GETTER rather than only
* setting the flag: a later `setImportantForAutofill` cannot quietly re-enable it.
*/
@Test
fun theStockTerminalViewIsExcludedThroughTheParentChain() {
val important = harness.onMain { harness.stockChild.isImportantForAutofill }
assertFalse(
"the stock TerminalView must be unimportant for autofill via the EXCLUDE_DESCENDANTS walk",
important,
)
}
@Test
fun focusingTheTerminalKeepsItExcluded() {
harness.onMain { harness.hostView.requestFocus() }
assertFalse(
"focus must not make the terminal fillable — focus is what triggered the old crash",
harness.onMain { harness.hostView.isImportantForAutofill },
)
assertFalse(
"…nor the stock child",
harness.onMain { harness.stockChild.isImportantForAutofill },
)
}
/**
* Belt and braces: even a fill delivered directly at the frame (bypassing the structure gate) must be
* inert. A password reaching `ClientMessage.Input` would be typed into whatever shell is running.
*/
@Test
fun aFillDeliveredAtTheFrameIsInertAndNeverReachesTheWire() {
harness.onMain { harness.hostView.autofill(AutofillValue.forText("correct-horse-battery-staple")) }
assertEquals(
"an autofilled value must never be sent to the shell",
emptyList<ClientMessage>(),
harness.drainSent(),
)
}
}

View File

@@ -0,0 +1,187 @@
package wang.yaojia.webterm.terminal
import android.view.KeyEvent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **BLOCKER 1: the app died on the first key press.**
*
* `TerminalView.mClient` was never installed, and stock `onKeyDown` dereferences it with no null guard
* (offset 8292 → `mClient.onKeyDown(keyCode, event, mTermSession)`), so *any* key — hardware or
* soft-keyboard — was an instant NPE on the UI thread. Installing a client that returned `false` would
* only have moved the crash one line down, into `mTermSession.write(getCharacters())` (offset 149160),
* because `mTermSession` is null forever here (plan §6.1). The soft-keyboard half was different but no
* better: the stock `TerminalView$2` `InputConnection` routes `commitText` into `inputCodePoint`, which
* early-returns when `mTermSession == null` — everything typed vanished silently.
*
* These tests therefore assert **both** halves of the contract on a real device: nothing crashes, AND the
* exact right bytes reach the wire. A test that only proved "no crash" would pass against the silent
* black hole, which was arguably the worse of the two bugs.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalKeyInputRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
// Discard the attach-time grid push before any assertion looks at the wire.
harness.awaitAttachSettled()
}
@After
fun tearDown() {
harness.close()
}
// ── Hardware keys ────────────────────────────────────────────────────────────────────────────
@Test
fun hardwareEnter_sendsCarriageReturnNotLineFeed() {
val consumed = harness.pressKey(KeyEvent.KEYCODE_ENTER)
assertTrue("the terminal must consume Enter, not leak it to the Activity", consumed)
// Repo-wide gotcha: a terminal Enter is CR (0x0D), never LF.
assertEquals(ClientMessage.Input("\r"), harness.awaitSent("Enter"))
}
@Test
fun hardwarePrintableKey_sendsThatCharacter() {
harness.pressKey(KeyEvent.KEYCODE_A)
assertEquals(ClientMessage.Input("a"), harness.awaitSent("the letter a"))
}
@Test
fun hardwareShiftedKey_sendsTheCapital() {
harness.pressKey(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON)
assertEquals(ClientMessage.Input("A"), harness.awaitSent("Shift+A"))
}
@Test
fun hardwareCtrlC_sendsTheInterruptControlByte() {
harness.pressKey(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON)
assertEquals(ClientMessage.Input("\u0003"), harness.awaitSent("Ctrl+C"))
}
@Test
fun hardwareBackspace_sendsDel() {
harness.pressKey(KeyEvent.KEYCODE_DEL)
assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("Backspace"))
}
/**
* DECCKM (plan §6.3): arrows must go through Termux's `KeyHandler` so application-cursor mode emits
* `ESC O A` rather than `ESC [ A`. Hardcoding the CSI form breaks vim/htop, so the byte form is
* asserted in BOTH modes against the live emulator bit.
*/
@Test
fun arrowUp_followsTheEmulatorsCursorKeyMode() {
harness.pressKey(KeyEvent.KEYCODE_DPAD_UP)
assertEquals(
"normal cursor mode must emit the CSI form",
ClientMessage.Input("\u001b[A"),
harness.awaitSent("Up in normal cursor mode"),
)
// ESC [ ? 1 h — DECCKM on (what full-screen TUIs set).
harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") { harness.emulator.isCursorKeysApplicationMode }
harness.clearSent()
harness.pressKey(KeyEvent.KEYCODE_DPAD_UP)
assertEquals(
"application cursor mode must emit the SS3 form",
ClientMessage.Input("\u001bOA"),
harness.awaitSent("Up in application cursor mode"),
)
}
/**
* BACK must stay the Activity's: `WebTermTerminalViewClient.shouldBackButtonBeMappedToEscape()`
* answers false precisely so stock `onKeyDown` cannot route it into the terminal branch (whose next
* stop is `mTermSession.write`). If this ever returns true the terminal screen becomes a trap the
* user cannot leave.
*/
@Test
fun systemBackKey_isNotSwallowedAndSendsNothing() {
val consumedDown = harness.pressKey(KeyEvent.KEYCODE_BACK)
val consumedUp = harness.releaseKey(KeyEvent.KEYCODE_BACK)
assertFalse("BACK down must fall through to the Activity", consumedDown)
assertFalse("BACK up must fall through to the Activity", consumedUp)
assertEquals("BACK must put no bytes on the wire", emptyList<ClientMessage>(), harness.drainSent())
}
// ── Soft keyboard (the IME seam) ─────────────────────────────────────────────────────────────
@Test
fun softKeyboardIsOffered_anInputConnectionForTheTerminal() {
val connection = harness.openInputConnection()
assertTrue(
"the frame must declare itself a text editor or no IME will attach",
harness.onMain { harness.hostView.onCheckIsTextEditor() },
)
// Proves the connection is OURS, not the stock TerminalView$2 black hole: committing text has to
// reach the wire.
harness.onMain { connection.commitText("ls -la", 1) }
assertEquals(ClientMessage.Input("ls -la"), harness.awaitSent("a soft-keyboard commit"))
}
@Test
fun softKeyboardEnter_sendsCarriageReturn() {
val connection = harness.openInputConnection()
// Some IMEs deliver Enter as a synthesised key event rather than as text.
harness.onMain {
connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
}
assertEquals(ClientMessage.Input("\r"), harness.awaitSent("an IME-synthesised Enter"))
}
@Test
fun softKeyboardBackspace_sendsDel() {
val connection = harness.openInputConnection()
harness.onMain { connection.deleteSurroundingText(1, 0) }
assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("an IME backspace"))
}
/**
* Non-ASCII is passed through VERBATIM (invariant #9 — no filtering, no normalisation). CJK is the
* case that matters because it arrives via composition, and it is also the one a byte-oriented
* implementation is most likely to mangle.
*/
@Test
fun softKeyboardCjkCommit_isPassedThroughVerbatim() {
val connection = harness.openInputConnection()
harness.onMain {
connection.setComposingText("中文", 1)
connection.commitText("中文", 1)
}
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertEquals(
"the committed CJK text must reach the wire unmodified",
"中文",
frames.joinToString(separator = "") { it.data },
)
}
}

View File

@@ -0,0 +1,155 @@
package wang.yaojia.webterm.terminal
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
/**
* REGRESSION for a **defect this suite FOUND, and which is NOT yet fixed** (2026-07-30).
*
* ## The defect
* `RemoteTerminalSession.onResize` calls `emulator.resize(cols, rows)` on its confined *writer* thread.
* The stock `TerminalRenderer` reads that same `TerminalBuffer` on the **UI thread** during `onDraw`, and
* it caches `mEmulator.mColumns` / `mRows` at the top of `render()` before walking the rows. So a regrid
* landing mid-frame tears the buffer out from under the renderer, which then indexes rows that have
* already been reallocated to the other size. Both shapes were observed as FATAL exceptions on the main
* thread on the `webterm` AVD (android-35):
*
* ```
* FATAL EXCEPTION: main
* java.lang.ArrayIndexOutOfBoundsException: length=31; index=31
* at com.termux.terminal.TerminalRow.getStyle(TerminalRow.java:244)
* at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:104)
* at com.termux.view.TerminalView.onDraw(TerminalView.java:921)
*
* FATAL EXCEPTION: main
* java.lang.IllegalArgumentException: extRow=19, mScreenRows=18, mActiveTranscriptRows=0
* at com.termux.terminal.TerminalBuffer.externalToInternalRow(TerminalBuffer.java:175)
* at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:83)
* at com.termux.view.TerminalView.onDraw(TerminalView.java:921)
* ```
*
* ## Why it matters, and how reliably it reproduces — stated precisely
* The window is not exotic: **every** session attach performs an 80x24 -> real-grid reflow (the server
* spawns the PTY at 80x24 and the client corrects it), and every rotation performs another, both while
* frames are being drawn. `TerminalBuffer.resize` reallocates every transcript row, so at production's
* 10 000-row transcript the mutation takes milliseconds rather than microseconds.
*
* **Reproduction is load-dependent, and a green run here does NOT mean the bug is gone.** Measured on the
* `webterm` AVD:
* - Under load (host memory starved, emulator thrashing) it fired constantly: it aborted roughly one
* instrumentation setup in three, hit an alternating-regrid loop within one or two iterations, and
* surfaced BOTH crash shapes above across about six separate runs.
* - On a freshly rebooted, idle emulator, 60 iterations with a concurrent output burst each pass cleanly:
* the reflow simply finishes inside a gap between frames.
*
* That is the signature of a real, unsynchronised data race rather than a deterministic bug — and load is
* exactly the condition a user meets: a mid-range phone rotating while a busy Claude session streams, or a
* cold start replaying a multi-MB ring buffer. So this test is a **stress probe, not a decider**: it fails
* loudly when it catches the tear and must not be read as an all-clear when it does not. The finding stands
* on the captured stack traces above, not on this test's verdict.
*
* It is invisible to the 900-strong JVM suite for the usual reason - no JVM test draws a `View` - and
* invisible to a manual emulator pass that only reaches the pairing screen, because it needs a bound
* session.
*
* ## The fix (verified here before being reported, and NOT applied — `:terminal-view` is not this
* agent's to edit)
* Do the reflow on the renderer's own thread: inside the confined consumer, wrap the mutation as
* `withContext(mainDispatcher) { emulator.resize(cols, rows) }`. That keeps command ORDER intact (the
* consumer still processes one command at a time, so a resize cannot overtake an append) while making the
* mutation mutually exclusive with `onDraw`. It is also what Termux itself does — its own
* `TerminalView.updateSize()` resizes from the UI thread.
*
* **Control experiment, run on this emulator:** 60 alternating regrids driven from the *main* thread with
* `invalidate()` after each one → zero crashes. The identical loop with the regrid on the confined thread
* → FATAL within two iterations. So the hop is sufficient, and the confinement is the cause.
*
* ## Reading this test
* A FAILURE (a main-thread FATAL that also aborts the instrumentation run, which is what the defect does to
* a user's app) is a positive catch: the bug is still there. A PASS means only that this run did not win the
* race. It deliberately uses the production transcript depth
* ([RemoteTerminalSession.TRANSCRIPT_ROWS]) and a concurrent output burst per iteration, which are the two
* knobs that widen the window. Once the reflow is hopped onto the main thread the test becomes a genuine
* decider, because then no interleaving exists that can tear the buffer.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalResizeDrawRaceRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
// Production scrollback depth on purpose: it is what makes the reallocation — and therefore the
// race window — the real size.
harness = TerminalTestHarness.launch(transcriptRows = RemoteTerminalSession.TRANSCRIPT_ROWS)
harness.awaitAttachSettled()
}
@After
fun tearDown() {
harness.close()
}
/**
* Alternate between two grids while forcing a redraw after each change — i.e. what a user does by
* rotating the device, folding a foldable, or dragging a multi-window divider. Every iteration must
* survive; a single torn frame is a dead app.
*/
@Test
fun regriddingWhileTheRendererDraws_neverTearsTheBuffer() {
repeat(REGRID_ITERATIONS) { iteration ->
// Keep the confined writer busy so the reflow is queued behind real appends and lands at an
// unpredictable point relative to the frame pipeline. Without this the reflow tends to complete
// in one gap between frames on a fast, idle device and the tear is missed — the race is
// probabilistic, and this is what makes the probability worth testing.
harness.session.feedRemote(OUTPUT_BURST.toByteArray(Charsets.UTF_8))
val wide = iteration % 2 == 0
harness.layoutTo(
widthPx = if (wide) WIDE_PX else NARROW_PX,
heightPx = if (wide) SHORT_PX else TALL_PX,
)
harness.onMain {
harness.hostView.invalidate()
harness.stockChild.invalidate()
}
harness.awaitTrue("iteration $iteration to reflow the emulator") {
harness.emulator.mColumns > 0 && harness.emulator.mRows > 0
}
}
// Reaching here at all is the assertion: the failure mode is a process-killing FATAL on the main
// thread, not a false return. This last check adds a coherence probe that indexes the buffer the
// same way the renderer does — a torn buffer throws out of it rather than answering.
val readBack = harness.emulator.screen.getSelectedText(
0,
0,
harness.emulator.mColumns - 1,
harness.emulator.mRows - 1,
)
assertNotNull("the buffer must still be readable at the emulator's advertised dims", readBack)
}
private companion object {
/**
* The tear is probabilistic: it needs the reflow to land while a frame is mid-flight, which on an
* idle emulator sometimes never happens. 60 iterations with a concurrent output burst each is what
* made it reproduce, rather than the 24 quiet ones that passed on a freshly booted device.
*/
const val REGRID_ITERATIONS = 60
/** ~8 KB of real terminal output per iteration: enough to keep the confined writer working. */
val OUTPUT_BURST: String = (1..64).joinToString("") { "webterm-race-$it " + "-".repeat(64) + "\r\n" }
const val WIDE_PX = 900
const val NARROW_PX = 500
const val SHORT_PX = 700
const val TALL_PX = 900
}
}

View File

@@ -0,0 +1,225 @@
package wang.yaojia.webterm.terminal
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **`resize` had zero call sites, so the PTY stayed at 80×24 forever.**
*
* `RemoteTerminalSession.updateSize` existed and was unit-tested, and nothing called it. The stock
* fallback cannot help either: `TerminalView.updateSize()` early-returns when `mTermSession == null`
* (offsets 1825), permanently our case. The visible symptom was every full-screen TUI — Claude Code
* included — rendering into an 80×24 box in the corner of a phone screen, and never reflowing on rotation.
*
* A JVM test cannot catch this class of bug at all: the arithmetic was already correct and tested
* ([wang.yaojia.webterm.terminalview.TerminalGridMath]); what was missing was a real layout pass, real
* cell metrics from a real `TerminalRenderer`, and a real wire. All three exist here.
*
* ### How "the expected grid" is asserted without reading private font metrics
* `TerminalRenderer`'s metrics are only reachable through `:terminal-view`'s `internal` API, and the
* Termux artifact is `implementation`-scoped so its types are off `:app`'s classpath. Rather than
* reflect (which silently rots) the tests assert the **documented formula's own algebra**, which needs no
* metric value: with zero padding, `cols = floor(W / fontWidth)`, so doubling the width must give
* `floor(2W / fontWidth) ∈ {2·cols, 2·cols + 1}` — and nothing else. Same for rows. That pins the grid to
* the plan §6.4 formula rather than merely checking it is "some positive number", and it fails loudly if
* the padding assumption ever stops holding (asserted separately).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalResizeRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
}
@After
fun tearDown() {
harness.close()
}
@Test
fun theStockViewHasNoPadding_whichTheGridAlgebraBelowRelies_on() {
val horizontal = harness.onMain { harness.stockChild.paddingLeft }
val vertical = harness.onMain { harness.stockChild.paddingTop }
assertEquals("the grid algebra in this class assumes zero horizontal padding", 0, horizontal)
assertEquals("the grid algebra in this class assumes zero vertical padding", 0, vertical)
}
@Test
fun theFirstLayoutPushesARealGridToTheWireAndToTheEmulator() {
val resize = harness.firstResize()
assertTrue("cols must be a real grid, not the 80x24 default box", resize.cols > 1)
assertTrue("rows must be a real grid, not the 80x24 default box", resize.rows > 1)
assertEquals(
"the local emulator must be reflowed to exactly the dims that went on the wire",
resize.cols to resize.rows,
harness.emulator.mColumns to harness.emulator.mRows,
)
assertTrue(
"the grid must be within the protocol's validated range",
resize.cols in RESIZE_RANGE && resize.rows in RESIZE_RANGE,
)
}
/**
* The §6.4 formula, checked by its own algebra: doubling the pixel box must double the grid to within
* the one cell a `floor` can absorb. A driver that ignored the real metrics (e.g. one that resent the
* default 80×24, or one that divided by a re-measured `Paint`) cannot satisfy this.
*/
@Test
fun doublingTheViewboxDoublesTheGrid_perThePlanFormula() {
val base = harness.firstResize()
harness.clearSent()
harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX * 2, TerminalTestHarness.DEFAULT_HEIGHT_PX * 2)
val doubled = harness.awaitResize("the grid after doubling the viewbox")
assertTrue(
"cols=floor(W/fw) ⇒ floor(2W/fw) must be 2·cols or 2·cols+1, was ${doubled.cols} for base ${base.cols}",
doubled.cols == base.cols * 2 || doubled.cols == base.cols * 2 + 1,
)
assertTrue(
"rows=H/ls ⇒ 2H/ls must be 2·rows or 2·rows+1, was ${doubled.rows} for base ${base.rows}",
doubled.rows == base.rows * 2 || doubled.rows == base.rows * 2 + 1,
)
assertEquals(
"the emulator must follow the wire",
doubled.cols to doubled.rows,
harness.emulator.mColumns to harness.emulator.mRows,
)
}
/**
* A rotation is, to this path, a layout pass with the dimensions swapped — and it must produce a NEW
* grid rather than keep the portrait one (the "does not reflow on rotation" half of the bug).
*/
@Test
fun swappingTheViewboxDimensions_reflowsToATransposedGrid() {
val portrait = harness.firstResize()
harness.clearSent()
harness.layoutTo(TerminalTestHarness.DEFAULT_HEIGHT_PX, TerminalTestHarness.DEFAULT_WIDTH_PX)
val landscape = harness.awaitResize("the grid after swapping width and height")
assertTrue("a wider box must give more columns", landscape.cols > portrait.cols)
assertTrue("a shorter box must give fewer rows", landscape.rows < portrait.rows)
}
/**
* Layout fires far more often than the grid changes (every IME show/hide, inset change and frame of a
* fold drag). Each one becoming a wire frame would mean a SIGWINCH storm, so an unchanged grid must be
* silent — the dedup that makes the forced re-assert below necessary in the first place.
*/
@Test
fun relayoutAtTheSameSize_sendsNothing() {
harness.firstResize()
harness.clearSent()
harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX, TerminalTestHarness.DEFAULT_HEIGHT_PX)
assertEquals(
"an unchanged grid must not reach the wire",
emptyList<ClientMessage>(),
harness.drainSent(),
)
}
/**
* **The §6.4 double-dedup bug.** A shared PTY has exactly ONE size, so a device returning to the
* foreground has to re-assert dims it has already sent in order to take the size back from whichever
* device wrote last. Two dedups sit in series on that path — `TerminalResizeDriver.lastGrid` and
* `RemoteTerminalSession.lastSentDims` — and BOTH survive a rebind, so an unflagged re-push dies
* silently and the returning device stays letterboxed forever.
*
* Rebinding the session is production's own trigger (`RemoteTerminalView.session`'s setter and
* `bindEmulator` both call `reassertLastGrid()`), so this test performs exactly that and requires the
* identical grid to reach the wire a second time.
*/
@Test
fun rebindingTheSession_reassertsTheSameGridOntoTheWire() {
val before = harness.firstResize()
harness.clearSent()
// What a device-switch / pane-show does: re-bind, which must force the memoised grid through both
// dedups even though not one pixel changed.
harness.onMain { harness.view.session = harness.session }
val reasserted = harness.awaitResize("the forced re-assert after a rebind")
assertEquals(
"the re-assert must carry the SAME grid — it is a claim on the shared PTY size, not a change",
before,
reasserted,
)
}
/**
* The same reclaim through the other production trigger: a brand-new session (a real background →
* foreground cycle bumps the generation and builds a fresh emulator at the server's 80×24 default).
* The view already knows the real grid, so it must push it at the new session immediately rather than
* wait for a layout pass that will never come.
*/
@Test
fun attachingAFreshSession_pushesTheMeasuredGridImmediately() {
val measured = harness.firstResize()
val replacementSent = java.util.concurrent.LinkedBlockingQueue<ClientMessage>()
val replacement = harness.onMain {
RemoteTerminalSession(engineSend = { message -> replacementSent.put(message) })
}
try {
harness.onMain { replacement.attachView(harness.view) }
val pushed = harness.awaitTrueValue("the fresh session to be told the real grid") {
replacementSent.poll()?.let { it as? ClientMessage.Resize }
}
assertEquals(
"a fresh emulator starts at the server's 80x24 default and must be corrected at once",
measured,
pushed,
)
assertEquals(
"the fresh emulator must be reflowed too",
measured.cols to measured.rows,
replacement.emulator.mColumns to replacement.emulator.mRows,
)
} finally {
harness.onMain { replacement.close() }
}
}
// ── helpers ──────────────────────────────────────────────────────────────────────────────────
/** The attach-time grid push — the first `Resize` the initial layout produced. */
private fun TerminalTestHarness.firstResize(): ClientMessage.Resize = awaitAttachSettled()
private fun TerminalTestHarness.awaitResize(what: String): ClientMessage.Resize {
var last: ClientMessage? = null
repeat(MAX_FRAMES_SCANNED) {
val message = awaitSent(what)
last = message
if (message is ClientMessage.Resize) return message
}
throw AssertionError("no Resize frame arrived while waiting for $what; last frame was $last")
}
private companion object {
/** `WireConstants.RESIZE_RANGE` — the server silently drops anything outside it. */
val RESIZE_RANGE = 1..1000
/** Guards the scan loop from spinning if the wire only ever carries non-Resize frames. */
const val MAX_FRAMES_SCANNED = 8
}
}

View File

@@ -0,0 +1,354 @@
package wang.yaojia.webterm.terminal
import android.app.Activity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import com.termux.terminal.TerminalEmulator
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
import wang.yaojia.webterm.terminalview.RemoteTerminalView
import wang.yaojia.webterm.wire.ClientMessage
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
/**
* The device-side rig for the four 2026-07-30 crash regressions.
*
* ### Why these tests can only exist here
* Every defect this rig covers lived in a real `android.view.View` (`TerminalView.onKeyDown`,
* `doScroll`, `autofill`, `updateSize`). The 900-odd JVM tests could not see any of them because no JVM
* test instantiates a View, inflates an `InputConnection`, dispatches a `MotionEvent` or runs a layout
* pass — which is exactly how three crash-on-first-interaction bugs shipped behind a green suite. So the
* rig builds the REAL [RemoteTerminalView] inside a REAL Activity and drives it through the REAL
* framework entry points. Nothing is faked except the wire: [sent] captures the [ClientMessage]s that
* would have gone to the server, which is the only observable a regression can be asserted against
* without a host.
*
* ### Threading contract
* The View and the session's bind path are main-thread-only, so every mutation goes through
* [onMain]. The test body itself runs on the instrumentation thread, which is what lets [awaitSent] and
* [awaitTrue] block: the confined emulator-writer thread and the main looper both stay free to make
* progress while the test waits. Blocking on the main thread instead would deadlock the very handoff
* under test.
*/
internal class TerminalTestHarness private constructor(
private val scenario: ActivityScenario<ComponentActivity>,
val view: RemoteTerminalView,
val session: RemoteTerminalSession,
val root: FrameLayout,
private val sent: LinkedBlockingQueue<ClientMessage>,
) {
/** The frame that owns focus + the IME contract; the stock Termux view is its only child. */
val hostView: View get() = view.terminalView
/** The stock `com.termux.view.TerminalView`, reachable only as a plain [View]: `:terminal-view`
* scopes the Termux artifact as `implementation`, so its type is off `:app`'s classpath. Every
* assertion this rig needs (`isImportantForAutofill`, padding) is plain `View` API anyway. */
val stockChild: View get() = (hostView as ViewGroup).getChildAt(0)
val emulator: TerminalEmulator get() = session.emulator
// ── Driving the view ─────────────────────────────────────────────────────────────────────────
fun <T> onMain(block: (Activity) -> T): T {
var result: T? = null
var failure: Throwable? = null
val done = CountDownLatch(1)
scenario.onActivity { activity ->
try {
result = block(activity)
} catch (t: Throwable) {
failure = t
} finally {
done.countDown()
}
}
assertTrue("the main thread never ran the block within $MAIN_SYNC_TIMEOUT_MS ms",
done.await(MAIN_SYNC_TIMEOUT_MS, TimeUnit.MILLISECONDS))
failure?.let { throw it }
@Suppress("UNCHECKED_CAST")
return result as T
}
/** Resize the hosted view to an exact pixel box and wait for the layout pass to land. */
fun layoutTo(widthPx: Int, heightPx: Int) {
onMain {
hostView.layoutParams = FrameLayout.LayoutParams(widthPx, heightPx)
hostView.requestLayout()
}
awaitTrue("the view never laid out at ${widthPx}x$heightPx") {
onMain { hostView.width == widthPx && hostView.height == heightPx }
}
}
/** Dispatch a key through the framework entry point a physical keyboard uses. */
fun pressKey(keyCode: Int, metaState: Int = 0): Boolean = onMain {
val down = KeyEvent(
/* downTime = */ 0L,
/* eventTime = */ 0L,
KeyEvent.ACTION_DOWN,
keyCode,
/* repeat = */ 0,
metaState,
)
hostView.dispatchKeyEvent(down)
}
fun releaseKey(keyCode: Int, metaState: Int = 0): Boolean = onMain {
val up = KeyEvent(0L, 0L, KeyEvent.ACTION_UP, keyCode, 0, metaState)
hostView.dispatchKeyEvent(up)
}
/** The soft-keyboard seam: the same `InputConnection` an IME would be handed. */
fun openInputConnection(): InputConnection = onMain {
val editorInfo = EditorInfo()
val connection = hostView.onCreateInputConnection(editorInfo)
assertNotNull("onCreateInputConnection returned null — the IME would have no target", connection)
connection!!
}
/**
* A vertical finger drag, dispatched exactly as the framework would: one DOWN, [steps] MOVEs and one
* UP, all through [View.dispatchTouchEvent] so `onInterceptTouchEvent` runs for real.
*
* @param totalDeltaPx negative moves the finger UP the screen (content scrolls forward), positive
* moves it DOWN (content scrolls back into history).
* @return whether the frame claimed the gesture — true is what makes the crashing stock
* `doScroll` / fling path unreachable rather than merely unlikely.
*/
fun dragVertically(startY: Float, totalDeltaPx: Float, steps: Int = DRAG_STEPS): Boolean {
val x = 10f
var claimed = false
onMain {
val down = motionEvent(MotionEvent.ACTION_DOWN, x, startY)
hostView.dispatchTouchEvent(down)
down.recycle()
for (step in 1..steps) {
val y = startY + totalDeltaPx * step / steps
val move = motionEvent(MotionEvent.ACTION_MOVE, x, y)
// dispatchTouchEvent returns the ViewGroup's verdict; once the frame intercepts, the
// remainder is routed to its own onTouchEvent, which keeps returning true.
claimed = hostView.dispatchTouchEvent(move) || claimed
move.recycle()
}
val up = motionEvent(MotionEvent.ACTION_UP, x, startY + totalDeltaPx)
hostView.dispatchTouchEvent(up)
up.recycle()
}
return claimed
}
/** One external-mouse wheel notch — the second stock entry into the crashing `doScroll`. */
fun scrollWheel(up: Boolean): Boolean = onMain {
val event = wheelEvent(if (up) 1f else -1f)
try {
hostView.dispatchGenericMotionEvent(event)
} finally {
event.recycle()
}
}
// ── Observing ────────────────────────────────────────────────────────────────────────────────
/** The next message that reached the (fake) wire, or fail after [WIRE_TIMEOUT_MS]. */
fun awaitSent(what: String): ClientMessage {
val message = sent.poll(WIRE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
assertNotNull("nothing reached the wire within $WIRE_TIMEOUT_MS ms while waiting for $what", message)
return message!!
}
/** Every message that reached the wire within [settleMs] (used to assert "and nothing else"). */
fun drainSent(settleMs: Long = SETTLE_MS): List<ClientMessage> {
Thread.sleep(settleMs)
val drained = ArrayList<ClientMessage>()
sent.drainTo(drained)
return drained
}
fun clearSent() {
sent.clear()
}
/** Feed remote bytes and block until the confined writer has applied them. */
fun feedAndAwait(bytes: String, description: String, predicate: () -> Boolean) {
session.feedRemote(bytes.toByteArray(Charsets.UTF_8))
awaitTrue(description, predicate)
}
fun awaitTrue(what: String, predicate: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS)
while (System.nanoTime() < deadline) {
if (predicate()) return
Thread.sleep(POLL_INTERVAL_MS)
}
assertTrue("timed out after $AWAIT_TIMEOUT_MS ms waiting for: $what", predicate())
}
fun close() {
onMain { session.close() }
scenario.close()
}
private fun motionEvent(action: Int, x: Float, y: Float): MotionEvent =
MotionEvent.obtain(DOWN_TIME, DOWN_TIME, action, x, y, 0)
private fun wheelEvent(vScroll: Float): MotionEvent {
val properties = arrayOf(
MotionEvent.PointerProperties().apply {
id = 0
toolType = MotionEvent.TOOL_TYPE_MOUSE
},
)
val coords = arrayOf(
MotionEvent.PointerCoords().apply {
x = 10f
y = 10f
setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll)
},
)
return MotionEvent.obtain(
/* downTime = */ DOWN_TIME,
/* eventTime = */ DOWN_TIME,
MotionEvent.ACTION_SCROLL,
/* pointerCount = */ 1,
properties,
coords,
/* metaState = */ 0,
/* buttonState = */ 0,
/* xPrecision = */ 1f,
/* yPrecision = */ 1f,
/* deviceId = */ 0,
/* edgeFlags = */ 0,
android.view.InputDevice.SOURCE_MOUSE,
/* flags = */ 0,
)
}
/**
* Block until the attach-time grid push (80×24 → the real grid) has landed on the wire AND the wire
* has gone quiet, then discard those frames.
*
* Without this, `clearSent()` in a `@Before` races the confined writer: the attach pushes the grid
* asynchronously (twice — `attachView`'s session setter and `bindEmulator` both force a re-assert),
* so a `Resize` can arrive *after* the clear and be mistaken for the first frame a test caused. Two
* of the very first runs of this suite failed exactly that way.
*/
fun awaitAttachSettled(): ClientMessage.Resize {
val grid = awaitTrueValue("the attach-time grid to reach the wire") {
sent.poll() as? ClientMessage.Resize
}
drainSent(settleMs = ATTACH_SETTLE_MS)
return grid
}
fun <T> awaitTrueValue(what: String, produce: () -> T?): T {
var found: T? = null
awaitTrue(what) {
found = produce()
found != null
}
return found!!
}
companion object {
/** The whole terminal viewport for the initial layout; individual tests re-layout as needed. */
const val DEFAULT_WIDTH_PX: Int = 600
const val DEFAULT_HEIGHT_PX: Int = 800
private const val DOWN_TIME = 1_000L
private const val DRAG_STEPS = 8
private const val MAIN_SYNC_TIMEOUT_MS = 5_000L
private const val WIRE_TIMEOUT_MS = 3_000L
private const val AWAIT_TIMEOUT_MS = 5_000L
private const val POLL_INTERVAL_MS = 20L
private const val SETTLE_MS = 300L
private const val ATTACH_SETTLE_MS = 500L
/**
* Build the rig: a [ComponentActivity] (supplied by `ui-test-manifest`), a root frame, the real
* [RemoteTerminalView] added to it at [DEFAULT_WIDTH_PX]×[DEFAULT_HEIGHT_PX], and a
* [RemoteTerminalSession] attached to it.
*
* ### Why the view is laid out BEFORE the session attaches
* This is the §6.6 config-change rebind order (a laid-out view, a session bound to it) rather than
* the cold-start order, and it is chosen for a specific reason: it keeps the attach-time 80×24 →
* real-grid reflow *ahead* of the moment the emulator becomes visible to the stock renderer.
* `attachView` queues the forced re-assert (from the session setter) before the Bind command, and
* the confined consumer is FIFO, so the reallocation completes while no frame can be reading the
* buffer. `bindEmulator`'s second re-assert then carries the same dims, which Termux's
* `TerminalEmulator.resize` short-circuits, so it reallocates nothing.
*
* Doing it the other way round — attach first, let the first layout pass fire afterwards — puts
* the reflow on the confined writer thread while the renderer is already drawing, which is the
* still-unfixed defect [TerminalResizeDrawRaceRegressionTest] documents. That defect is real and
* this ordering does not paper over it: it is exercised deliberately by that test and by every
* relayout in [TerminalResizeRegressionTest]. Ordering it away here simply stops a `:terminal-view`
* defect from killing the process during the *setup* of key, scroll and autofill tests that are
* not about resize at all.
*/
fun launch(transcriptRows: Int = RemoteTerminalSession.TRANSCRIPT_ROWS): TerminalTestHarness {
val sent = LinkedBlockingQueue<ClientMessage>()
val scenario = ActivityScenario.launch(ComponentActivity::class.java)
var view: RemoteTerminalView? = null
var root: FrameLayout? = null
scenario.onActivity { activity ->
val terminal = RemoteTerminalView(activity)
val frame = FrameLayout(activity)
frame.addView(
terminal.terminalView,
FrameLayout.LayoutParams(DEFAULT_WIDTH_PX, DEFAULT_HEIGHT_PX),
)
activity.setContentView(frame)
view = terminal
root = frame
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
val terminal = view!!
// Wait for the real layout pass. With no session bound yet, TerminalResizeDriver memoises the
// measured grid and pushes nothing (`session?.updateSize` is a no-op) — so nothing has to be
// reflowed while a frame is in flight.
var laidOut = false
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS)
while (!laidOut && System.nanoTime() < deadline) {
val done = CountDownLatch(1)
scenario.onActivity { laidOut = terminal.terminalView.width == DEFAULT_WIDTH_PX }
done.countDown()
if (!laidOut) Thread.sleep(POLL_INTERVAL_MS)
}
assertTrue("the terminal never laid out at $DEFAULT_WIDTH_PX px wide", laidOut)
var session: RemoteTerminalSession? = null
scenario.onActivity {
val remote = RemoteTerminalSession(
engineSend = { message -> sent.put(message) },
transcriptRows = transcriptRows,
)
remote.attachView(terminal)
session = remote
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
val harness = TerminalTestHarness(scenario, terminal, session!!, root!!, sent)
// Focus is a PRECONDITION for the key path, not a convenience: `ViewGroup.dispatchKeyEvent`
// only calls `super.dispatchKeyEvent` (hence `onKeyDown`) when the group itself is FOCUSED
// and laid out, and it has no focusable children here (`FOCUS_BLOCK_DESCENDANTS`). Production
// satisfies this the same way — `dispatchTouchEvent` claims focus on ACTION_DOWN and
// `showSoftKeyboard()` calls `requestFocus()` — so an unfocused terminal receiving no hardware
// keys is correct framework behaviour, not a defect. Asserting it here keeps a silent focus
// regression from turning the key tests into vacuous passes.
harness.awaitTrue("the terminal frame to take focus") {
harness.onMain { harness.hostView.requestFocus() || harness.hostView.isFocused }
}
return harness
}
}
}

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.GateBanner
import wang.yaojia.webterm.components.PlanGateSheet
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.wire.GateKind
/**
* **Plan §7 Compose UI tests — the gate surfaces.**
*
* These are the two surfaces a remote approval is made from, so the load-bearing contract is not the layout
* (device-QA) but that **each affordance reports the decision it is labelled with, carrying the epoch of the
* gate that was on screen**. The epoch is the stale-guard seam: `GateViewModel.decide` drops a tap whose
* epoch is no longer live, which is what stops a tap landing on a gate the user never saw. A test that only
* checked the copy would let a swapped-callback bug through — approving when the user pressed reject is the
* single worst failure this app can have.
*
* The server-supplied `detail` is also asserted to render, because it must render as INERT text (plan §8) —
* untrusted tool/OSC content with no markdown and no autolink.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class GateSurfacesUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun toolGateBanner_reportsApproveWithTheOnScreenEpoch() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e })
}
}
compose.onNodeWithText(DETAIL).assertIsDisplayed()
compose.onNodeWithText(APPROVE).performClick()
assertEquals(listOf(GateDecision.APPROVE to TOOL_EPOCH), decisions)
}
@Test
fun toolGateBanner_reportsRejectWithTheOnScreenEpoch() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e })
}
}
compose.onNodeWithText(REJECT).performClick()
assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions)
}
/**
* A previewless gate is a NORMAL state, not an error: the server only sends a preview for a held
* Bash/Edit-family tool, and a malformed one decodes to null rather than costing us the gate. So the
* banner must still be fully usable with no preview.
*/
@Test
fun toolGateBanner_withNoPreviewIsStillFullyDecidable() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
GateBanner(gate = toolGate.copy(preview = null), onDecide = { d, e -> decisions += d to e })
}
}
compose.onNodeWithText(APPROVE).assertIsDisplayed()
compose.onNodeWithText(REJECT).performClick()
assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions)
}
/**
* The plan gate is THREE-way, and the third option is the one most easily got wrong: 「继续计划」 keeps
* planning, i.e. it wires to `reject`, not to a second kind of approval.
*/
@Test
fun planGateSheet_offersAllThreeDecisionsWithTheOnScreenEpoch() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
PlanGateSheet(gate = planGate, onDecide = { d, e -> decisions += d to e }, onDismiss = {})
}
}
compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed()
compose.onNodeWithText(PLAN_DETAIL).assertIsDisplayed()
compose.onNodeWithText(ACCEPT_EDITS).performClick()
compose.onNodeWithText(KEEP_PLANNING).performClick()
compose.onNodeWithText(APPROVE, useUnmergedTree = false).performClick()
assertEquals(
listOf(
GateDecision.ACCEPT_EDITS to PLAN_EPOCH,
GateDecision.REJECT to PLAN_EPOCH,
GateDecision.APPROVE to PLAN_EPOCH,
),
decisions,
)
}
/**
* Dismissing the sheet (scrim tap / back) must resolve NOTHING — the gate stays held until an explicit
* decision. Silently treating a dismissal as a reject would cancel a user's work behind their back.
*/
@Test
fun planGateSheet_dismissalDecidesNothing() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
var dismissals = 0
compose.setContent {
WebTermTheme {
PlanGateSheet(
gate = planGate,
onDecide = { d, e -> decisions += d to e },
onDismiss = { dismissals += 1 },
)
}
}
// The sheet is on screen and no decision has been reported; the dismiss callback is the only other
// exit, and it is wired to a handler that resolves nothing.
compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed()
assertEquals(emptyList<Pair<GateDecision, Int>>(), decisions)
assertEquals(0, dismissals)
}
private companion object {
const val TOOL_EPOCH = 7
const val PLAN_EPOCH = 11
const val DETAIL = "Bash"
const val PLAN_DETAIL = "Refactor the auth module into 3 files."
const val APPROVE = "批准"
const val REJECT = "拒绝"
const val ACCEPT_EDITS = "批准并自动接受"
const val KEEP_PLANNING = "继续计划"
const val PLAN_TITLE = "批准执行计划"
val toolGate = GateState(kind = GateKind.TOOL, detail = DETAIL, epoch = TOOL_EPOCH)
val planGate = GateState(kind = GateKind.PLAN, detail = PLAN_DETAIL, epoch = PLAN_EPOCH)
}
}

View File

@@ -0,0 +1,150 @@
package wang.yaojia.webterm.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.KeyBar
import wang.yaojia.webterm.designsystem.WebTermTheme
/**
* **Plan §7 Compose UI test — the mobile key-bar, pinned above the IME.**
*
* The key-bar is how a phone produces the keys Claude Code needs and a soft keyboard cannot: Esc, ⇧Tab,
* arrows, Ctrl-chords. Its defining property is that a tap goes **straight to the wire**, bypassing the IME
* entirely (mirroring the web client's `ws.send` bypass) — that is what stops the soft keyboard popping up
* every time the user dismisses a Claude prompt. So the assertions are on the bytes each button emits, not
* on its looks.
*
* What stays device-QA: that the bar is *visually* above the IME. `WindowInsets.ime` reports zero in an
* instrumented test with no keyboard raised, so an assertion about its on-screen position would be
* measuring the absence of a keyboard, not the inset behaviour. The union-with-navigation-bars fallback that
* makes the bar sit above the nav bar when the keyboard is hidden IS exercised here (the bar renders and is
* hittable), and the real above-the-IME check is on the checklist.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class KeyBarUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun escTap_sendsTheEscapeByteDirectly() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("Esc").performClick()
assertEquals(listOf("\u001b"), sent)
}
@Test
fun enterTap_sendsCarriageReturnNotLineFeed() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("").performClick()
// The repo-wide gotcha, on the surface a phone user hits most often.
assertEquals(listOf("\r"), sent)
}
@Test
fun ctrlCTap_sendsTheInterruptControlByte() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("^C").performClick()
assertEquals(listOf("\u0003"), sent)
}
@Test
fun shiftTabTap_sendsTheModeCycleSequence() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("⇧Tab").performClick()
assertEquals(listOf("\u001b[Z"), sent)
}
/**
* The bar is horizontally scrollable precisely so every key stays reachable on a narrow phone; the keys
* past the fold are the ones a layout regression would silently drop. Reaching one by its
* `contentDescription` (which is also its accessibility label) proves it is present and hittable.
*/
@Test
fun everyKeyIsReachable_includingTheOnesPastTheFold() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
// Off-screen keys must be reached the way a user reaches them — by scrolling the bar. A bare
// performClick() on a node outside the viewport fails, which is itself the proof that this key
// really does sit past the fold rather than being trivially present.
compose.onNodeWithContentDescription(SLASH_DESCRIPTION).performScrollTo().performClick()
assertEquals(listOf("/"), sent)
}
@Test
fun tapsAccumulateInOrder_soTwoFastTapsCannotSwap() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("").performClick()
compose.onNodeWithText("").performClick()
compose.onNodeWithText("").performClick()
assertEquals(listOf("\u001b[A", "\u001b[B", "\r"), sent)
}
/**
* A key-bar tap must never route through a text field, or the soft keyboard would open. There is no
* public hook to observe "the IME was not asked to open", but there IS an observable proxy: the bar
* contains no text-input node at all, so nothing on it can request the IME. Combined with the byte
* assertions above (which prove the tap reached the wire), that pins the bypass.
*/
@Test
fun theBarHoldsNoTextInput_soATapCannotRaiseTheKeyboard() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("Esc").assertIsDisplayed()
val editableNodes = compose
.onAllNodes(hasSetTextAction())
.fetchSemanticsNodes(atLeastOneRootRequired = false)
assertTrue("the key-bar must contain no editable text node", editableNodes.isEmpty())
}
private fun setUpKeyBar(sink: MutableList<String>) {
compose.setContent {
WebTermTheme {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
KeyBar(onSend = { bytes -> sink += bytes })
}
}
}
}
private companion object {
/** `KEYBAR_LAYOUT`'s accessibility label for the command-launcher key, which sits past the fold. */
const val SLASH_DESCRIPTION = "Slash — command launcher"
}
}

View File

@@ -0,0 +1,134 @@
package wang.yaojia.webterm.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasProgressBarRangeInfo
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.session.ConnectionState
import wang.yaojia.webterm.session.Connection
import wang.yaojia.webterm.session.FailureReason
import wang.yaojia.webterm.components.ReconnectBanner
import wang.yaojia.webterm.designsystem.WebTermTheme
import kotlin.time.Duration.Companion.seconds
/**
* **Plan §7 Compose UI test — the reconnect banner, and specifically its precedence rule.**
*
* The banner is the only place the app tells a walked-away user *why* their session went quiet, so the
* failure modes it must not have are: showing a retry spinner for something that can never succeed, and
* hiding a terminal state behind a transient one. The reduction itself is JVM-tested; what only a device can
* check is that the rendered surface honours it — the spinner really is absent, the affordance really is
* present and really fires, and the untrusted exit `reason` really renders inert.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class ReconnectBannerUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun hiddenRendersNothingAtAll() {
setUpBanner(BannerModel.Hidden)
val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot())
.fetchSemanticsNodes(atLeastOneRootRequired = false)
// A root always exists; what must not exist is any banner content under it.
assertTrue("Hidden must render no banner content", nodes.all { it.children.isEmpty() })
}
@Test
fun reconnectingShowsTheAttemptCountdownAndASpinner() {
setUpBanner(BannerModel.Reconnecting(attempt = 3, countdown = 4.seconds))
compose.onNodeWithText("连接断开,重连中(第 3 次4 秒后重试)").assertIsDisplayed()
assertTrue("a retrying banner must show a spinner", hasSpinner())
}
/**
* The replay-too-large wall is NON-retryable: reconnecting would hit the same wall forever, so a
* spinner here would be a lie that never resolves. The copy has to be actionable and the only way out
* is a new session.
*/
@Test
fun aNonRetryableFailureShowsNoSpinnerAndOffersANewSession() {
var newSessions = 0
setUpBanner(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE)) { newSessions += 1 }
assertTrue("a non-retryable wall must NOT show a retry spinner", !hasSpinner())
compose.onNodeWithText("新会话").performClick()
assertEquals(1, newSessions)
}
/**
* `exit(-1)` is a spawn failure, and its `reason` is server-supplied, i.e. untrusted — it must render as
* inert text inside the banner copy, never as a link or markup (plan §8).
*/
@Test
fun aSpawnFailureRendersTheServerReasonInertly() {
setUpBanner(BannerModel.Exited(code = -1, reason = UNTRUSTED_REASON))
compose.onNodeWithText("会话启动失败:$UNTRUSTED_REASON").assertIsDisplayed()
assertTrue("a terminal exit must not show a spinner", !hasSpinner())
compose.onNodeWithText("开新会话").assertIsDisplayed()
}
@Test
fun anOrdinaryExitShowsTheCodeAndTheNewSessionAffordance() {
var newSessions = 0
setUpBanner(BannerModel.Exited(code = 130, reason = null)) { newSessions += 1 }
compose.onNodeWithText("会话已结束(退出码 130").assertIsDisplayed()
compose.onNodeWithText("开新会话").performClick()
assertEquals(1, newSessions)
}
/**
* **The precedence rule, rendered.** A `connecting` event arriving after the session already exited must
* NOT replace the terminal banner with a spinner — otherwise a walked-away user sees "connecting…"
* forever and never learns the session is over. The reduction is asserted and then the reduced model is
* rendered, so copy and rule are checked as one thing.
*/
@Test
fun aTerminalBannerIsStickyOverATransientOne() {
val exited = BannerModel.Exited(code = 1, reason = null)
val afterConnecting = wang.yaojia.webterm.components.bannerModel(
current = exited,
event = Connection(ConnectionState.Connecting),
)
assertEquals("a transient event must not override a terminal banner", exited, afterConnecting)
setUpBanner(afterConnecting)
compose.onNodeWithText("会话已结束(退出码 1").assertIsDisplayed()
assertTrue(!hasSpinner())
}
private fun hasSpinner(): Boolean = compose
.onAllNodes(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate))
.fetchSemanticsNodes(atLeastOneRootRequired = false)
.isNotEmpty()
private fun setUpBanner(model: BannerModel, onNewSession: () -> Unit = {}) {
compose.setContent {
WebTermTheme { ReconnectBanner(model = model, onNewSession = onNewSession) }
}
}
private companion object {
/** Server-supplied and therefore untrusted; it must appear verbatim and inert. */
const val UNTRUSTED_REASON = "spawn /bin/nope ENOENT"
}
}

View File

@@ -0,0 +1,134 @@
package wang.yaojia.webterm.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.components.ContinueLastBanner
import wang.yaojia.webterm.components.ContinueLastModel
import wang.yaojia.webterm.components.SessionListRow
import wang.yaojia.webterm.components.continueLastModel
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.viewmodels.SessionRow
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* **Plan §7 Compose UI tests — session-list rows and the continue-last banner.**
*
* Two things only a device can check here. First, that a row renders host-supplied strings **inert**: the
* title arrives from an OSC escape the host (or something running on it) chose, so it must be plain
* [androidx.compose.material3.Text] with no linkify and no markdown (plan §8). Second, that "shown iff there
* is something to show" holds — a `null` model must render literally nothing, because a dead
* continue-affordance that navigates nowhere is worse than no affordance.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class SessionListUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun aRowRendersTheSanitizedTitleAndTheGeometry() {
compose.setContent {
WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = {}) }
}
compose.onNodeWithText(TITLE).assertIsDisplayed()
// The geometry label uses U+00D7 MULTIPLICATION SIGN, not the letter x.
compose.onNodeWithText("161×50", substring = true).assertIsDisplayed()
}
@Test
fun tappingARowOpensThatSession() {
var opened = 0
compose.setContent {
WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = { opened += 1 }) }
}
compose.onNodeWithText(TITLE).performClick()
assertEquals(1, opened)
}
/**
* A hostile OSC title must appear as characters, not as behaviour. `TitleSanitizer` strips bidi and
* zero-width runs upstream (JVM-tested); what this pins is that whatever survives is rendered verbatim
* and is not turned into a link by autolinking.
*/
@Test
fun anUntrustedLookingTitleIsRenderedAsPlainCharacters() {
compose.setContent {
WebTermTheme { SessionListRow(row = row(title = URL_LOOKING_TITLE), onOpen = {}) }
}
compose.onNodeWithText(URL_LOOKING_TITLE).assertIsDisplayed()
}
@Test
fun continueLastBannerIsAbsentWhenThereIsNoLastSession() {
assertEquals("no last session must reduce to no model", null, continueLastModel(null))
compose.setContent {
WebTermTheme { ContinueLastBanner(model = continueLastModel(null), onContinue = {}) }
}
val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot())
.fetchSemanticsNodes(atLeastOneRootRequired = false)
assertTrue("a null model must render nothing at all", nodes.all { it.children.isEmpty() })
}
@Test
fun continueLastBannerReOpensTheRememberedSession() {
val remembered = mutableListOf<String>()
compose.setContent {
WebTermTheme {
ContinueLastBanner(
model = ContinueLastModel(LAST_SESSION_ID),
onContinue = { remembered += it },
)
}
}
compose.onNodeWithText("继续上次会话").performClick()
assertEquals(
"the banner must hand back the id it was rendered with, not a re-derived one",
listOf(LAST_SESSION_ID),
remembered,
)
}
private fun row(title: String) = SessionRow(
info = LiveSessionInfo(
id = UUID.fromString(LAST_SESSION_ID),
createdAt = 1_700_000_000_000L,
clientCount = 1,
status = ClaudeStatus.WORKING,
exited = false,
cwd = "/Users/dev/project",
title = title,
cols = 161,
rows = 50,
),
displayStatus = DisplayStatus.Working,
title = title,
isUnread = true,
)
private companion object {
const val TITLE = "claude"
const val URL_LOOKING_TITLE = "https://example.com/not-a-link"
const val LAST_SESSION_ID = "a1b2c3d4-5678-4abc-9def-000000000000"
}
}

View File

@@ -12,13 +12,32 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<!-- Cleartext: @xml/network_security_config is the AUTHORITATIVE policy (minSdk 29, so the
platform always reads it and IGNORES android:usesCleartextTraffic). The attribute is set
to true only so the manifest does not state the opposite of what actually ships: bare-LAN
`ws://` must work (plan §6.9). The config is where the real posture lives — cleartext ON
for user-typed LAN addresses (no CIDR syntax exists to scope it), system-only trust
anchors, and cleartext still BLOCKED for *.terminal.yaojia.wang. Read its header before
changing either value; they must stay in agreement. -->
<!-- Backup: OFF, and deliberately not an exclusion list (plan §8 "app-private, uninstall-wiped,
never in backups/cloud"). Auto Backup defaults to ON and would sweep up EVERY app-private
file: the `webterm` Preferences DataStore (which holds the sealed `webterm_auth` cookie — a
full-shell credential the server keeps alive for 30 days) and the Tink keyset/ciphertext pref
files. `dataExtractionRules`/`fullBackupContent` were rejected because they are allow-by-
default: every store added later is backed up until someone remembers to exclude it, and a
silent miss here is an off-device credential leak. Nothing in this app has restore value that
justifies that risk — re-pairing a host is a QR scan, and the hosts are LAN-local anyway.
NOTE: this attribute is Auto Backup (cloud). Android 12+ DEVICE-TO-DEVICE transfer is governed
by a `dataExtractionRules` <device-transfer> block, which needs an @xml resource (tracked
separately — see the orchestrator note). Do not set this back to true. -->
<application
android:name=".WebTermApp"
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.WebTerm"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"

View File

@@ -16,10 +16,15 @@ import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.StatusBadge
import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.viewmodels.inertText
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.PreviewDiffFile
/**
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
@@ -44,6 +49,7 @@ public fun GateBanner(
gate: GateState,
onDecide: (GateDecision, Int) -> Unit,
modifier: Modifier = Modifier,
preview: ApprovalPreview? = null,
) {
WebTermCard(modifier = modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
@@ -63,6 +69,9 @@ public fun GateBanner(
)
}
}
// WHAT is being approved sits between the label and the buttons, so the decision and the
// evidence are never on different screens. Absent → the name-only bar, unchanged.
ApprovalPreviewBlock(preview)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
Text(text = "拒绝")
@@ -75,6 +84,124 @@ public fun GateBanner(
}
}
// ── W1: the approval preview (shared by GateBanner and PlanGateSheet) ───────────
/** How many diff/command lines a gate surface paints before it says "truncated". The server already
* caps at 40 lines / 4 KB; this is the on-phone reading limit, so the buttons stay reachable. */
private const val PREVIEW_MAX_VISIBLE_LINES = 20
/**
* W1 — render WHAT a held approval would do: the shell command, or the one-file diff.
*
* Everything here is **untrusted** (server-derived from the hook's `tool_input`, which Claude and the
* repo contents influence) and therefore INERT: plain [Text] only — no Markdown, no autolink, no
* `AnnotatedString` link detection (plan §8). A `null` preview renders NOTHING: absent is a normal
* state (plan gates and unknown tools have nothing reviewable), never an error.
*/
@Composable
internal fun ApprovalPreviewBlock(preview: ApprovalPreview?, modifier: Modifier = Modifier) {
if (preview == null) return
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Spacing.xs2),
) {
when (preview) {
is ApprovalPreview.Command -> CommandPreview(preview)
is ApprovalPreview.Diff -> DiffPreview(preview.file)
}
if (preview.truncated) {
Text(
text = GatePreviewCopy.TRUNCATED,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun CommandPreview(preview: ApprovalPreview.Command) {
// Newlines are content in a shell command, so the lines are kept and rendered as lines. The
// sanitizer drops every other control byte (defence in depth over the server's own stripping).
val lines = (preview.inertText() ?: return).lines()
for (line in lines.take(PREVIEW_MAX_VISIBLE_LINES)) {
Text(
text = line,
style = WebTermType.monoTabular(12),
color = MaterialTheme.colorScheme.onSurface,
)
}
if (lines.size > PREVIEW_MAX_VISIBLE_LINES) ClippedNote()
}
@Composable
private fun DiffPreview(file: PreviewDiffFile) {
Text(
text = GatePreviewCopy.diffHeader(file),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (file.binary) {
Text(text = GatePreviewCopy.BINARY, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
return
}
var painted = 0
for (hunk in file.hunks) {
if (painted >= PREVIEW_MAX_VISIBLE_LINES) break
DiffLineText(hunk.header, DIFF_KIND_HUNK)
painted++
for (line in hunk.lines) {
if (painted >= PREVIEW_MAX_VISIBLE_LINES) break
DiffLineText(line.text, line.kind)
painted++
}
}
val total = file.hunks.sumOf { it.lines.size + 1 }
if (total > painted) ClippedNote()
}
@Composable
private fun DiffLineText(text: String, kind: String) {
// An UNKNOWN kind still renders (just uncoloured): dropping a line the client cannot classify
// would hide part of what the user is approving.
val color = when (kind) {
DIFF_KIND_ADDED -> WebTermColors.statusWorking
DIFF_KIND_REMOVED -> WebTermColors.statusStuck
DIFF_KIND_HUNK, DIFF_KIND_META -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurface
}
Text(text = text, style = WebTermType.monoTabular(12), color = color, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@Composable
private fun ClippedNote() {
Text(
text = GatePreviewCopy.CLIPPED,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** Wire `DiffLineKind` values (`src/types.ts`) — raw strings, so an unknown kind stays itself. */
private const val DIFF_KIND_ADDED = "added"
private const val DIFF_KIND_REMOVED = "removed"
private const val DIFF_KIND_HUNK = "hunk"
private const val DIFF_KIND_META = "meta"
/** Preview copy (local UI text). */
internal object GatePreviewCopy {
const val TRUNCATED: String = "… 服务端已截断"
const val CLIPPED: String = "… 仅显示前 $PREVIEW_MAX_VISIBLE_LINES 行"
const val BINARY: String = "二进制文件"
fun diffHeader(file: PreviewDiffFile): String {
val path = if (file.oldPath != file.newPath) "${file.oldPath}${file.newPath}" else file.newPath
return "$path +${file.added}/-${file.removed}"
}
}
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "GateBanner")
@@ -84,6 +211,7 @@ private fun GateBannerPreview() {
GateBanner(
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
onDecide = { _, _ -> },
preview = ApprovalPreview.Command("rm -rf build/\nnpm run build", truncated = true),
)
}
}

View File

@@ -20,6 +20,7 @@ import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.GateKind
/**
@@ -49,6 +50,7 @@ public fun PlanGateSheet(
onDecide: (GateDecision, Int) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
preview: ApprovalPreview? = null,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
@@ -69,6 +71,9 @@ public fun PlanGateSheet(
overflow = TextOverflow.Ellipsis,
)
}
// W1: a plan gate usually has nothing reviewable, but when the server DOES send a preview
// (a plan that resolves straight into an Edit/Bash) it belongs above the buttons.
ApprovalPreviewBlock(preview)
Button(
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
modifier = Modifier.fillMaxWidth(),

View File

@@ -17,6 +17,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import wang.yaojia.webterm.nav.LayoutMode
import wang.yaojia.webterm.nav.PointerMenuPolicy
import wang.yaojia.webterm.viewmodels.SessionRow
import java.util.UUID
/**
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
@@ -59,7 +61,12 @@ public fun PointerContextMenu(
val density = LocalDensity.current
Box(
modifier = modifier.pointerInput(actions) {
// Keyed on Unit, NOT on [actions]: the gesture body only records the anchor and opens the menu —
// it never reads [actions] (the DropdownMenu below reads the current list on every composition).
// Keying on the list would restart the pointer handler on every recomposition, because the
// per-entry lambdas in a freshly-built List<ContextMenuAction> are never equal to the previous
// ones — a right-click landing during that restart window would be dropped.
modifier = modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
@@ -95,3 +102,41 @@ public fun PointerContextMenu(
/** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */
public data class ContextMenuAction(val label: String, val onClick: () -> Unit)
// ── The session-row action set (A26 — the one surface the pointer menu is attached to) ───────────────
/** 打开会话 — the same target a row tap has. */
public const val MENU_LABEL_OPEN: String = "打开会话"
/** 在当前目录开新会话 — `attach(null, cwd)` in the row's own cwd (plan §1 new-session-in-cwd). */
public const val MENU_LABEL_NEW_IN_CWD: String = "在当前目录开新会话"
/** 终止会话 — `DELETE /live-sessions/:id`, the pointer equivalent of swipe-to-kill. */
public const val MENU_LABEL_KILL: String = "终止会话"
/**
* The [PointerContextMenu] entries for one session row — the pure half of the A26 pointer menu (the
* gesture and the placement are device-QA; [PointerMenuPolicy] gates whether the menu exists at all).
*
* Mirrors iOS's pointer menu with ONE deliberate omission: **copy is absent**. Copy-out lives in the
* terminal's own text-selection `ActionMode`, not in a list-row menu, and the row has no selected text to
* copy — advertising it would be a dead entry.
*
* The new-session entry appears only when BOTH a usable cwd is known AND a spawn handler is wired: the
* server may omit `cwd` (or send blank), and `attach(null, cwd)` with no directory is not the same action.
* Every label here is a fixed app string — the server-provided cwd is carried in the CALLBACK only and is
* never rendered into a menu entry (plan §8: untrusted server strings stay out of chrome).
*/
public fun sessionRowMenuActions(
row: SessionRow,
onOpen: (UUID) -> Unit,
onNewSessionInCwd: ((String) -> Unit)?,
onKill: (UUID) -> Unit,
): List<ContextMenuAction> = buildList {
add(ContextMenuAction(MENU_LABEL_OPEN) { onOpen(row.id) })
val cwd = row.info.cwd?.takeIf { it.isNotBlank() }
if (cwd != null && onNewSessionInCwd != null) {
add(ContextMenuAction(MENU_LABEL_NEW_IN_CWD) { onNewSessionInCwd(cwd) })
}
add(ContextMenuAction(MENU_LABEL_KILL) { onKill(row.id) })
}

View File

@@ -0,0 +1,271 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.QueueNotice
import wang.yaojia.webterm.viewmodels.QueueUiState
import wang.yaojia.webterm.viewmodels.QueueViewModel
/**
* # QueueBadge (W2) — the "N queued" pill.
*
* Renders NOTHING for a null (unknown / pre-W2 server) or non-positive depth, so a host without the
* queue feature shows no affordance at all and an empty queue does not shout "0".
*
* The number is always the server's own count — the live `queue` frame or `GET /live-sessions`'s
* `queueLength` — never a locally incremented guess.
*/
@Composable
public fun QueueBadge(depth: Int?, modifier: Modifier = Modifier) {
val pending = depth ?: return
if (pending <= 0) return
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(Radius.sm8))
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs2),
) {
Text(
text = "$pending 待发",
style = WebTermType.metaMono, // tabular numerals so the pill does not jitter as N changes
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
)
}
}
/**
* # QueuePanel (W2) — queue / review / cancel the follow-up prompt FIFO.
*
* The Android counterpart of `public/queue.ts`: a bottom sheet that queues a prompt the server injects
* into the PTY the next time Claude goes idle, lists what is already pending, and offers the ONE cancel
* the server actually has — cancel-ALL. There is deliberately no per-item cancel affordance, because
* `DELETE /live-sessions/:id/queue` clears the whole FIFO and pretending otherwise would lie.
*
* ### The composed text is raw keyboard input (invariant #9)
* The field's contents go to [onSend] **verbatim** — this file never trims, normalises, or appends a
* `\r`. `appendEnter` is a FLAG on the request, so the SERVER materialises the carriage return
* byte-identically to a keystroke. The send action is enabled by [QueueViewModel.canSend], which refuses
* only a genuinely EMPTY string; whitespace is legitimate shell input.
*
* ### Untrusted text (plan §8)
* Queued items are round-tripped through the server, so they are rendered as INERT [Text] — no linkify,
* no Markdown, no `AnnotatedString` autolink — exactly like every other server-derived string. So is the
* [QueueNotice.Rejected] message, which is the server's own already-sanitized `error` field.
*
* Sheet presentation / detents / IME behaviour are device-QA (plan §7); the state machine is
* `QueueViewModelTest`.
*
* @param onSend the composed prompt, verbatim. The panel clears its field only after invoking this.
* @param onClearAll `DELETE …/queue` — cancel every pending entry.
* @param onRefresh re-read the queue (used when the live depth says the cached list is stale).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
public fun QueuePanel(
state: QueueUiState,
onSend: (String) -> Unit,
onClearAll: () -> Unit,
onDismissNotice: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
onRefresh: () -> Unit = {},
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var draft by remember { mutableStateOf("") }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.lg16, vertical = Spacing.md12),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(
text = "排队提示词",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
QueueBadge(depth = state.depth)
}
Text(
text = "Claude 下次空闲时,服务器会按顺序把它们送进终端。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
state.notice?.let { notice ->
QueueNoticeRow(notice = notice, onDismiss = onDismissNotice)
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it }, // VERBATIM: no trim / normalisation anywhere on this path
label = { Text("要排队的提示词") },
enabled = !state.isBusy,
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 6,
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Button(
onClick = {
onSend(draft) // exactly what the user typed
draft = ""
},
enabled = !state.isBusy && QueueViewModel.canSend(draft),
) { Text("加入队列") }
TextButton(onClick = onClearAll, enabled = !state.isBusy && state.depth > 0) {
Text("全部取消")
}
}
HorizontalDivider()
QueuedItems(state = state, onRefresh = onRefresh)
}
}
}
/** The pending prompts, inert. A stale list offers a re-read rather than contradicting the badge. */
@Composable
private fun QueuedItems(state: QueueUiState, onRefresh: () -> Unit) {
if (state.isItemsStale) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(
text = "队列已变化。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onRefresh) { Text("刷新") }
}
return
}
if (state.items.isEmpty()) {
Text(
text = "队列是空的。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = QUEUE_LIST_MAX_HEIGHT),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
itemsIndexed(state.items) { index, item ->
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text(
text = "${index + 1}.",
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// INERT: round-tripped through the server, so plain Text only (plan §8).
Text(
text = item,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
}
/**
* One honest line per failure. A 429 says so plainly and offers NO retry button — the bucket is shared
* (20/min/IP), so a retry only burns the budget a real enqueue needs.
*/
@Composable
private fun QueueNoticeRow(notice: QueueNotice, onDismiss: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8))
.padding(Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(
text = queueNoticeText(notice),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) { Text("知道了") }
}
}
/**
* App-authored copy for each outcome. [QueueNotice.Rejected] is the ONE case that surfaces a server
* string, and it is displayed inertly and verbatim (the server already sanitized it); a null message
* falls back to app copy rather than printing "null".
*/
internal fun queueNoticeText(notice: QueueNotice): String = when (notice) {
QueueNotice.Full -> "队列已满,请先取消一条。"
QueueNotice.TooLarge -> "提示词太长了,请缩短后重试。"
QueueNotice.Disabled -> "这台主机关闭了排队功能。"
QueueNotice.SessionGone -> "会话已退出,无法排队。"
// No retry offered: the 20/min bucket is shared with every other client of this host.
QueueNotice.RateLimited -> "操作过于频繁(每分钟上限 20 次),请稍后再手动重试。"
QueueNotice.Unreachable -> "无法连接主机,队列状态未知。"
is QueueNotice.Rejected -> notice.message ?: "主机拒绝了这次操作。"
}
/** Keeps the sheet's buttons reachable no matter how long the queue gets. */
private val QUEUE_LIST_MAX_HEIGHT = Spacing.xxl24 * 8 // 192dp
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "QueueBadge")
@Composable
private fun QueueBadgePreview() {
WebTermTheme {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
QueueBadge(depth = 3)
QueueBadge(depth = 0) // renders nothing
QueueBadge(depth = null) // renders nothing
}
}
}

View File

@@ -2,20 +2,35 @@ package wang.yaojia.webterm.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
@@ -96,6 +111,241 @@ internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) {
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
gateHeld && hasChips
// ── The palette presenter (production state holder) ─────────────────────────────
/**
* The state holder the terminal screen renders the chips from: one published, immutable
* [chips] snapshot over a [QuickReplyStore].
*
* Every mutation delegates to the store — which is the CRUD authority and returns the new
* list — and republishes exactly what came back, so the UI can never drift from what was
* persisted (no optimistic local copy to reconcile, and a guarded no-op like a blank `add`
* simply republishes the unchanged list). Nothing is published before [load]: an unloaded
* palette shows no chips rather than flashing the defaults over the user's real list.
*
* Plain (not an `androidx.lifecycle.ViewModel`) so it drives under `runTest` virtual time;
* the screen `remember`s one and loads it in a `LaunchedEffect`. Cross-device sync is out of
* scope (plan §1) — this is one device's palette.
*/
public class QuickReplyPalette(private val store: QuickReplyStore) {
private val _chips = MutableStateFlow<List<QuickReplyChip>>(emptyList())
/** The palette in display order; empty until [load] (and after a user "delete all"). */
public val chips: StateFlow<List<QuickReplyChip>> = _chips.asStateFlow()
/** Read the persisted palette (defaults on first ever read) and publish it. */
public suspend fun load() {
_chips.value = store.loadAll()
}
/** Append [text] (blank is a store-level no-op) and republish. */
public suspend fun add(text: String) {
_chips.value = store.add(text)
}
/** Replace the text of [id] and republish (unknown id / blank text → no-op). */
public suspend fun edit(id: String, text: String) {
_chips.value = store.edit(id, text)
}
/** Remove [id] and republish (unknown id → no-op). */
public suspend fun remove(id: String) {
_chips.value = store.remove(id)
}
/** Reorder [from]→[to] and republish (out-of-range → no-op). */
public suspend fun move(from: Int, to: Int) {
_chips.value = store.move(from, to)
}
}
// ── The editor's select / commit reducer (pure, JVM-tested) ─────────────────────
/**
* The editor's whole mutable state: ONE text field that either appends a new chip
* ([selectedId] null) or rewrites the selected one. Immutable — every transition returns a
* fresh snapshot.
*
* A single field (rather than an inline field per row) is deliberate: a per-row bound field
* would write to the store on every keystroke, i.e. one DataStore round-trip per character.
*/
internal data class QuickReplyEditorState(
/** The chip being rewritten, or null while composing a new one. */
val selectedId: String? = null,
/** The field's current text, carried VERBATIM into the commit (whitespace preserved). */
val draft: String = "",
)
/** What committing the current [QuickReplyEditorState] does. */
internal enum class QuickReplyCommit { ADD, EDIT, NONE }
/** Load [chip] into the field for rewriting (its id becomes the commit target). */
internal fun QuickReplyEditorState.selecting(chip: QuickReplyChip): QuickReplyEditorState =
copy(selectedId = chip.id, draft = chip.text)
/** Back to "composing a new chip" with an empty field. */
internal fun QuickReplyEditorState.cleared(): QuickReplyEditorState =
QuickReplyEditorState()
/**
* A blank draft commits NOTHING (mirrors the store's blank guard, so the button can be
* disabled instead of silently no-op-ing); otherwise ADD when nothing is selected, EDIT when
* a chip is.
*/
internal fun QuickReplyEditorState.commitKind(): QuickReplyCommit = when {
draft.isBlank() -> QuickReplyCommit.NONE
selectedId == null -> QuickReplyCommit.ADD
else -> QuickReplyCommit.EDIT
}
// ── The palette editor ──────────────────────────────────────────────────────────
/**
* The editable-palette surface (plan §1 "Quick-reply chips + editable palette"): an
* [AlertDialog] over the store's CRUD. One field composes/rewrites; each row can be selected
* (tap 编辑), reordered (▲/▼) or deleted (✕).
*
* The chip texts are the user's OWN strings — never server data — but they are still rendered
* as plain inert [Text] (no Markdown/autolink), consistent with §8.
*
* Layout/scroll behaviour is device-QA (§7); the select/commit decision is the JVM-tested
* [QuickReplyEditorState] reducer.
*
* @param chips the current palette snapshot (from [QuickReplyPalette.chips]).
* @param onAdd / [onEdit] / [onRemove] / [onMove] the store-backed CRUD callbacks.
* @param onDismiss close the editor.
*/
@Composable
public fun QuickReplyPaletteEditor(
chips: List<QuickReplyChip>,
onAdd: (String) -> Unit,
onEdit: (id: String, text: String) -> Unit,
onRemove: (id: String) -> Unit,
onMove: (from: Int, to: Int) -> Unit,
onDismiss: () -> Unit,
) {
var state by remember { mutableStateOf(QuickReplyEditorState()) }
// A chip removed while selected must not leave a dangling edit target pointing at a gone id.
// DERIVED, not written back during composition (a composition-time state write is a recomposition
// hazard): the field simply falls back to "adding" and keeps whatever was typed.
val effective = if (state.selectedId != null && chips.none { it.id == state.selectedId }) {
state.copy(selectedId = null)
} else {
state
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("快捷回复") },
text = {
Column(
modifier = Modifier.heightIn(max = EDITOR_MAX_HEIGHT_DP.dp),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
QuickReplyEditorField(
state = effective,
onDraftChange = { state = effective.copy(draft = it) },
onCommit = {
when (effective.commitKind()) {
QuickReplyCommit.ADD -> onAdd(effective.draft)
QuickReplyCommit.EDIT -> onEdit(requireNotNull(effective.selectedId), effective.draft)
QuickReplyCommit.NONE -> Unit
}
state = effective.cleared()
},
onCancelSelection = { state = effective.cleared() },
)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
if (chips.isEmpty()) {
Text("还没有快捷回复。", style = MaterialTheme.typography.bodySmall)
}
chips.forEachIndexed { index, chip ->
QuickReplyEditorRow(
chip = chip,
isSelected = chip.id == effective.selectedId,
canMoveUp = index > 0,
canMoveDown = index < chips.lastIndex,
onSelect = { state = effective.selecting(chip) },
onMoveUp = { onMove(index, index - 1) },
onMoveDown = { onMove(index, index + 1) },
onRemove = { onRemove(chip.id) },
)
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("完成") } },
)
}
/** The single compose/rewrite field + its commit action. */
@Composable
private fun QuickReplyEditorField(
state: QuickReplyEditorState,
onDraftChange: (String) -> Unit,
onCommit: () -> Unit,
onCancelSelection: () -> Unit,
) {
OutlinedTextField(
value = state.draft,
onValueChange = onDraftChange,
label = { Text(if (state.selectedId == null) "新增内容" else "修改内容") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
TextButton(
onClick = onCommit,
enabled = state.commitKind() != QuickReplyCommit.NONE,
) { Text(if (state.selectedId == null) "添加" else "保存") }
if (state.selectedId != null) {
TextButton(onClick = onCancelSelection) { Text("取消") }
}
}
}
/** One palette row: the (inert) text plus select / reorder / delete affordances. */
@Composable
private fun QuickReplyEditorRow(
chip: QuickReplyChip,
isSelected: Boolean,
canMoveUp: Boolean,
canMoveDown: Boolean,
onSelect: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onRemove: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = chip.text,
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onSelect) { Text("编辑") }
TextButton(onClick = onMoveUp, enabled = canMoveUp) { Text("") }
TextButton(onClick = onMoveDown, enabled = canMoveDown) { Text("") }
TextButton(onClick = onRemove) { Text("") }
}
}
/** Cap on the editor body height so a long palette scrolls inside the dialog. */
private const val EDITOR_MAX_HEIGHT_DP: Int = 320
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "QuickReply (gate held)")
@@ -109,3 +359,18 @@ private fun QuickReplyPreview() {
)
}
}
@Preview(name = "QuickReply palette editor")
@Composable
private fun QuickReplyPaletteEditorPreview() {
WebTermTheme {
QuickReplyPaletteEditor(
chips = QuickReplyDefaults.chips,
onAdd = {},
onEdit = { _, _ -> },
onRemove = {},
onMove = { _, _ -> },
onDismiss = {},
)
}
}

View File

@@ -113,6 +113,10 @@ private fun TitleLine(row: SessionRow) {
) {
StatusBadge(status = row.displayStatus)
if (row.isUnread) UnreadDot()
// W2: the pending follow-up depth from `GET /live-sessions`'s `queueLength` (null on a pre-W2
// server → renders nothing). The badge is the cold/list source; an ATTACHED session's live depth
// comes from the `queue` WS frame instead.
QueueBadge(depth = row.info.queueLength)
Text(
text = row.title.ifBlank { fallbackLabel(row.info) },
style = MaterialTheme.typography.bodyMedium,
@@ -202,7 +206,12 @@ private fun SessionListRowPreview() {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
SessionListRow(
row = SessionRow(
info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L),
info = previewInfo(
status = ClaudeStatus.WORKING,
title = "web-terminal",
lastOutputAt = 2L,
queueLength = 2,
),
displayStatus = DisplayStatus.Working,
title = "web-terminal",
isUnread = true,
@@ -228,6 +237,7 @@ private fun previewInfo(
cols: Int = 161,
rows: Int = 50,
lastOutputAt: Long? = null,
queueLength: Int? = null,
): LiveSessionInfo = LiveSessionInfo(
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
createdAt = 1L,
@@ -240,4 +250,5 @@ private fun previewInfo(
rows = rows,
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
lastOutputAt = lastOutputAt,
queueLength = queueLength,
)

View File

@@ -0,0 +1,136 @@
package wang.yaojia.webterm.di
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.CookieJar
import wang.yaojia.webterm.hostregistry.AuthCookieCipher
import wang.yaojia.webterm.hostregistry.AuthCookieStore
import wang.yaojia.webterm.hostregistry.DataStoreAuthCookieStore
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
import wang.yaojia.webterm.tlsandroid.TinkAuthCookieCipher
import wang.yaojia.webterm.transport.AuthCookieJar
import wang.yaojia.webterm.wiring.toRecord
import javax.inject.Singleton
/**
* The optional `WEBTERM_TOKEN` gate's client boundary (server `src/http/auth.ts`).
*
* Three things are wired here, and all three are required for the gate to work at all:
* 1. **[AuthCookieJar] on the ONE shared `OkHttpClient`** (via the [CookieJar] binding [NetworkModule]
* consumes). Both transports share that client, so the `webterm_auth` cookie rides every REST call
* AND the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls too). Without this the
* client runs on `CookieJar.NO_COOKIES` and a token-gated host 401s everything.
* 2. **[AuthCookieStore]** so the credential survives a process restart — encrypted at rest.
* 3. **The [AuthCookieCipher] seam** bridging `:host-registry` (which must not depend on Tink) to
* `:client-tls-android`'s [TinkAuthCookieCipher] (which must not depend on `:host-registry`) — the
* same sibling-bridge pattern [NetworkModule] uses for the mTLS `ClientIdentityProvider`.
*
* ### Fail CLOSED
* [DataStoreAuthCookieStore] takes the cipher as a REQUIRED constructor argument precisely so no wiring
* can persist plaintext by omission. If a real cipher cannot be constructed, this module binds
* [InMemoryAuthCookieStore] instead: memory-only persistence (the user re-authenticates after a restart)
* is the correct answer, and writing a full-shell credential in the clear is not an option. A cipher that
* constructs but later fails to seal is handled the same way one level down — the store persists NOTHING
* and drops any previous blob.
*
* ### Threading
* The graph builds nothing expensive: [TinkAuthCookieCipher] defers all AEAD/keystore work to first use,
* and `AppEnvironment.warmUp()` does the first read off `Main`. The persister side must be non-blocking
* because OkHttp calls a `CookieJar` synchronously on the call thread, so the durable write is handed to
* an app-scoped IO scope.
*/
@Module
@InstallIn(SingletonComponent::class)
public object AuthCookieModule {
/**
* The durable home of the `webterm_auth` cookie. Shares the ONE Preferences DataStore with the host
* list / last-session pointer ([StorageModule]) — disjoint keys (`authCookiesSealed` vs `hosts` /
* `lastSessionId.<hostId>`) — but the VALUE here is Tink-AEAD ciphertext, never plaintext.
*/
@Provides
@Singleton
public fun provideAuthCookieStore(
dataStore: DataStore<Preferences>,
@ApplicationContext context: Context,
): AuthCookieStore {
val cipher = buildAuthCookieCipher(context)
if (cipher == null) {
// Fail closed: memory-only rather than a plaintext credential at rest.
Log.w(TAG, "auth-cookie cipher unavailable — the session cookie will not be persisted")
return InMemoryAuthCookieStore()
}
return DataStoreAuthCookieStore(
dataStore = dataStore,
cipher = cipher,
onCipherFailure = ::reportCipherFailure,
)
}
/**
* The ONE jar. Its persister hands each host's changed cookie set to [AuthCookieStore] on an
* app-scoped IO scope — never blocking OkHttp's call thread on DataStore/Tink I/O. The scope lives for
* the process (there is no later moment at which a pending credential write should be abandoned).
*/
@Provides
@Singleton
public fun provideAuthCookieJar(store: AuthCookieStore): AuthCookieJar {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
return AuthCookieJar(
persister = { hostKey, cookies ->
scope.launch {
runCatching { store.replaceHost(hostKey, cookies.map { it.toRecord(hostKey) }) }
.onFailure(::reportPersistFailure)
}
},
)
}
/** What [NetworkModule] installs on the shared client — the same instance as [provideAuthCookieJar]. */
@Provides
@Singleton
public fun provideCookieJar(jar: AuthCookieJar): CookieJar = jar
/**
* The 4-line adapter [TinkAuthCookieCipher]'s KDoc prescribes: it matches [AuthCookieCipher]'s shape
* exactly but cannot declare it (sibling modules), so `:app` bridges the two.
*
* Returns null if the cipher cannot even be CONSTRUCTED. Today's implementation defers all keystore
* work to first use and so does not throw here; the guard exists because the alternative to "no
* cipher" must always be "no persistence", never "plaintext persistence".
*/
private fun buildAuthCookieCipher(context: Context): AuthCookieCipher? =
runCatching {
val tink = TinkAuthCookieCipher(context)
object : AuthCookieCipher {
override fun seal(plaintext: String): String = tink.seal(plaintext)
override fun open(sealed: String): String = tink.open(sealed)
}
}.getOrNull()
/**
* A seal/open failure means a credential was dropped instead of stored (or a stored one could not be
* read). Reported by TYPE only: neither the plaintext nor the ciphertext may reach a log.
*/
private fun reportCipherFailure(error: Throwable) {
Log.w(TAG, "auth-cookie at-rest crypto failed (${error::class.simpleName}) — nothing was persisted")
}
/** A durable-write failure leaves the cookie in memory only; same logging discipline. */
private fun reportPersistFailure(error: Throwable) {
Log.w(TAG, "auth-cookie persist failed (${error::class.simpleName}) — cookie kept in memory only")
}
private const val TAG: String = "WebTermAuthCookie"
}

View File

@@ -5,6 +5,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.ConnectionPool
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import wang.yaojia.webterm.transport.ClientIdentity
import wang.yaojia.webterm.transport.ClientIdentityProvider
@@ -27,6 +28,11 @@ import javax.inject.Singleton
* re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no
* client rebuild (R4). The provider is read ONCE, when the client is built.
*
* ### The WEBTERM_TOKEN cookie jar
* The shared client also carries the ONE [CookieJar] provided by [AuthCookieModule], so the optional
* access-token gate applies uniformly to REST and to the WS upgrade. See that module for the at-rest
* (Tink AEAD) custody of the cookie.
*
* ### Breaking the construction cycle (A11's frozen constructor)
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
* client needs the repository's SSL material — a cycle. It is broken with an explicit shared
@@ -53,14 +59,24 @@ public object NetworkModule {
ClientIdentity(material.sslSocketFactory, material.trustManager)
}
/** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */
/**
* The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool] and
* carrying the ONE [CookieJar] ([AuthCookieModule]).
*
* The cookie jar is what makes the optional `WEBTERM_TOKEN` gate work: a gated host answers pairing
* with `Set-Cookie: webterm_auth=…` and then requires that cookie on every remote route AND on the WS
* upgrade. Because both transports share this client, installing the jar HERE covers both at once
* (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Leaving it at `CookieJar.NO_COOKIES` — as
* this provider did before — makes the whole token feature inert.
*/
@Provides
@Singleton
public fun provideOkHttpClient(
identityProvider: ClientIdentityProvider,
connectionPool: ConnectionPool,
cookieJar: CookieJar,
): OkHttpClient =
OkHttpClientFactory.create(identityProvider)
OkHttpClientFactory.create(identityProvider, cookieJar)
.newBuilder()
.connectionPool(connectionPool)
.build()

View File

@@ -1,11 +1,19 @@
package wang.yaojia.webterm.di
import com.google.firebase.messaging.FirebaseMessaging
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.suspendCancellableCoroutine
import wang.yaojia.webterm.push.PushRegistrar
import wang.yaojia.webterm.push.PushRegistrarTokenSink
import wang.yaojia.webterm.push.PushTokenSink
import wang.yaojia.webterm.wiring.HostPushUnregister
import wang.yaojia.webterm.wiring.PushTokenSource
import javax.inject.Singleton
import kotlin.coroutines.resume
/**
* Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with
@@ -13,10 +21,52 @@ import wang.yaojia.webterm.push.PushTokenSink
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
* (self-heal). This is the intended optional-collaborator handoff (no mutable global).
*
* It also binds the two seams the HOST-REMOVAL path needs
* ([HostPushUnregister] + [PushTokenSource]): `PushRegistrar.unregisterHost` had zero call sites, so a
* removed host kept this device's token and kept pushing for a host the user had dropped.
*/
@Module
@InstallIn(SingletonComponent::class)
public interface PushModule {
@Binds
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink
public companion object {
/**
* `DELETE /push/fcm-token` for ONE host — the FIRST caller of [PushRegistrar.unregisterHost],
* which was defined and never invoked, so a removed host kept this device's token forever.
*
* Routed through the registrar rather than a fresh `ApiClient` so the removal inherits its
* already-tested best-effort discipline (a `CancellationException` rethrown, any other failure
* swallowed with the token never logged).
*/
@Provides
@Singleton
public fun provideHostPushUnregister(
registrar: PushRegistrar,
): HostPushUnregister = HostPushUnregister { host, token -> registrar.unregisterHost(host, token) }
/**
* The current FCM registration token. Suspends on Firebase's own `Task` rather than blocking, and
* answers `null` for every degraded case (no `google-services.json`, uninitialised `FirebaseApp`,
* a failed fetch) so a push-less build still removes hosts cleanly. The token is returned to the
* caller and NEVER logged (plan §8).
*/
@Provides
@Singleton
public fun providePushTokenSource(): PushTokenSource = PushTokenSource {
suspendCancellableCoroutine { continuation ->
val task = runCatching { FirebaseMessaging.getInstance().token }.getOrNull()
if (task == null) {
continuation.resume(null)
return@suspendCancellableCoroutine
}
task
.addOnSuccessListener { token -> continuation.resume(token?.takeIf { it.isNotEmpty() }) }
.addOnFailureListener { continuation.resume(null) }
.addOnCanceledListener { continuation.resume(null) }
}
}
}
}

View File

@@ -12,15 +12,22 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.hostregistry.DataStoreHostStore
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
import wang.yaojia.webterm.hostregistry.DataStoreSessionWatermarkStore
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.LastSessionStore
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
import javax.inject.Singleton
/**
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore.
* The host list ([HostStore], key `"hosts"`) and per-host last-session id ([LastSessionStore], key
* `"lastSessionId.<hostId>"`) use disjoint keys, so they safely share one DataStore file. Secret
* material (the device cert) never lives here — that is the Tink store in [TlsModule].
* The host list ([HostStore], key `"hosts"`), the per-host last-session id ([LastSessionStore], key
* `"lastSessionId.<hostId>"`) and the unread watermarks ([SessionWatermarkStore], key
* `"unreadWatermarks"`) use disjoint keys, so they safely share one DataStore file. Secret material (the
* device cert, the `webterm_auth` cookie) never lives here in the clear — those are the Tink-sealed
* stores in [TlsModule] / [AuthCookieModule].
*
* Two `DataStore` instances over one file THROW, so every store here takes the single [provideDataStore]
* singleton; a new store means a new KEY, never a new file.
*/
@Module
@InstallIn(SingletonComponent::class)
@@ -43,5 +50,16 @@ public object StorageModule {
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
DataStoreLastSessionStore(dataStore)
/**
* The durable home of the session-list unread watermarks (`sessionId → last-seen ms`). Until this was
* wired the ledger was memory-only, so the unread dot reset on every process death — precisely when
* it matters, since the product premise is walking away and coming back.
*/
@Provides
@Singleton
public fun provideSessionWatermarkStore(
dataStore: DataStore<Preferences>,
): SessionWatermarkStore = DataStoreSessionWatermarkStore(dataStore)
private const val DATASTORE_NAME: String = "webterm"
}

View File

@@ -0,0 +1,31 @@
package wang.yaojia.webterm.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.wiring.CanvasThumbnailRasterizer
import wang.yaojia.webterm.wiring.ThumbnailRasterizer
import javax.inject.Singleton
/**
* Session-preview boundary (plan §6.7): binds the off-screen rasterizer the thumbnail pipeline paints
* with, so `AppEnvironment.buildThumbnailPipeline(endpoint)` can mint a per-host pipeline and the session
* list finally renders real previews instead of placeholder tiles.
*
* ONE rasterizer for the whole app: its cell metrics are measured once, and it is explicitly safe to
* share across the pipeline's concurrent renders (each `rasterize` copies the metrics `Paint` into a
* local draw `Paint` — see [CanvasThumbnailRasterizer]).
*
* Consumers inject it as `dagger.Lazy` (`AppEnvironment`): constructing it measures a `Paint`, which is an
* `android.graphics` touch that must not happen while the Hilt graph is assembled (android.graphics is
* stubbed under JVM unit tests, and graph assembly happens on `Main`).
*/
@Module
@InstallIn(SingletonComponent::class)
public object ThumbnailModule {
@Provides
@Singleton
public fun provideThumbnailRasterizer(): ThumbnailRasterizer = CanvasThumbnailRasterizer()
}

View File

@@ -16,6 +16,8 @@ import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.tlsandroid.TinkCertStore
import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wiring.CertificateRotationScheduler
import javax.inject.Singleton
/**
@@ -78,4 +80,35 @@ public object TlsModule {
@Singleton
public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher =
repository
/**
* Silent device-certificate rotation (the iOS `CertificateRotationScheduler` counterpart). Android had
* NO rotation at all, so a device certificate simply expired and stranded the app; the scheduler also
* routes an ALREADY-lapsed leaf to `POST /device/:id/recover`, which iOS cannot do.
*
* ### This provider must stay I/O-free
* The shared `OkHttpClient` and the mTLS [HttpTransport] are taken as [dagger.Lazy] and passed as
* SUPPLIERS, so nothing here builds an `SSLSocketFactory` (AndroidKeyStore + Tink reads) while the
* Hilt graph is assembled — that assembly happens on `Main`, and a previous review caught exactly
* that class of ANR. The scheduler resolves both suppliers inside its own `Dispatchers.IO` hop.
*
* `plainTransport` is deliberately NOT passed: its default builds a fresh identity-free client, and
* that separation is the entire point of the recovery path (no TLS terminator will forward an expired
* client certificate, in any `ssl_verify_client` mode).
*/
@Provides
@Singleton
public fun provideCertificateRotationScheduler(
certStore: CertStore,
recordStore: EnrollmentRecordStore,
cacheRefresher: IdentityCacheRefresher,
client: dagger.Lazy<OkHttpClient>,
httpTransport: dagger.Lazy<HttpTransport>,
): CertificateRotationScheduler = CertificateRotationScheduler.create(
certStore = certStore,
recordStore = recordStore,
cacheRefresher = cacheRefresher,
sharedClient = { client.get() },
mtlsTransport = { httpTransport.get() },
)
}

View File

@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -18,12 +20,16 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import wang.yaojia.webterm.components.ContinueLastBanner
import wang.yaojia.webterm.components.SessionThumbnails
import wang.yaojia.webterm.components.continueLastModel
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.screens.AdaptiveHome
import wang.yaojia.webterm.screens.SessionListScreen
import wang.yaojia.webterm.viewmodels.ApiClientSessionGateway
import wang.yaojia.webterm.viewmodels.SessionListViewModel
import wang.yaojia.webterm.wiring.AppEnvironment
import wang.yaojia.webterm.wiring.ThumbnailPipeline
import wang.yaojia.webterm.wiring.sessionThumbnailPipeline
import java.util.UUID
/**
@@ -39,6 +45,21 @@ import java.util.UUID
*
* The [SessionListViewModel] host menu drives its own active-host selection; this pane reads the resolved
* active host id from its state to build the terminal endpoint / continue-last pointer.
*
* ### Live preview thumbnails (plan §6.7)
* This is the ONE production construction point of the [ThumbnailPipeline]: the active host's endpoint is
* resolved to an [ApiClient][wang.yaojia.webterm.api.routes.ApiClient] and handed to
* [sessionThumbnailPipeline], whose [SessionThumbnails] adapter is threaded into [SessionListScreen] so
* every row shows the session's actual screen instead of a placeholder tile. The fetch is the READ-ONLY
* `GET /live-sessions/:id/preview` — it never attaches, so merely looking at the chooser cannot inflate a
* session's client count or reset its idle clock. The pipeline is pruned to the current row set on every
* poll emission (dead sessions release their bitmaps) and closed when this pane leaves the composition.
*
* ### Pointer context menu (A26)
* The resolved [LayoutMode] is passed down so [SessionListScreen] can wrap each row in a
* [PointerContextMenu][wang.yaojia.webterm.components.PointerContextMenu]; the gate itself is
* [PointerMenuPolicy], evaluated inside that wrapper. The menu's「在当前目录开新会话」routes exactly like
* the terminal toolbar's does.
*/
@Composable
public fun SessionsHome(
@@ -70,6 +91,40 @@ public fun SessionsHome(
val openNewSession: () -> Unit = {
activeHostId?.let { navController.navigate(NavRoutes.terminalNew(it)) }
}
// A26 pointer-menu「在当前目录开新会话」— same `attach(null, cwd)` route the terminal toolbar uses.
val openNewSessionInCwd: (String) -> Unit = { cwd ->
activeHostId?.let { navController.navigate(newTerminalRoute(it, cwd)) }
}
// ── §6.7 live preview thumbnails, for the ACTIVE host ────────────────────────────────────────────
// The VM exposes only host ids, so resolve the endpoint here (the pipeline needs a per-host ApiClient).
val activeHost by produceState<Host?>(initialValue = null, key1 = activeHostId) {
// Clear FIRST: produceState keeps the previous value across a key change, so without this a host
// switch would leave the OLD host's endpoint in place and the pipeline would fetch host A's
// session ids against host B until the resolve lands.
value = null
value = activeHostId?.let { id ->
runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == id } }.getOrNull()
}
}
// Constructing the pipeline is cheap and Main-safe: the ApiClient (→ shared OkHttpClient → mTLS
// material) resolves lazily on the first fetch, which runs on the pipeline's own dispatcher.
val pipeline: ThumbnailPipeline? = remember(activeHost) {
activeHost?.let { host -> sessionThumbnailPipeline { env.apiClientFactory.create(host.endpoint) } }
}
DisposableEffect(pipeline) {
onDispose { pipeline?.close() }
}
val thumbnails: SessionThumbnails? = remember(pipeline) {
pipeline?.let { live -> SessionThumbnails { session -> live.thumbnail(session) } }
}
// Release bitmaps for sessions that are no longer live (killed/exited) and superseded lastOutputAt keys,
// so the cache only ever holds screens this list can still show. A render already in flight for a
// just-killed session cannot re-insert its bitmap behind this prune (ThumbnailPipeline.retainOnly closes
// its publish gate); a bitmap outlives its session only for as long as a row keeps asking for it.
LaunchedEffect(pipeline, state.rows) {
pipeline?.retainOnly(state.rows.map { it.info })
}
AdaptiveHome(
listPane = {
@@ -89,6 +144,9 @@ public fun SessionsHome(
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
onEnroll = { navController.navigate(NavRoutes.ENROLL) },
onImportCert = { navController.navigate(NavRoutes.CERT) },
thumbnails = thumbnails,
layoutMode = mode,
onNewSessionInCwd = openNewSessionInCwd,
)
}
}

View File

@@ -4,7 +4,6 @@ import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService
@@ -56,11 +55,23 @@ public class FcmService : FirebaseMessagingService() {
if (pushTokenSink.isPresent) pushTokenSink.get().onTokenRefreshed(token)
}
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
/**
* NOT `@RequiresPermission`: [hasNotificationPermission] is only a fast path. The user can revoke
* POST_NOTIFICATIONS between that check and this call (a real TOCTOU — a push arrives on a binder
* thread while the settings UI is open), and `notify` then throws [SecurityException]. Throwing out
* of `onMessageReceived` would crash the FCM service, so the loss is absorbed and logged instead:
* a missed notification must never take the push channel down. Declaring the permission as
* *required* here would also be a lie — the method tolerates its absence.
*/
private fun postNotification(payload: PushPayload, plan: NotificationBuilder.NotificationPlan) {
val notification = NotificationBuilder.build(this, payload, plan)
val id = NotificationBuilder.notificationIdFor(payload.sessionId.toString())
NotificationManagerCompat.from(this).notify(id, notification)
try {
NotificationManagerCompat.from(this).notify(id, notification)
} catch (e: SecurityException) {
// Never log the payload — it carries the session id and the decision token (§8).
Log.w(TAG, "POST_NOTIFICATIONS revoked between check and notify; dropping notification", e)
}
}
/** POST_NOTIFICATIONS is a runtime permission on API 33+; below that it is implicitly granted. */

View File

@@ -47,6 +47,7 @@ import wang.yaojia.webterm.viewmodels.CertPhase
import wang.yaojia.webterm.viewmodels.CertSummaryView
import wang.yaojia.webterm.viewmodels.ClientCertUiState
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
import wang.yaojia.webterm.wiring.RotationOutcome
/**
* # ClientCertScreen (A27) — device-certificate (mTLS) management.
@@ -78,6 +79,7 @@ public fun ClientCertScreen(
viewModel: ClientCertViewModel,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
rotationOutcome: RotationOutcome? = rememberRotationOutcome(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load.
@@ -117,9 +119,21 @@ public fun ClientCertScreen(
onDismissError = viewModel::clearError,
modifier = modifier,
onBack = onBack,
rotationOutcome = rotationOutcome,
)
}
/**
* The last silent-rotation outcome, from the app graph. `null` outside a Hilt application (a `@Preview`),
* and `null` before the first pass — both render nothing.
*/
@Composable
internal fun rememberRotationOutcome(): RotationOutcome? {
val outcomes = rememberAppEnvironment()?.rotationOutcomes() ?: return null
val outcome by outcomes.collectAsStateWithLifecycle()
return outcome
}
/** Stateless body — pure inputs so a [Preview] and any host can drive it without the SAF/VM machinery. */
@Composable
public fun ClientCertScreen(
@@ -132,6 +146,7 @@ public fun ClientCertScreen(
onDismissError: () -> Unit,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
rotationOutcome: RotationOutcome? = null,
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
@@ -159,6 +174,10 @@ public fun ClientCertScreen(
state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) }
// Silent rotation is invisible by design; a FAILING one must not be. Only the states
// the user can act on are rendered — a healthy/not-due pass stays quiet.
RotationStatusRow(rotationOutcome)
PassphraseField(
passphrase = passphrase,
onPassphraseChange = onPassphraseChange,
@@ -220,6 +239,45 @@ private fun InstalledCertCard(summary: CertSummaryView) {
}
}
/**
* The last silent-rotation pass, when it is something the user can act on.
*
* Quiet for the healthy states ([RotationOutcome.NotDue] / [RotationOutcome.Renewed] /
* [RotationOutcome.Recovered] / [RotationOutcome.NotEnrolled] / [RotationOutcome.BackingOff]) — a
* successful invisible renewal should stay invisible. Loud only for the three the user must know about:
* a lapsed certificate past its recovery window, a host that has no control-plane URL recorded, and a
* repeatedly failing renewal.
*
* The failure text is the outcome's error SHAPE (a class name or `Http(status,code)`), which is all the
* scheduler retains — deliberately never `Throwable.message`, which can embed a control-plane URL, a
* response body or credential material (plan §8).
*/
@Composable
private fun RotationStatusRow(outcome: RotationOutcome?) {
val message = rotationStatusText(outcome) ?: return
Text(text = message, style = MaterialTheme.typography.bodySmall, color = WebTermColors.statusStuck)
}
/** App-authored copy for the actionable rotation outcomes; null = say nothing. */
internal fun rotationStatusText(outcome: RotationOutcome?): String? = when (outcome) {
null,
RotationOutcome.NotEnrolled,
RotationOutcome.Renewed,
RotationOutcome.Recovered,
is RotationOutcome.NotDue,
is RotationOutcome.BackingOff,
-> null
is RotationOutcome.ReEnrollRequired ->
"⚠ 设备证书已过期超过可恢复期,自动续期无法完成。请用「自动获取证书」重新登记本机。"
is RotationOutcome.NotConfigured ->
"⚠ 未记录控制面地址,无法自动续期证书。请重新执行一次「自动获取证书」。"
is RotationOutcome.Failed ->
"⚠ 证书自动续期失败(${outcome.stage.name.lowercase()}${outcome.errorShape})。当前证书仍然有效。"
}
@Composable
private fun SummaryRow(label: String, value: String) {
Row(

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -38,6 +39,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
@@ -50,8 +53,10 @@ import com.google.mlkit.vision.common.InputImage
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.viewmodels.EntryError
import wang.yaojia.webterm.viewmodels.PairingCopy
import wang.yaojia.webterm.viewmodels.PairingUiState
import wang.yaojia.webterm.viewmodels.PairingViewModel
import wang.yaojia.webterm.viewmodels.TokenError
import wang.yaojia.webterm.viewmodels.WarningTier
import wang.yaojia.webterm.viewmodels.needsExplicitAck
@@ -71,10 +76,14 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck
* - **POST_NOTIFICATIONS** is NOT requested here — push registration owns that prompt (A31); pairing
* only establishes the host.
*
* All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is
* JVM-tested in `PairingViewModelTest`.
* A host behind the optional `WEBTERM_TOKEN` gate cannot answer the probe at all until it is
* authenticated, so the flow has one more step: the masked access-token prompt (see [TokenRequiredStep]).
*
* @param viewModel the pairing state machine (nav layer builds it with the real [pairingProber]).
* All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is
* JVM-tested in `PairingViewModelTest` + `PairingTokenFlowTest`.
*
* @param viewModel the pairing state machine (the nav layer builds it with the real prober from
* `AppEnvironment.buildPairingProber()`).
* @param onPaired invoked once with the persisted [Host] after a successful pair.
* @param onImportCert opens the device-cert screen (A27) when a tunnel host is cert-gated.
*/
@@ -114,6 +123,13 @@ public fun PairingScreen(
onRetry = { viewModel.retry() },
onCancel = viewModel::reset,
)
is PairingUiState.TokenRequired -> TokenRequiredStep(
host = s.name,
error = s.error,
onSubmit = viewModel::submitAccessToken,
onCancel = viewModel::reset,
)
is PairingUiState.TokenSubmitting -> BusyStep(message = "正在验证访问令牌 …")
is PairingUiState.Failed -> FailedStep(
message = s.message,
needsAck = s.tier.needsExplicitAck,
@@ -199,6 +215,9 @@ private fun QrScanner(onScanned: (String) -> Unit) {
}
}
// QrCodeAnalyzer is @ExperimentalGetImage (it reads ImageProxy.image to feed ML Kit); constructing it
// here propagates that requirement to this call site, so the opt-in has to be declared here too.
@androidx.annotation.OptIn(ExperimentalGetImage::class)
@Composable
private fun CameraPreview(onScanned: (String) -> Unit) {
val context = LocalContext.current
@@ -292,14 +311,82 @@ private fun tierCopy(tier: WarningTier): Pair<String, String> = when (tier) {
@Composable
private fun ProbingStep(host: String) {
BusyStep(message = "正在验证 $host")
}
@Composable
private fun BusyStep(message: String) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
CircularProgressIndicator()
Text("正在验证 $host", style = MaterialTheme.typography.bodyMedium)
Text(message, style = MaterialTheme.typography.bodyMedium)
}
}
}
// ── Access token (the optional WEBTERM_TOKEN gate) ───────────────────────────────────────────────────
/**
* The access-token prompt for a `WEBTERM_TOKEN`-gated host.
*
* The token is a **shell credential**, so the field is masked ([PasswordVisualTransformation]) and typed
* as [KeyboardType.Password] (which also keeps it out of the keyboard's suggestion/learning store), and
* the composition drops it the instant it is handed to the ViewModel — nothing here, and nothing in the
* resulting UI state, retains it. All copy is app-authored and inert (plan §8).
*/
@Composable
private fun TokenRequiredStep(
host: String,
error: TokenError?,
onSubmit: (String) -> Unit,
onCancel: () -> Unit,
) {
var token by remember(host) { mutableStateOf("") }
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(Spacing.md12),
) {
Column(modifier = Modifier.padding(Spacing.md12), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text("需要访问令牌", style = MaterialTheme.typography.titleSmall)
Text(
text = "$host 启用了访问令牌WEBTERM_TOKEN。输入令牌后即可继续配对令牌只用于本次验证不会显示或记录。",
style = MaterialTheme.typography.bodySmall,
)
}
}
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text("访问令牌") },
singleLine = true,
isError = error != null,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Text(
text = PairingCopy.describeToken(error),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
Button(
onClick = {
// Hand it over and forget it in the same breath — never leave the credential in state.
val submitted = token
token = ""
onSubmit(submitted)
},
enabled = token.isNotBlank(),
modifier = Modifier.weight(1f),
) { Text("提交") }
}
}
@Composable
private fun CertRequiredStep(
host: String,

View File

@@ -37,10 +37,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectSessionRef
import wang.yaojia.webterm.api.models.SyncState
import wang.yaojia.webterm.api.models.WorktreeInfo
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.Stroke
@@ -48,8 +50,18 @@ import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.GitChipTone
import wang.yaojia.webterm.viewmodels.GitPanelCopy
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
import wang.yaojia.webterm.viewmodels.SyncBand
import wang.yaojia.webterm.viewmodels.boundaryIndex
import wang.yaojia.webterm.viewmodels.boundaryLabel
import wang.yaojia.webterm.viewmodels.isUnpushed
import wang.yaojia.webterm.viewmodels.WorktreeRowState
import wang.yaojia.webterm.viewmodels.WorktreeViewModel
import wang.yaojia.webterm.viewmodels.headerDirtyChip
import wang.yaojia.webterm.viewmodels.syncBandOf
import wang.yaojia.webterm.viewmodels.worktreeChips
import java.net.URI
/**
@@ -74,8 +86,15 @@ public fun ProjectDetailScreen(
val phase by viewModel.phase.collectAsStateWithLifecycle()
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
val fetch by viewModel.fetchPhase.collectAsStateWithLifecycle()
val worktreeStates by viewModel.worktreeStates.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
LaunchedEffect(viewModel) { viewModel.load() }
LaunchedEffect(viewModel) {
viewModel.load()
// w6/G7: probe the other worktrees once per opened project (never per tick) — each row's
// failure is isolated, so a bad probe costs one chip, not the panel.
viewModel.probeWorktrees()
}
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
@@ -91,7 +110,12 @@ public fun ProjectDetailScreen(
detail = current.detail,
prChip = prChip,
recent = recent,
fetch = fetch,
canFetch = viewModel.canFetch,
onFetch = { scope.launch { viewModel.fetchUpstream() } },
onDismissFetchBanner = viewModel::clearFetchBanner,
worktree = viewModel.worktree,
worktreeStates = worktreeStates,
onOpenClaude = onOpenClaude,
onViewDiff = onViewDiff,
)
@@ -120,6 +144,11 @@ private fun DetailBody(
worktree: WorktreeViewModel?,
onOpenClaude: (String) -> Unit,
onViewDiff: (String) -> Unit = {},
fetch: ProjectDetailViewModel.FetchPhase = ProjectDetailViewModel.FetchPhase.Idle,
canFetch: Boolean = false,
onFetch: () -> Unit = {},
onDismissFetchBanner: () -> Unit = {},
worktreeStates: Map<String, WorktreeRowState> = emptyMap(),
) {
Column(
modifier = Modifier
@@ -139,19 +168,34 @@ private fun DetailBody(
modifier = Modifier.weight(1f),
)
detail.branch?.let { Text(text = it, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) }
if (detail.dirty == true) Text(text = "", color = WebTermColors.statusWaiting)
// w6/G3: a count beats a bare dot — "how many uncommitted" is the question the dot dodged.
headerDirtyChip(detail.dirtyCount, detail.dirty)?.let {
Text(text = it, style = WebTermType.metaMono, color = WebTermColors.statusWaiting)
}
}
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
SyncBandCard(
// On a detached HEAD there is no branch to show, so the short sha stands in for it; the
// current worktree row is where the server reports it.
band = syncBandOf(detail.sync, head = detail.worktrees.firstOrNull { it.isCurrent }?.head),
fetch = fetch,
canFetch = canFetch,
onFetch = onFetch,
onDismissFetchBanner = onDismissFetchBanner,
)
PrChipRow(prChip)
if (detail.isGit && worktree != null) {
WorktreeSection(detail = detail, worktree = worktree)
WorktreeSection(detail = detail, worktree = worktree, worktreeStates = worktreeStates)
} else {
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size))
if (!detail.isGit) EmptyLine("不是 git 仓库。")
else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
else for (w in detail.worktrees) WorktreeRow(w, onRemove = null)
else for (w in detail.worktrees) {
WorktreeRow(w, onRemove = null, state = worktreeStateOf(w, detail, worktreeStates))
}
}
val running = detail.sessions.filter { !it.exited }
@@ -173,6 +217,134 @@ private fun DetailBody(
}
}
// ── w6/G3: the sync band ─────────────────────────────────────────────────────────────────────────
/**
* The header sync band: upstream · ↑ahead · ↓behind · Fetch. Stacked (not a 4-column row) because a
* phone is narrow and the mock's desktop row collapses to a column at this width anyway.
*
* **The one design rule:** only [SyncBand.isAllClear] paints green. A stale `↓0` carries the
* "存疑" chip on the NUMBER (↑ never does — it needs only local refs), and a branch with no upstream
* says so in words. Absent facts render nothing at all.
*/
@Composable
private fun SyncBandCard(
band: SyncBand?,
fetch: ProjectDetailViewModel.FetchPhase,
canFetch: Boolean,
onFetch: () -> Unit,
onDismissFetchBanner: () -> Unit,
) {
if (band == null) return
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
when (band) {
is SyncBand.Detached -> SyncCell(GitPanelCopy.HEAD_CAPTION) {
Text(
text = band.head?.let { "${GitPanelCopy.DETACHED_CHIP} @ $it" } ?: GitPanelCopy.DETACHED_CHIP,
style = WebTermType.metaMono,
color = WebTermColors.statusStuck,
)
}
SyncBand.NoUpstream -> {
SyncCell(GitPanelCopy.UPSTREAM_CAPTION) {
Text(text = GitPanelCopy.NO_UPSTREAM_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusStuck)
}
EmptyLine(GitPanelCopy.NO_UPSTREAM_NOTE)
}
is SyncBand.Tracking -> TrackingCells(band)
}
FetchRow(
band = band,
fetch = fetch,
canFetch = canFetch,
onFetch = onFetch,
onDismissFetchBanner = onDismissFetchBanner,
)
}
}
}
@Composable
private fun TrackingCells(band: SyncBand.Tracking) {
SyncCell(GitPanelCopy.UPSTREAM_CAPTION) {
// Upstream is a server string (a remote-tracking ref name) → inert text.
Text(text = band.upstream, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
SyncCell(GitPanelCopy.TO_PUSH_CAPTION) {
Text(
text = GitPanelCopy.aheadChip(band.ahead),
style = WebTermType.metaMono,
color = if (band.ahead > 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (band.isAllClear) {
Text(text = GitPanelCopy.IN_SYNC_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusWorking)
}
}
EmptyLine(if (band.ahead == 0) GitPanelCopy.NOTHING_TO_PUSH_NOTE else GitPanelCopy.aheadNote(band.ahead))
SyncCell(GitPanelCopy.TO_PULL_CAPTION) {
Text(
text = GitPanelCopy.behindChip(band.behind),
style = WebTermType.metaMono,
// Behind is read off a CACHED remote ref: dim the digit itself when it cannot be trusted.
color = if (band.isStale) WebTermColors.statusStuck else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (band.isStale) {
Text(text = GitPanelCopy.STALE_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusStuck)
}
}
EmptyLine(
when {
band.isStale && band.lastFetchMs == null -> GitPanelCopy.NEVER_FETCHED_NOTE
band.isStale -> GitPanelCopy.STALE_NOTE
band.behind == 0 -> GitPanelCopy.UP_TO_DATE_NOTE
else -> GitPanelCopy.WAITING_ON_REMOTE_NOTE
},
)
}
@Composable
private fun SyncCell(caption: String, value: @Composable () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text(
text = caption,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
value()
}
}
@Composable
private fun FetchRow(
band: SyncBand,
fetch: ProjectDetailViewModel.FetchPhase,
canFetch: Boolean,
onFetch: () -> Unit,
onDismissFetchBanner: () -> Unit,
) {
val working = fetch == ProjectDetailViewModel.FetchPhase.Working
// Fetch only moves remote-tracking refs — never a pull, never a merge. Disabled on a detached
// HEAD (nothing to compare) and while one is in flight (a second tap is dropped, not queued).
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(enabled = canFetch && band.canFetch && !working, onClick = onFetch) {
Text(GitPanelCopy.FETCH)
}
when (fetch) {
ProjectDetailViewModel.FetchPhase.Idle -> if (!band.canFetch) {
EmptyLine(GitPanelCopy.FETCH_DISABLED_DETACHED)
}
ProjectDetailViewModel.FetchPhase.Working -> EmptyLine(GitPanelCopy.FETCH_WORKING)
is ProjectDetailViewModel.FetchPhase.Done ->
BannerLine(GitPanelCopy.FETCH_DONE, WebTermColors.statusWorking, onDismissFetchBanner)
is ProjectDetailViewModel.FetchPhase.Failed ->
BannerLine(fetch.message, WebTermColors.statusStuck, onDismissFetchBanner)
}
}
}
// ── PR + CI chip (link only when https) ──────────────────────────────────────────────────────────
@Composable
@@ -230,18 +402,30 @@ private fun isHttpsUrl(url: String): Boolean =
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
@Composable
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
private fun WorktreeSection(
detail: ProjectDetail,
worktree: WorktreeViewModel,
worktreeStates: Map<String, WorktreeRowState>,
) {
val scope = rememberCoroutineScope()
val phase by worktree.phase.collectAsStateWithLifecycle()
var branch by remember { mutableStateOf("") }
var base by remember { mutableStateOf("") }
var removeTarget by remember { mutableStateOf<WorktreeInfo?>(null) }
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
// w6/G5: ALWAYS "Worktrees (n)" — one worktree per session makes the count the point, and a
// section that renames itself to "Branch" at n=1 hides the whole model.
SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size))
if (detail.worktrees.isEmpty()) {
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
} else {
for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w })
for (w in detail.worktrees) {
WorktreeRow(
worktree = w,
state = worktreeStateOf(w, detail, worktreeStates),
onRemove = { if (!w.isMain) removeTarget = w },
)
}
}
// New-worktree inline form.
@@ -337,31 +521,77 @@ private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
when (recent) {
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
ProjectDetailViewModel.RecentCommits.Loading -> {
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("正在读取提交记录…")
}
ProjectDetailViewModel.RecentCommits.Unavailable -> {
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("提交记录不可用。")
}
is ProjectDetailViewModel.RecentCommits.Loaded -> {
SectionTitle("最近提交")
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
else for (commit in recent.result.commits) CommitRow(commit)
is ProjectDetailViewModel.RecentCommits.Loaded -> CommitList(recent.result)
}
}
/**
* The commit list with w6/G4's unpushed marking: a marked row carries `↑` and the accent, and the
* upstream boundary is drawn EXACTLY ONCE, right after the last marked row — it is the real position
* of `origin/<branch>` on this timeline, so it is only drawn when there is an upstream to name.
*/
@Composable
private fun CommitList(log: GitLogResult) {
val boundaryLabel = log.boundaryLabel()
val boundaryIndex = log.boundaryIndex()
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
SectionTitle(GitPanelCopy.RECENT_COMMITS)
// The count belongs ON the heading: it qualifies "recent commits", it is not its own statement.
if (log.unpushedCount > 0 && boundaryLabel != null) {
Text(
text = GitPanelCopy.unpushedBadge(log.unpushedCount),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (log.commits.isEmpty()) {
EmptyLine("暂无提交。")
return
}
log.commits.forEachIndexed { index, commit ->
CommitRow(commit)
if (index == boundaryIndex && boundaryLabel != null) UpstreamBoundary(boundaryLabel)
}
if (log.truncated) EmptyLine(GitPanelCopy.truncatedNote(log.commits.size))
}
@Composable
private fun UpstreamBoundary(upstream: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
// Inert: `upstream` is a server-supplied ref name.
Text(text = upstream, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outline,
thickness = Stroke.hairline,
)
}
}
@Composable
private fun CommitRow(commit: CommitLogEntry) {
val unpushed = commit.isUnpushed()
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
Text(
text = commit.hash.take(7),
text = if (unpushed) GitPanelCopy.UNPUSHED_MARK else " ",
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = commit.hash.take(SHORT_HASH_LENGTH),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = commit.subject,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurface,
color = if (unpushed) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
@@ -369,25 +599,59 @@ private fun CommitRow(commit: CommitLogEntry) {
}
}
/** `git log --format=%h` width — the same 7 the rest of the cockpit shows. */
private const val SHORT_HASH_LENGTH = 7
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
/**
* One worktree row: identity + state chips on top, path underneath (on one line the path and the
* chips competed for width and whichever lost got truncated).
*
* The chips come from [worktreeChips], which deliberately has no green "clean" chip: only a fresh
* `↑0 ↓0` earns green, and "no upstream" — the normal state of a fresh worktree branch — is a warning.
*/
@Composable
private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) {
private fun WorktreeRow(
worktree: WorktreeInfo,
onRemove: (() -> Unit)?,
state: WorktreeRowState? = null,
) {
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), verticalAlignment = Alignment.CenterVertically) {
Text(text = label, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
if (worktree.isMain) Tag("main")
if (worktree.isCurrent) Tag("current")
if (worktree.locked == true) Tag("locked")
for (chip in worktreeChips(worktree, state)) Tag(chip.label, chip.tone)
if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") }
}
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (state != null && state.sessionCount > 0) {
Text(
text = GitPanelCopy.sessionCount(state.sessionCount),
style = WebTermType.metaMono,
color = WebTermColors.statusWorking,
)
}
}
}
}
/**
* Per-row git state. The CURRENT worktree's state is the project's own — free, already loaded. Other
* rows need the w6/G7 per-worktree probe (`GET /projects/worktree/state`), which this client does not
* have a route for yet, so they stay unprobed and show no sync chip rather than a guess.
*/
private fun worktreeStateOf(
worktree: WorktreeInfo,
detail: ProjectDetail,
probed: Map<String, WorktreeRowState>,
): WorktreeRowState? {
if (!worktree.isCurrent) return probed[worktree.path]
if (detail.sync == null && detail.dirtyCount == null) return null
return WorktreeRowState(sync = detail.sync, dirtyCount = detail.dirtyCount)
}
@Composable
private fun SessionRow(session: ProjectSessionRef) {
WebTermCard(modifier = Modifier.fillMaxWidth()) {
@@ -413,8 +677,15 @@ private fun ClaudeMdBlock(text: String) {
}
@Composable
private fun Tag(text: String) {
Text(text = text, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
private fun Tag(text: String, tone: GitChipTone = GitChipTone.ACCENT) {
val color = when (tone) {
GitChipTone.NEUTRAL -> MaterialTheme.colorScheme.onSurfaceVariant
GitChipTone.ACCENT -> MaterialTheme.colorScheme.primary
GitChipTone.WARN -> WebTermColors.statusStuck
GitChipTone.DIRTY -> WebTermColors.statusWaiting
GitChipTone.OK -> WebTermColors.statusWorking
}
Text(text = text, style = WebTermType.metaMono, color = color)
}
@Composable
@@ -452,16 +723,27 @@ private fun Centered(content: @Composable () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() }
}
@Preview(name = "ProjectDetailScreen — loaded")
@Preview(name = "ProjectDetailScreen — git panel (stale ↓, 9 to push)")
@Composable
private fun ProjectDetailScreenPreview() {
val detail = ProjectDetail(
name = "Acme.Web.frontend",
path = "/home/dev/acme-web",
isGit = true,
branch = "main",
branch = "develop",
dirty = true,
worktrees = emptyList(),
dirtyCount = 3,
// The panel's headline case: 9 to push, and a ↓0 that is 19 days old — so NOT green.
sync = SyncState(
upstream = "origin/develop",
ahead = 9,
behind = 0,
lastFetchMs = 1L,
),
worktrees = listOf(
WorktreeInfo(path = "/home/dev/acme-web", branch = "develop", isMain = true, isCurrent = true),
WorktreeInfo(path = "/home/dev/acme-web/.claude/worktrees/x", branch = "worktree-x"),
),
sessions = emptyList(),
hasClaudeMd = true,
claudeMd = "# CLAUDE.md\n\nProject instructions…",
@@ -471,13 +753,21 @@ private fun ProjectDetailScreenPreview() {
detail = detail,
prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)),
recent = ProjectDetailViewModel.RecentCommits.Loaded(
wang.yaojia.webterm.api.models.GitLogResult(
commits = listOf(CommitLogEntry("abc1234", 1, "Initial commit")),
GitLogResult(
commits = listOf(
CommitLogEntry("553a00c1", 1, "docs(progress): log the leftover fixes", unpushed = true),
CommitLogEntry("1dbed541", 1, "feat(control-panel): web admin UI"),
),
truncated = false,
upstream = "origin/develop",
),
),
worktree = null,
onOpenClaude = {},
canFetch = true,
worktreeStates = mapOf(
"/home/dev/acme-web/.claude/worktrees/x" to WorktreeRowState(sync = SyncState(), sessionCount = 1),
),
)
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -37,20 +39,31 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.launch
import wang.yaojia.webterm.components.PointerContextMenu
import wang.yaojia.webterm.components.SessionListRow
import wang.yaojia.webterm.components.SessionThumbnails
import wang.yaojia.webterm.components.sessionRowMenuActions
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.nav.LayoutMode
import wang.yaojia.webterm.viewmodels.HostOption
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
import wang.yaojia.webterm.viewmodels.SessionListUiState
import wang.yaojia.webterm.viewmodels.SessionListViewModel
import wang.yaojia.webterm.viewmodels.SessionRow
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
import wang.yaojia.webterm.wiring.AppEnvironment
import java.util.UUID
/**
@@ -81,6 +94,20 @@ import java.util.UUID
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
* @param thumbnails off-screen preview seam; production wires it to the active host's
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles.
* @param layoutMode the adaptive layout from [wang.yaojia.webterm.nav.LayoutPolicy]; it gates the
* per-row pointer context menu (A26) via
* [PointerMenuPolicy][wang.yaojia.webterm.nav.PointerMenuPolicy] inside [PointerContextMenu]. The
* [LayoutMode.STACK] default means a caller that does not pass it gets today's phone behaviour.
* @param onNewSessionInCwd pointer-menu「在当前目录开新会话」→ `attach(null, cwd)` in the row's cwd.
* `null` (the default) drops that entry.
* @param watermarkStore the DURABLE unread-watermark home, installed into [viewModel] before its first
* fetch so the dot survives process death. Defaults to the app-graph store
* ([rememberAppEnvironment]); a test/preview passes an
* [InMemoryUnreadWatermarkStore][wang.yaojia.webterm.viewmodels.InMemoryUnreadWatermarkStore].
* @param hostRemoval the un-pair path (`HostRemover`), likewise installed into [viewModel]. `null`
* removes the affordance entirely, which is the right behaviour for a surface that cannot un-pair.
* @param onForeground fired on every STARTED transition — production runs the silent certificate
* rotation pass here (the app's landing surface is the reliable "we are in the foreground" edge).
*/
@Composable
public fun SessionListScreen(
@@ -92,14 +119,28 @@ public fun SessionListScreen(
onImportCert: () -> Unit,
modifier: Modifier = Modifier,
thumbnails: SessionThumbnails? = null,
layoutMode: LayoutMode = LayoutMode.STACK,
onNewSessionInCwd: ((String) -> Unit)? = null,
watermarkStore: UnreadWatermarkStore? = rememberAppEnvironment()?.unreadWatermarkStore,
hostRemoval: HostRemovalGateway? = rememberAppEnvironment()?.hostRemoval,
onForeground: () -> Unit = rememberCertificateRotationTrigger(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current
var confirmingRemove by remember { mutableStateOf(false) }
// STARTED-scoped poll: fetch→wait→repeat while foregrounded, cancelled on background (plan §6.6).
LaunchedEffect(viewModel, lifecycleOwner) {
// The app-scoped collaborators are installed FIRST, inside the same effect, so the install is
// provably ordered before `poll()`'s first ledger read (a separate LaunchedEffect would only be
// ordered by composition order — true today, but not a property worth depending on).
LaunchedEffect(viewModel, lifecycleOwner, watermarkStore, hostRemoval) {
watermarkStore?.let(viewModel::useWatermarkStore)
hostRemoval?.let(viewModel::useRemovalGateway)
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Every foreground return: rotate the device certificate if it is due (idempotent, and
// NotDue costs one store read). Fire-and-forget — it must never delay the list.
onForeground()
viewModel.poll()
}
}
@@ -114,6 +155,7 @@ public fun SessionListScreen(
onPairHost = onPairHost,
onEnroll = onEnroll,
onImportCert = onImportCert,
onRemoveHost = if (hostRemoval != null) ({ confirmingRemove = true }) else null,
)
},
) { padding ->
@@ -129,8 +171,45 @@ public fun SessionListScreen(
onNewSession = onNewSession,
onPairHost = onPairHost,
thumbnails = thumbnails,
layoutMode = layoutMode,
onNewSessionInCwd = onNewSessionInCwd,
onDismissRemoveError = viewModel::dismissRemoveError,
)
}
if (confirmingRemove) {
RemoveHostConfirmDialog(
hostName = state.hosts.firstOrNull { it.isActive }?.name.orEmpty(),
onConfirm = {
confirmingRemove = false
scope.launch { viewModel.removeActiveHost() }
},
onDismiss = { confirmingRemove = false },
)
}
}
/**
* Confirm-gate for un-pairing (plan §8): removal deletes this device's push registration ON the host and
* erases the paired record, the last-session pointer, the saved access cookie and the unread watermarks.
* It is not destructive to the host's SESSIONS — they keep running — and the copy says exactly that so
* the user is not guessing. The device certificate is app-wide and deliberately survives.
*/
@Composable
private fun RemoveHostConfirmDialog(hostName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("移除主机") },
text = {
Text(
// hostName is user-entered at pairing time; rendered inert like every other stored string.
text = "将移除「$hostName」的配对信息、访问凭证与推送注册(该主机不会再向本机推送通知)。" +
"主机上的会话会继续运行;设备证书不会被删除。",
)
},
confirmButton = { TextButton(onClick = onConfirm) { Text("移除") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
)
}
// ── Top bar + host menu ──────────────────────────────────────────────────────────────────────────────
@@ -143,6 +222,7 @@ private fun SessionListTopBar(
onPairHost: () -> Unit,
onEnroll: () -> Unit,
onImportCert: () -> Unit,
onRemoveHost: (() -> Unit)?,
) {
var menuOpen by remember { mutableStateOf(false) }
val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话"
@@ -162,13 +242,20 @@ private fun SessionListTopBar(
onPairHost = { menuOpen = false; onPairHost() },
onEnroll = { menuOpen = false; onEnroll() },
onImportCert = { menuOpen = false; onImportCert() },
onRemoveHost = onRemoveHost?.let { remove -> { menuOpen = false; remove() } },
)
}
},
)
}
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */
/**
* The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions.
*
* 「移除此主机」acts on the ACTIVE host only — the un-pair has to drop the unread watermarks of that
* host's sessions, and those ids are only known for the host currently listed. It is confirm-gated by the
* caller, and hidden entirely when no removal path is wired.
*/
@Composable
private fun HostMenu(
expanded: Boolean,
@@ -178,6 +265,7 @@ private fun HostMenu(
onPairHost: () -> Unit,
onEnroll: () -> Unit,
onImportCert: () -> Unit,
onRemoveHost: (() -> Unit)?,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
for (host in hosts) {
@@ -193,6 +281,13 @@ private fun HostMenu(
// 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS.
DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll)
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
if (onRemoveHost != null && hosts.any { it.isActive }) {
HorizontalDivider()
DropdownMenuItem(
text = { Text(text = "移除此主机", color = MaterialTheme.colorScheme.error) },
onClick = onRemoveHost,
)
}
}
}
@@ -208,32 +303,116 @@ private fun SessionListBody(
onNewSession: () -> Unit,
onPairHost: () -> Unit,
thumbnails: SessionThumbnails?,
layoutMode: LayoutMode,
onNewSessionInCwd: ((String) -> Unit)?,
onDismissRemoveError: () -> Unit,
) {
androidx.compose.material3.pulltorefresh.PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize().padding(contentPadding),
) {
when {
state.activeHostId == null ->
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
state.rows.isEmpty() ->
EmptyState(
message = if (state.hasLoadError) "无法加载会话列表。下拉重试。" else "该主机没有运行中的会话。",
actionLabel = "开新会话",
onAction = onNewSession,
)
else -> SessionList(state = state, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails)
Column(modifier = Modifier.fillMaxSize()) {
// The un-pair failed and NOTHING was removed (all-or-nothing) — say so above the live list.
if (state.hasRemoveError) RemoveErrorBanner(onDismiss = onDismissRemoveError)
Box(modifier = Modifier.weight(1f)) {
when {
state.activeHostId == null ->
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
state.rows.isEmpty() ->
EmptyState(
message = if (state.hasLoadError) {
"无法加载会话列表。下拉重试。"
} else {
"该主机没有运行中的会话。"
},
actionLabel = "开新会话",
onAction = onNewSession,
)
else -> SessionList(
state = state,
onOpen = onOpen,
onKill = onKill,
thumbnails = thumbnails,
layoutMode = layoutMode,
onNewSessionInCwd = onNewSessionInCwd,
)
}
}
}
}
}
/** "nothing was removed" notice — the host is still paired and the action is safe to retry. */
@Composable
private fun RemoveErrorBanner(onDismiss: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8))
.padding(Spacing.md12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(
text = "移除主机失败,未做任何更改。可以再试一次。",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) { Text("知道了") }
}
}
// ── The Hilt escape hatch for app-scoped collaborators (see TerminalScreen's palette store) ─────────────
/**
* The app-scoped [AppEnvironment], resolved from the Hilt `SingletonComponent` — the standard escape
* hatch for a Composable, which cannot take constructor injection. Returns `null` outside a Hilt
* application (a `@Preview` or a plain-Compose test host), so those surfaces degrade to no durable
* watermarks and no un-pair affordance instead of crashing.
*
* Resolving it is `Main`-safe: every heavy collaborator inside is behind `dagger.Lazy` and the instance
* already exists by the time any screen composes (`MainActivity` field-injects it in `onCreate`).
*/
@Composable
internal fun rememberAppEnvironment(): AppEnvironment? {
val application = LocalContext.current.applicationContext
return remember(application) {
runCatching {
EntryPointAccessors
.fromApplication(application, AppEnvironmentEntryPoint::class.java)
.appEnvironment()
}.getOrNull()
}
}
/**
* The default `onForeground` action: run one silent certificate-rotation pass if due. A no-op outside a
* Hilt application. Fire-and-forget by construction — [AppEnvironment.runCertificateRotationIfDue]
* launches on its own app-scoped IO scope, so nothing here can delay the session list.
*/
@Composable
internal fun rememberCertificateRotationTrigger(): () -> Unit {
val env = rememberAppEnvironment()
return remember(env) { { env?.runCertificateRotationIfDue() } }
}
/** Reads the app-scoped [AppEnvironment] for [rememberAppEnvironment]. */
@EntryPoint
@InstallIn(SingletonComponent::class)
internal interface AppEnvironmentEntryPoint {
public fun appEnvironment(): AppEnvironment
}
@Composable
private fun SessionList(
state: SessionListUiState,
onOpen: (UUID) -> Unit,
onKill: (UUID) -> Unit,
thumbnails: SessionThumbnails?,
layoutMode: LayoutMode,
onNewSessionInCwd: ((String) -> Unit)?,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -244,7 +423,14 @@ private fun SessionList(
item(key = "load-error") { LoadErrorBanner() }
}
items(items = state.rows, key = { it.id.toString() }) { row ->
SwipeToKillRow(row = row, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails)
SwipeToKillRow(
row = row,
onOpen = onOpen,
onKill = onKill,
thumbnails = thumbnails,
layoutMode = layoutMode,
onNewSessionInCwd = onNewSessionInCwd,
)
}
}
}
@@ -253,6 +439,11 @@ private fun SessionList(
* One row wrapped in a trailing (end→start) [SwipeToDismissBox]. Swiping left confirms the kill, which the
* ViewModel handles optimistically (the row leaves the list on the next [state] emission). A start→end
* swipe is disabled so a left-handed drag can't accidentally kill.
*
* On a large screen the row is ALSO wrapped in [PointerContextMenu] (A26), giving mouse/trackpad users the
* same three actions without a swipe: 打开会话 / 在当前目录开新会话 / 终止会话. The wrapper is a transparent
* pass-through whenever [PointerMenuPolicy][wang.yaojia.webterm.nav.PointerMenuPolicy] says no, so the
* phone path is byte-for-byte unchanged.
*/
@Composable
private fun SwipeToKillRow(
@@ -260,6 +451,8 @@ private fun SwipeToKillRow(
onOpen: (UUID) -> Unit,
onKill: (UUID) -> Unit,
thumbnails: SessionThumbnails?,
layoutMode: LayoutMode,
onNewSessionInCwd: ((String) -> Unit)?,
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
@@ -276,7 +469,17 @@ private fun SwipeToKillRow(
enableDismissFromStartToEnd = false,
backgroundContent = { KillBackground() },
) {
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
PointerContextMenu(
mode = layoutMode,
actions = sessionRowMenuActions(
row = row,
onOpen = onOpen,
onNewSessionInCwd = onNewSessionInCwd,
onKill = onKill,
),
) {
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
}
}
}

View File

@@ -3,8 +3,9 @@ package wang.yaojia.webterm.screens
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.view.View
import android.view.WindowManager
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -25,6 +26,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -39,23 +41,45 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.components.AwayDigestView
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.components.DataStoreQuickReplyStore
import wang.yaojia.webterm.components.GateBanner
import wang.yaojia.webterm.components.HardwareKeyRouter
import wang.yaojia.webterm.components.KeyBar
import wang.yaojia.webterm.components.PlanGateSheet
import wang.yaojia.webterm.components.QueueBadge
import wang.yaojia.webterm.components.QueuePanel
import wang.yaojia.webterm.components.QuickReply
import wang.yaojia.webterm.components.QuickReplyPalette
import wang.yaojia.webterm.components.QuickReplyPaletteEditor
import wang.yaojia.webterm.components.QuickReplyStore
import wang.yaojia.webterm.components.ReconnectBanner
import wang.yaojia.webterm.components.TelemetryChips
import wang.yaojia.webterm.designsystem.LocalReduceMotion
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.terminalview.RemoteTerminalView
import wang.yaojia.webterm.terminalview.TerminalCopyResult
import wang.yaojia.webterm.viewmodels.QueueGateway
import wang.yaojia.webterm.viewmodels.QueueUiState
import wang.yaojia.webterm.viewmodels.QueueViewModel
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wiring.RetainedSessionHolder
import wang.yaojia.webterm.wiring.TerminalSessionControllerImpl
import wang.yaojia.webterm.wiring.UiConfigGate
import java.util.UUID
/**
* The arguments to open a NEW session — the pure output of the "new session in current cwd" decision.
@@ -80,10 +104,11 @@ public object NewSessionInCwd {
*
* Hosts the forked Termux emulator ([RemoteTerminalView]) via an [AndroidView] and wires it to the
* holder-owned [TerminalSessionControllerImpl]. Structure top→bottom: a toolbar (sanitized title +
* "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) + telemetry chips
* ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with the privacy
* cover), the [KeyBar] pinned above the IME, and — for plan gates — the [PlanGateSheet]
* `ModalBottomSheet` overlay. Every server-derived string is rendered INERT by its component (§8).
* 「快捷回复」editor + "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) +
* telemetry chips ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with
* the privacy cover), the [QuickReply] chip row (A25 — floats only while a gate is held), the [KeyBar]
* pinned above the IME, and — for plan gates — the [PlanGateSheet] `ModalBottomSheet` overlay. Every
* server-derived string is rendered INERT by its component (§8).
*
* The Compose / AndroidView / lifecycle / privacy-shade / haptic behaviour here is DEVICE-QA (plan §7);
* the banner reduction, gate stale-guard and new-session-in-cwd routing are the JVM-tested cores.
@@ -106,8 +131,16 @@ public object NewSessionInCwd {
* cycle rebuilds a fresh emulator (ring replay), while a rotation re-binds the survivor.
* `holder.onStop(isChangingConfigurations)` drives that split.
* - `FLAG_SECURE` + a cover Composable on `ON_STOP` keep terminal bytes out of the recents snapshot (§8).
* - Latest-writer-wins resize: `ON_RESUME` and window-focus re-send the remembered dims via
* `notifyForegrounded` so the active device reclaims full-screen (plan §6.4).
*
* ### Latest-writer-wins resize (plan §6.4) — who pushes what
* The grid itself is measured and pushed by `:terminal-view` (`RemoteTerminalHostView.onSizeChanged` →
* `TerminalResizeDriver` → `RemoteTerminalSession.updateSize` → `Resize` on the wire), so this screen does
* NOT compute cols×rows and must not re-assert a size the view just pushed. What the view cannot see is
* "this device became the active one again": `ON_RESUME` and a window-focus GAIN therefore call
* `notifyForegrounded`, which re-sends the engine's remembered dims unconditionally (no dedup) and so wins
* the shared PTY size back from whichever device wrote last. The size-changed outlet is only forwarded
* while the session is NOT connected, where the same call is the connect-now nudge — see
* [TerminalReclaimPolicy] for the decision table (JVM-tested).
*
* @param holder the nav-scoped, config-surviving session holder (`hiltViewModel()` at the destination).
* @param onNewSessionInCwd nav callback that opens a new session for the produced [NewSessionRequest].
@@ -116,6 +149,14 @@ public object NewSessionInCwd {
* the current session (default no-op keeps the screen usable standalone). This only WIRES the existing
* [AwayDigestView.onExpand][wang.yaojia.webterm.components.AwayDigestView] callback outward; no internal
* logic changes.
* @param quickReplyStore the A25 palette store. Defaults to the app-graph one
* ([rememberAppQuickReplyStore] — the SAME `DataStore<Preferences>` singleton every other store uses, so
* the palette really persists); a test/preview passes an
* [InMemoryQuickReplyStore][wang.yaojia.webterm.components.InMemoryQuickReplyStore].
* @param queueGateway the W2 follow-up-queue seam for this host. `null` hides the queue affordance
* entirely (a surface with no way to reach the host cannot queue).
* @param uiConfigGate the host's `GET /config/ui` policy. `null` leaves `allowAutoMode` at its
* fail-closed `false`, so "approve + auto-accept" is never offered on an unknown host.
*/
@Composable
public fun TerminalScreen(
@@ -127,6 +168,9 @@ public fun TerminalScreen(
modifier: Modifier = Modifier,
onWarmUp: suspend () -> Unit = {},
onDigestExpand: () -> Unit = {},
quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(),
queueGateway: QueueGateway? = rememberAppQueueGateway(endpoint),
uiConfigGate: UiConfigGate? = rememberAppEnvironment()?.uiConfigGate,
) {
val context = LocalContext.current
val activity = remember(context) { context.findActivity() }
@@ -192,13 +236,15 @@ public fun TerminalScreen(
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, activity, controller) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> {
when {
event == Lifecycle.Event.ON_STOP -> {
covered = true
holder.onStop(activity?.isChangingConfigurations == true)
}
Lifecycle.Event.ON_START -> covered = false
Lifecycle.Event.ON_RESUME -> controller.notifyForegrounded(null, null)
event == Lifecycle.Event.ON_START -> covered = false
// §6.4 reclaim: the engine re-sends its remembered dims with NO dedup, so the returning
// device takes the shared PTY size back even though nothing about it changed.
TerminalReclaimPolicy.reclaimsOnLifecycle(event) -> controller.notifyForegrounded(null, null)
else -> Unit
}
}
@@ -210,10 +256,47 @@ public fun TerminalScreen(
val windowInfo = LocalWindowInfo.current
LaunchedEffect(windowInfo, controller) {
snapshotFlow { windowInfo.isWindowFocused }.collect { focused ->
if (focused) controller.notifyForegrounded(null, null)
if (TerminalReclaimPolicy.reclaimsOnWindowFocus(focused)) controller.notifyForegrounded(null, null)
}
}
// `GET /config/ui` → may this host be put into auto-accept-edits? Fetched once per host and pushed
// into the gate presenter, which drops an ACCEPT_EDITS decision while it is false. FAIL CLOSED: a
// failed/absent fetch never calls this, so the presenter keeps its `false` default and the sheet's
// third affordance is not offered at all (see the plan-gate branch below).
LaunchedEffect(gateViewModel, uiConfigGate, endpoint) {
val gate = uiConfigGate ?: return@LaunchedEffect
// Off-Main: the first touch resolves the shared mTLS client (AndroidKeyStore/Tink reads).
val allowed = withContext(Dispatchers.IO) { gate.allowsAutoMode(endpoint) }
if (allowed) gateViewModel.setAutoModeAllowed(true)
}
// W2 follow-up queue. The presenter is composition-scoped (it holds only HTTP-derived state), while the
// authoritative DEPTH rides the retained gate presenter's control mailbox — so a rotation cannot leak a
// mailbox or lose the badge. A freshly spawned session has no id until the server adopts one.
val queueVm = remember(queueGateway) { queueGateway?.let { QueueViewModel(it) } }
// Remembered UNCONDITIONALLY (a `remember` inside an elvis branch is a conditional composable call)
// so the stand-in for "no queue on this host" is a stable flow, not a per-recomposition instance.
val noQueueState = remember { MutableStateFlow(QueueUiState()) }
val queueUi by (queueVm?.uiState ?: noQueueState).collectAsStateWithLifecycle()
val liveSessionId = remember(sessionId, gateUi.sessionId) {
(sessionId ?: gateUi.sessionId)?.let { raw -> runCatching { UUID.fromString(raw) }.getOrNull() }
}
// The live `queue` frame is the authoritative depth; hand it to the panel presenter so its badge and
// its item list can never disagree with what the server just broadcast.
LaunchedEffect(queueVm, gateUi.queueDepth) {
gateUi.queueDepth?.let { depth -> queueVm?.onServerDepth(depth) }
}
var showQueuePanel by remember { mutableStateOf(false) }
val queueScope = rememberCoroutineScope()
// A25 quick-reply palette: one remembered presenter over the injected store, loaded once per store.
val palette = remember(quickReplyStore) { QuickReplyPalette(quickReplyStore) }
val quickReplyChips by palette.chips.collectAsStateWithLifecycle()
val paletteScope = rememberCoroutineScope()
var showPaletteEditor by remember { mutableStateOf(false) }
LaunchedEffect(palette) { palette.load() }
val requestNewSession: () -> Unit = { onNewSessionInCwd(NewSessionInCwd.requestFor(cwd)) }
// A slow wall-clock tick so the telemetry chips grey out as the last frame ages (§ staleness).
@@ -224,16 +307,62 @@ public fun TerminalScreen(
}
}
// Copy-out (first-party selection layer in `:terminal-view`). The view handle is captured per
// `generation` so a real-background rebuild never leaves a stale reference behind.
var remoteTerminalView by remember { mutableStateOf<RemoteTerminalView?>(null) }
var isSelecting by remember { mutableStateOf(false) }
var copyNotice by remember { mutableStateOf<String?>(null) }
LaunchedEffect(copyNotice) {
if (copyNotice != null) {
delay(COPY_NOTICE_MS)
copyNotice = null
}
}
Column(modifier = modifier.fillMaxSize()) {
TerminalToolbar(title = title, onNewSessionInCwd = requestNewSession)
TerminalToolbar(
title = title,
onNewSessionInCwd = requestNewSession,
onEditQuickReplies = { showPaletteEditor = true },
queueDepth = gateUi.queueDepth,
onOpenQueue = if (queueVm != null && liveSessionId != null && queueUi.isSupported) {
{
showQueuePanel = true
queueScope.launch { queueVm.refresh(liveSessionId) }
}
} else {
null
},
// Shown only while a selection is live: an OEM whose floating toolbar misbehaves still has a
// reachable Copy, and the action is ALWAYS an explicit user gesture (§8 — terminal bytes reach
// the clipboard on no other path).
onCopySelection = if (isSelecting) {
{
copyNotice = copyResultText(remoteTerminalView?.copySelection())
remoteTerminalView?.clearSelection()
}
} else {
null
},
)
copyNotice?.let { notice -> InlineNotice(text = notice) }
ReconnectBanner(model = banner, onNewSession = requestNewSession)
// Cockpit surfaces (A22): away-digest (once per reconnect) + statusLine telemetry chips. Both hide
// themselves when their state is null/empty; server strings render inert (§8).
gateUi.digest?.let { AwayDigestView(digest = it, onExpand = onDigestExpand) } // onExpand → A28 timeline (wired)
gateUi.telemetry?.let { TelemetryChips(telemetry = it, nowMs = nowMs) }
// Tool gate → an inline approve/reject card; plan gates use the ModalBottomSheet below.
// The W1 `status.preview` MUST be passed: without it the card is a name-only bar and the user is
// approving a `Bash` call without seeing the command (the "blind approval" defect).
gateUi.gate?.takeIf { it.kind == GateKind.TOOL }?.let { toolGate ->
GateBanner(gate = toolGate, onDecide = gateViewModel::decide)
GateBanner(gate = toolGate, onDecide = gateViewModel::decide, preview = gateUi.preview)
}
// A plan gate on a host that FORBIDS auto-accept-edits degrades to the same two-way card: only
// 批准 (approve + review) and 拒绝 (keep planning) are real affordances there, so offering the
// three-way sheet would advertise a decision the host will not honour. The authoritative drop
// lives in `GateViewModel.decide`; this is the "don't offer it" half.
gateUi.gate?.takeIf { it.kind == GateKind.PLAN && !gateUi.allowAutoMode }?.let { planGate ->
GateBanner(gate = planGate, onDecide = gateViewModel::decide, preview = gateUi.preview)
}
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
key(generation) {
@@ -246,38 +375,150 @@ public fun TerminalScreen(
remoteView.onKeyCommand = { keyCode, keyEvent ->
HardwareKeyRouter.handle(keyCode, keyEvent, controller::sendInput)
}
// A layout/size change feeds the latest-writer-wins reclaim path (§6.4). The pixel→
// grid metric read (Termux's package-private mFontWidth) + emulator resize is
// completed in the view layer on-device; here we reclaim full-screen.
remoteView.onViewSizeChanged = { _, _ -> controller.notifyForegrounded(null, null) }
// The grid for this size has ALREADY been measured and pushed by the view layer
// (TerminalResizeDriver → RemoteTerminalSession.updateSize → `Resize`) by the time
// this fires, so re-asserting it here would only double the frame. Forward it to
// the engine solely while disconnected, where the call is the connect-now nudge
// (§6.4). bannerState is read at CALL time — a value captured in this factory
// lambda would be frozen at first composition.
remoteView.onViewSizeChanged = { _, _ ->
if (TerminalReclaimPolicy.nudgesOnViewSizeChange(holder.bannerState.value)) {
controller.notifyForegrounded(null, null)
}
}
// Copy-out: mirror the selection state up so the toolbar can offer a Copy action.
// Stock Termux selection stays unreachable (its ActionMode dereferences the absent
// TerminalSession) — this is the first-party layer, and nothing here calls
// startTextSelectionMode.
remoteView.onSelectionChanged = { selecting -> isSelecting = selecting }
remoteTerminalView = remoteView
controller.remoteSession.attachView(remoteView)
remoteView.hostView()
// The host view IS the View to compose: :terminal-view now exposes its own
// `RemoteTerminalHostView` (a FrameLayout that owns focus + the IME contract and
// keeps the final stock Termux view away from its session-dereferencing paths), so
// no reflection is needed to avoid naming an `implementation`-scoped Termux type.
remoteView.terminalView
},
// Config change / real background: unbind the view but the holder-owned emulator
// survives (FIX 3) — never close it here (that is the holder teardown's job).
onRelease = { controller.remoteSession.detachView() },
onRelease = {
remoteTerminalView = null
isSelecting = false
controller.remoteSession.detachView()
},
)
}
if (covered) PrivacyCover()
}
// A25: the quick-reply chips float directly above the key-bar while a gate is held — the moment
// Claude is waiting is exactly when a one-tap canned reply is useful. Each tap sends the chip's
// text VERBATIM through the same ordered send pump as typing (invariant #1/#9).
QuickReply(
chips = quickReplyChips,
gateHeld = gateUi.gate != null,
onSend = controller::sendInput,
)
// The IME-bypass touch key-bar, pinned above the keyboard (hidden on wide screens / hardware kbd).
KeyBar(onSend = controller::sendInput)
}
// Plan gate → a three-way ModalBottomSheet overlay. Dismissing (scrim/back) resolves NOTHING — the
// gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in
// GateViewModel.decide.
gateUi.gate?.takeIf { it.kind == GateKind.PLAN }?.let { planGate ->
PlanGateSheet(gate = planGate, onDecide = gateViewModel::decide, onDismiss = {})
// The editable palette (A25). Every mutation goes through the store (the CRUD authority) and the
// presenter republishes what came back, so the chips above can never drift from what was persisted.
if (showPaletteEditor) {
QuickReplyPaletteEditor(
chips = quickReplyChips,
onAdd = { text -> paletteScope.launch { palette.add(text) } },
onEdit = { id, text -> paletteScope.launch { palette.edit(id, text) } },
onRemove = { id -> paletteScope.launch { palette.remove(id) } },
onMove = { from, to -> paletteScope.launch { palette.move(from, to) } },
onDismiss = { showPaletteEditor = false },
)
}
// Plan gate → a three-way ModalBottomSheet overlay, but ONLY on a host that permits auto-accept-edits
// (`GET /config/ui`); otherwise the two-way card above stands in. Dismissing (scrim/back) resolves
// NOTHING — the gate stays held until an explicit decision (PlanGateSheet contract); the epoch
// stale-guard lives in GateViewModel.decide.
gateUi.gate?.takeIf { it.kind == GateKind.PLAN && gateUi.allowAutoMode }?.let { planGate ->
PlanGateSheet(
gate = planGate,
onDecide = gateViewModel::decide,
onDismiss = {},
preview = gateUi.preview,
)
}
// W2 queue panel. Every write is one attempt with a typed outcome — the presenter never auto-retries,
// least of all a 429 (the 20/min bucket is shared with every other client of this host).
if (showQueuePanel && queueVm != null && liveSessionId != null) {
QueuePanel(
state = queueUi,
onSend = { text -> queueScope.launch { queueVm.enqueue(liveSessionId, text) } },
onClearAll = { queueScope.launch { queueVm.clearAll(liveSessionId) } },
onDismissNotice = queueVm::dismissNotice,
onDismiss = { showQueuePanel = false },
onRefresh = { queueScope.launch { queueVm.refresh(liveSessionId) } },
)
}
}
/** How long a copy-outcome notice stays on screen. */
private const val COPY_NOTICE_MS: Long = 2_000L
/**
* App-authored copy for a [TerminalCopyResult]. The copied TEXT is never rendered or logged (§8) — only
* whether the copy happened.
*/
internal fun copyResultText(result: TerminalCopyResult?): String = when (result) {
TerminalCopyResult.COPIED -> "已复制到剪贴板"
TerminalCopyResult.NOTHING_SELECTED -> "没有选中内容"
TerminalCopyResult.NOTHING_TO_COPY -> "选中的区域是空白"
TerminalCopyResult.CLIPBOARD_UNAVAILABLE -> "系统剪贴板拒绝了这次复制"
null -> "终端还没准备好"
}
/** A short-lived inert status line (copy outcome). Fixed app copy — never a server or exception string. */
@Composable
private fun InlineNotice(text: String) {
Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
)
}
}
/**
* The W2 queue seam for [endpoint], resolved from the Hilt graph (a Composable cannot be
* constructor-injected). `null` outside a Hilt application, which correctly hides the affordance in a
* `@Preview`. Remembered per endpoint so a recomposition does not mint a new presenter.
*/
@Composable
internal fun rememberAppQueueGateway(endpoint: HostEndpoint): QueueGateway? {
val env = rememberAppEnvironment()
return remember(env, endpoint) { env?.buildQueueGateway(endpoint) }
}
/** Telemetry-chip staleness tick interval (ms) — re-reads the wall clock so chips grey as a frame ages. */
private const val TELEMETRY_TICK_MS: Long = 1_000L
/** Toolbar: the inert (already-sanitized) session title + the new-session-in-cwd action. */
/**
* Toolbar: the inert (already-sanitized) session title, the quick-reply palette editor and the
* new-session-in-cwd action. The editor lives HERE rather than on the chip row because the row only
* floats while a gate is held (and hides itself when the palette is empty) — an editor reachable only
* from the row could never be used to create the first chip.
*/
@Composable
private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) {
private fun TerminalToolbar(
title: String,
onNewSessionInCwd: () -> Unit,
onEditQuickReplies: () -> Unit,
queueDepth: Int? = null,
onOpenQueue: (() -> Unit)? = null,
onCopySelection: (() -> Unit)? = null,
) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -292,6 +533,15 @@ private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) {
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
// Only while text is selected — the clipboard write is always an explicit gesture (§8).
onCopySelection?.let { copy -> TextButton(onClick = copy) { Text("复制") } }
onOpenQueue?.let { open ->
TextButton(onClick = open) {
Text("排队")
QueueBadge(depth = queueDepth, modifier = Modifier.padding(start = Spacing.xs4))
}
}
TextButton(onClick = onEditQuickReplies) { Text("快捷回复") }
TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") }
}
}
@@ -340,17 +590,65 @@ private tailrec fun Context.findActivity(): Activity? = when (this) {
}
/**
* The [android.view.View] to add to the Compose tree — the Termux `TerminalView` behind
* [RemoteTerminalView].
* WHICH app-side events must call `SessionEngine.notifyForegrounded` for the §6.4 latest-writer-wins
* reclaim — the pure, JVM-tested half of the device-switch resize path.
*
* BOUNDARY WORKAROUND (recorded): A16's `RemoteTerminalView.terminalView` is typed
* `com.termux.view.TerminalView`, but `:terminal-view` scopes the Termux `terminal-view` artifact as
* `implementation`, so that concrete type is NOT on `:app`'s compile classpath (and neither
* `app/build.gradle.kts` nor `:terminal-view`/`RemoteTerminalView` is in A21's editable lane). We
* therefore read the already-attached stock view through its public getter as a plain [View] — a
* `TerminalView` IS a `View`, so hosting it in an `AndroidView` is correct; only the compile-time type
* name is avoided. The clean fix (out of A21's lane) is to `api`-expose the Termux view from
* `:terminal-view` or add `libs.termux.terminal.view` to `:app`, then `AndroidView { remote.terminalView }`.
* A shared PTY has exactly ONE size, so the device you are actively using has to re-assert its dims to
* take that size back from whichever device wrote last. Two things make this subtler than "resend on every
* event":
* - **The view layer already owns the grid.** `RemoteTerminalHostView.onSizeChanged` →
* `TerminalResizeDriver` → `RemoteTerminalSession.updateSize` measures and pushes the real cols×rows,
* deduping sub-cell layout churn so a rotation costs exactly one `Resize` frame. Calling the engine on
* the SAME size-changed event would double every one of those frames (and re-SIGWINCH the shell).
* - **The engine call has no dedup.** `notifyForegrounded` re-sends the remembered dims unconditionally —
* which is precisely what the reclaim needs, and precisely why it must not be attached to layout churn.
*
* So the engine call is reserved for what the view cannot observe (becoming the active device again:
* `ON_RESUME`, a window-focus GAIN) plus the disconnected case, where the same call is the
* connect-now-without-resetting-the-ladder nudge.
*/
private fun RemoteTerminalView.hostView(): View =
RemoteTerminalView::class.java.getMethod("getTerminalView").invoke(this) as View
internal object TerminalReclaimPolicy {
/** `ON_RESUME` (pane-show / device-switch) is the only lifecycle event that reclaims. */
fun reclaimsOnLifecycle(event: Lifecycle.Event): Boolean = event == Lifecycle.Event.ON_RESUME
/** Focus GAIN reclaims; a blur must never resize (the v0.4 min→latest-writer pivot removed that). */
fun reclaimsOnWindowFocus(focused: Boolean): Boolean = focused
/**
* A view size change reaches the engine only while a connect is pending/retrying — there the call is
* the connect-now nudge. Connected ([BannerModel.Hidden]) the view driver has already pushed this
* exact grid; terminally failed/exited there is no reconnect owed.
*/
fun nudgesOnViewSizeChange(banner: BannerModel): Boolean =
banner is BannerModel.Connecting || banner is BannerModel.Reconnecting
}
/**
* The app-graph quick-reply store: a [DataStoreQuickReplyStore] over the SAME
* `DataStore<Preferences>` singleton the host/last-session stores use (disjoint keys, one file — two
* `DataStore` instances over one file would throw).
*
* `TerminalScreen` is a Composable, so it cannot take the store by constructor injection; the store is
* pulled off the Hilt `SingletonComponent` through an `@EntryPoint` instead (the standard Hilt escape
* hatch for exactly this case) and kept as a default parameter value so a test/preview can still pass an
* in-memory store. Nothing here widens the DI graph — the provider already exists in `di/StorageModule`.
*/
@Composable
internal fun rememberAppQuickReplyStore(): QuickReplyStore {
val application = LocalContext.current.applicationContext
return remember(application) {
DataStoreQuickReplyStore(
EntryPointAccessors
.fromApplication(application, QuickReplyStoreEntryPoint::class.java)
.preferencesDataStore(),
)
}
}
/** Reads the app-scoped Preferences `DataStore` provider for [rememberAppQuickReplyStore]. */
@EntryPoint
@InstallIn(SingletonComponent::class)
internal interface QuickReplyStoreEntryPoint {
public fun preferencesDataStore(): DataStore<Preferences>
}

View File

@@ -10,13 +10,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.session.Adopted
import wang.yaojia.webterm.session.AwayDigest
import wang.yaojia.webterm.session.Digest
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.Queued
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.session.Telemetry
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.StatusTelemetry
@@ -58,6 +61,19 @@ import wang.yaojia.webterm.wiring.TerminalSessionController
* mailbox that is INDEPENDENT of the banner's (and the A28 timeline's). Every consumer sees every
* control event; none steals from another. Capturing at construction registers the mailbox eagerly,
* before the holder calls `controller.start()`, so the first gate is never dropped.
*
* ### `allowAutoMode` fails CLOSED (`GET /config/ui`)
* `approve.mode="acceptEdits"` puts Claude into auto-accept-edits, which an operator can switch off
* host-side (`ALLOW_AUTO_MODE=0` → `GET /config/ui` `{allowAutoMode:false}`). [decide] therefore drops
* [GateDecision.ACCEPT_EDITS] unless [setAutoModeAllowed] has been told the host permits it — the
* default is `false`, so a host that is merely unreachable can never *widen* what the phone may approve.
* This is the authoritative half; the surface additionally stops OFFERING the affordance.
*
* ### It also carries the two cockpit signals nothing else owns
* The `queue` frame ([Queued]) and the adopted session id ([Adopted]) arrive on this same control
* mailbox and used to be discarded here. They live in [GateUiState] because this VM is the retained,
* rotation-surviving cockpit consumer (`RetainedSessionHolder`): a composition-scoped collector would
* both lose them on rotation and leak a fresh orphaned mailbox each time.
*/
public class GateViewModel(
private val controller: TerminalSessionController,
@@ -105,14 +121,29 @@ public class GateViewModel(
is Gate -> onGate(event.gate)
is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry)
is Digest -> onDigest(event.digest)
// The session ended: a held gate is no longer decidable — clear it so no stale card lingers.
is Exited -> _uiState.value = _uiState.value.copy(gate = null)
else -> Unit // Output / Connection / Adopted are not cockpit concerns.
// W2: the server's authoritative pending-prompt depth (never a local count).
is Queued -> _uiState.value = _uiState.value.copy(queueDepth = event.length.coerceAtLeast(0))
// The server-issued id — the only way a freshly spawned session learns what to queue against.
is Adopted -> _uiState.value = _uiState.value.copy(sessionId = event.sessionId)
// The session ended: a held gate is no longer decidable and there is no queue to show.
is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null, queueDepth = null)
else -> Unit // Output / Connection are not cockpit concerns.
}
}
/**
* Record whether this host permits `approve.mode="acceptEdits"` (`GET /config/ui`
* `{allowAutoMode}`). Called by the screen after a SUCCESSFUL fetch only — a failed fetch must leave
* the fail-closed `false` in place, because the permissive default is the dangerous one.
*/
public fun setAutoModeAllowed(allowed: Boolean) {
_uiState.value = _uiState.value.copy(allowAutoMode = allowed)
}
private fun onGate(gate: GateState?) {
_uiState.value = _uiState.value.copy(gate = gate)
// The preview is bound to the gate it describes: it arrives with it and is cleared with it, so
// a resolved gate's command/diff can never sit under the NEXT gate's buttons.
_uiState.value = _uiState.value.copy(gate = gate, preview = gate?.preview)
if (gate == null) return // Falling edge: gate lifted; keep lastArrivalEpoch so it can't refire.
// Rising edge = a NEW epoch (epochs are monotonic; a refresh keeps the same epoch).
if (gate.epoch != lastArrivalEpoch) {
@@ -133,8 +164,12 @@ public class GateViewModel(
* an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message.
*/
public fun decide(decision: GateDecision, epoch: Int) {
val held = _uiState.value.gate ?: return // canDecide line 1: no gate held → drop.
if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop.
val state = _uiState.value
val held = state.gate ?: return // canDecide line 1: no gate held → drop.
if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop.
// The host's own policy outranks the phone: auto-accept-edits is refused unless a successful
// `GET /config/ui` said it is allowed (fail closed — see the class doc).
if (decision == GateDecision.ACCEPT_EDITS && !state.allowAutoMode) return
val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop.
controller.decideGate(epoch, message)
}
@@ -152,14 +187,49 @@ public enum class GateDecision {
REJECT,
}
/**
* W1 — strip everything that could make untrusted preview text behave like anything other than text:
* every C0 control char except `\n`/`\t`, DEL, and the C1 range. ESC in particular must never reach a
* renderer — and `\n` must survive, because a shell command's line structure IS the content.
*
* The server already sanitizes (`src/http/approval-preview.ts`); this is the client's defence in
* depth, because the gate surfaces are the one place untrusted bytes sit under an approve button.
*/
public fun inertPreviewText(raw: String): String =
raw.filterNot { ch ->
val code = ch.code
(code < 0x20 && ch != '\n' && ch != '\t') || code == 0x7F || (code in 0x80..0x9F)
}
/** The sanitized command text of a [ApprovalPreview.Command], or null when nothing legible remains. */
public fun ApprovalPreview.Command.inertText(): String? = inertPreviewText(text).takeIf { it.isNotBlank() }
/** The gate/cockpit snapshot rendered by the surfaces. All fields null = nothing to show. */
public data class GateUiState(
/** The held permission gate, or `null` when none (falling edge / never risen). */
val gate: GateState? = null,
/** What the held gate would run, or `null` when the server sent no preview (a NORMAL state). */
val preview: ApprovalPreview? = null,
/** Latest statusLine telemetry, or `null` before the first `telemetry` frame. */
val telemetry: StatusTelemetry? = null,
/** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */
val digest: AwayDigest? = null,
/**
* W2 pending prompt-queue depth from the live `queue` frame — `null` = unknown (no frame yet, or the
* session exited), `0` = empty. The badge renders only for a positive value.
*/
val queueDepth: Int? = null,
/**
* The SERVER-issued session id (`attached`/`Adopted`), or `null` before the first attach. The queue
* routes are addressed by it, so a freshly spawned session (opened with a null id) can only be
* queued to once this arrives.
*/
val sessionId: String? = null,
/**
* Does this host permit `approve.mode="acceptEdits"` (`GET /config/ui` `{allowAutoMode}`)?
* **Defaults to `false` and is only ever raised by a SUCCESSFUL fetch** — fail closed.
*/
val allowAutoMode: Boolean = false,
)
/**

View File

@@ -8,14 +8,11 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.api.pairing.runPairingProbe
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport
import java.util.UUID
/**
@@ -35,13 +32,23 @@ import java.util.UUID
* 3. **Public host needs an explicit acknowledge.** A [WarningTier.PUBLIC] host cannot be probed until
* the user ticks the risk acknowledge ([confirm]`(acknowledgedPublicRisk = true)`); otherwise the
* confirm is a no-op that re-arms the reminder.
* 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** Both [confirm] and [retry]
* funnel through the ONE private [runProbe]; for a [WarningTier.TUNNEL] host with no installed
* device cert, [runProbe] refuses BEFORE any network I/O and re-refuses on every retry (plan §8
* "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed").
* 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** [confirm], [retry] and the
* post-token resume all funnel through the ONE private `performProbe`; for a [WarningTier.TUNNEL]
* host with no installed device cert it refuses BEFORE any network I/O and re-refuses on every
* retry (plan §8 "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is
* installed").
*
* On probe success the validated host is persisted via [HostStore.upsert].
*
* ### The `WEBTERM_TOKEN` gate (server `src/http/auth.ts`)
* A token-gated host answers the probe's unauthenticated `GET /live-sessions` with **401**, which the
* frozen probe taxonomy can only report as [PairingError.HttpOkButNotWebTerminal] ("端口对吗?") — so
* without this flow such a host is impossible to pair AND the copy is misleading. On that ambiguous
* failure the ViewModel asks [PairingProber.requiresAccessToken]; a gated host routes to
* [PairingUiState.TokenRequired], and [submitAccessToken] posts the token (`POST /auth`) so the shared
* cookie jar picks up the `webterm_auth` cookie, then resumes pairing through the same choke point.
* The token is a SHELL credential: it is a parameter, never a field and never part of any UI state.
*
* @param hostStore where a successfully-paired [Host] is saved.
* @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests.
* @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO).
@@ -126,38 +133,102 @@ public class PairingViewModel(
runProbe(candidate, acknowledgedPublicRisk)
}
/**
* Submit the host's shared access token (`WEBTERM_TOKEN`). Only meaningful from
* [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never post a
* credential to a host the user has moved on from.
*
* [token] is passed straight through to the prober and is NEVER stored: no field, no UI state, no
* log. On acceptance the shared cookie jar holds `webterm_auth` and pairing resumes through the same
* choke point as [confirm]/[retry] (so RULE 4's cert-gate still applies); every other outcome
* re-arms the prompt with a distinguishable reason and NEVER auto-retries (429 included).
*/
public fun submitAccessToken(token: String) {
val state = _uiState.value
if (state !is PairingUiState.TokenRequired) return
if (token.isBlank()) return
val candidate = Candidate(state.endpoint, state.name, state.tier)
val scope = scope ?: return
probeJob?.cancel()
probeJob = scope.launch {
_uiState.value = PairingUiState.TokenSubmitting(candidate.endpoint, candidate.name, candidate.tier)
when (prober.submitAccessToken(candidate.endpoint, token)) {
// Straight into the probe body: RULE 3 is already satisfied by construction (reaching the
// prompt required a probe, which for a public host required the explicit ack — and an ack
// cannot be un-given), while RULE 4's cert-gate lives INSIDE performProbe and re-applies.
TokenSubmission.Accepted -> performProbe(candidate)
TokenSubmission.Rejected -> promptForToken(candidate, TokenError.INVALID)
TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED)
is TokenSubmission.Unreachable -> promptForToken(candidate, TokenError.UNREACHABLE)
TokenSubmission.Unavailable -> promptForToken(candidate, TokenError.UNAVAILABLE)
}
}
}
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
val tier = candidate.tier
// RULE 3 — public host: no probe until the risk is explicitly acknowledged.
if (tier.needsExplicitAck && !acknowledgedPublicRisk) {
// RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the
// pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe.
if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) {
_uiState.value = PairingUiState.Confirming(
endpoint = candidate.endpoint,
name = candidate.name,
tier = tier,
tier = candidate.tier,
awaitingAck = true,
)
return
}
val scope = scope ?: return
probeJob?.cancel()
probeJob = scope.launch {
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry re-hits this same guard.
if (tier.isCertGated && !hasDeviceCert()) {
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
return@launch
}
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
when (val result = prober.probe(candidate.endpoint)) {
is PairingProbeResult.Success -> onProbeSuccess(candidate)
is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed(
endpoint = candidate.endpoint,
name = candidate.name,
tier = tier,
error = result.error,
message = PairingCopy.describe(result.error, tier),
)
}
probeJob = scope.launch { performProbe(candidate) }
}
/**
* The ONE probe body — reached from [confirm]/[retry] via [runProbe] and from an accepted access
* token. Holds RULE 4 (the cert-gate) so BOTH entry points hit it; RULE 3 (the public-risk ack) is a
* pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns
* [probeJob], which would cancel the coroutine it is called from).
*/
private suspend fun performProbe(candidate: Candidate) {
val tier = candidate.tier
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both
// re-hit this same guard.
if (tier.isCertGated && !hasDeviceCert()) {
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
return
}
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
when (val result = prober.probe(candidate.endpoint)) {
is PairingProbeResult.Success -> onProbeSuccess(candidate)
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
}
}
/**
* A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a
* `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token
* gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim.
*/
private suspend fun onProbeFailure(candidate: Candidate, error: PairingError) {
if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) {
promptForToken(candidate, error = null)
return
}
_uiState.value = PairingUiState.Failed(
endpoint = candidate.endpoint,
name = candidate.name,
tier = candidate.tier,
error = error,
message = PairingCopy.describe(error, candidate.tier),
)
}
private fun promptForToken(candidate: Candidate, error: TokenError?) {
_uiState.value = PairingUiState.TokenRequired(
endpoint = candidate.endpoint,
name = candidate.name,
tier = candidate.tier,
error = error,
)
}
private suspend fun onProbeSuccess(candidate: Candidate) {
@@ -181,20 +252,80 @@ public class PairingViewModel(
HostClassifier.hostOf(endpoint).ifEmpty { endpoint.baseUrl }
}
/** The two-step pairing probe seam ([wang.yaojia.webterm.api.pairing.runPairingProbe]); faked in tests. */
public fun interface PairingProber {
/**
* The pairing NETWORK seam: the two-step probe ([wang.yaojia.webterm.api.pairing.runPairingProbe]) plus
* the `WEBTERM_TOKEN` gate's two operations. Faked in tests; production is built by
* `AppEnvironment.buildPairingProber()` over the ONE shared `OkHttpClient` (so the token cookie the
* submit obtains is the same jar every later request and the WS upgrade read from).
*
* The two token operations are DEFAULTED so a probe-only double still satisfies the interface. Their
* defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and
* [TokenSubmission.Unavailable] — never a silent success.
*/
public interface PairingProber {
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
/**
* Does [endpoint] answer an unauthenticated read with the server's `401 authentication required`
* (`src/server.ts` `authGate`)? Only consulted to disambiguate
* [PairingError.HttpOkButNotWebTerminal].
*
* Implementations MUST NOT throw for a network failure — an unreachable host is `false` ("not known
* to be gated"), so the app never prompts for a credential because a host is merely down.
*/
public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = false
/**
* `POST /auth` with [token]. On [TokenSubmission.Accepted] the shared cookie jar holds the host's
* `webterm_auth` cookie, so the very next probe/attach is authenticated.
*
* [token] is a SHELL CREDENTIAL: implementations must put it in the request BODY only — never a URL,
* never a log, never an exception message — and must not retain it.
*/
public suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission =
TokenSubmission.Unavailable
}
/**
* Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
* layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel —
* `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink
* I/O on a UI thread.
* Outcome of `POST /auth` (server `src/http/auth.ts` + the route in `src/server.ts`). A wrong token
* ([Rejected]) and a host that did not answer ([Unreachable]) are DISTINCT cases on purpose — collapsing
* them would tell the user to re-type a token that was fine.
*/
public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber =
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) }
public sealed interface TokenSubmission {
/** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */
public data object Accepted : TokenSubmission
/** `401` — the token does not match the host's `WEBTERM_TOKEN`. */
public data object Rejected : TokenSubmission
/** `429` — over the host's 10-per-minute auth limit. Surfaced as-is; NEVER auto-retried. */
public data object RateLimited : TokenSubmission
/**
* The submit never got an answer it could interpret: a transport failure or an unexpected status.
* [reason] is a short, credential-free diagnostic (an error TYPE or `"HTTP <status>"`) — it is
* shown to nobody and logged nowhere; the UI copy is app-authored.
*/
public data class Unreachable(val reason: String) : TokenSubmission
/** This prober has no way to submit a token (a probe-only seam). Reported, never silently ignored. */
public data object Unavailable : TokenSubmission
}
/** Why the access-token prompt is showing an error. Maps to inert, app-authored copy in [PairingCopy]. */
public enum class TokenError {
/** The submitted token was rejected by the host (`401`). */
INVALID,
/** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */
RATE_LIMITED,
/** The host did not answer the submit (or answered unintelligibly). */
UNREACHABLE,
/** The app cannot submit a token at all on this seam. */
UNAVAILABLE,
}
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
@@ -276,6 +407,27 @@ public sealed interface PairingUiState {
*/
public data class CertRequired(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState
/**
* The host is behind the optional `WEBTERM_TOKEN` gate and needs its shared access token before
* pairing can continue. [error] is null on the first prompt and set after a failed submit.
*
* This state deliberately carries NO token field — the credential is a
* [PairingViewModel.submitAccessToken] parameter and must not survive the call (plan §8).
*/
public data class TokenRequired(
val endpoint: HostEndpoint,
val name: String,
val tier: WarningTier,
val error: TokenError? = null,
) : PairingUiState
/** A submitted access token is in flight (`POST /auth`). Carries no token, for the same reason. */
public data class TokenSubmitting(
val endpoint: HostEndpoint,
val name: String,
val tier: WarningTier,
) : PairingUiState
/** The probe failed; [message] is inert, app-authored copy for [error]. Retryable. */
public data class Failed(
val endpoint: HostEndpoint,
@@ -302,6 +454,8 @@ private fun PairingUiState.candidate(): Candidate? = when (this) {
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
is PairingUiState.CertRequired -> Candidate(endpoint, name, tier)
is PairingUiState.TokenRequired -> Candidate(endpoint, name, tier)
is PairingUiState.TokenSubmitting -> Candidate(endpoint, name, tier)
is PairingUiState.Failed -> Candidate(endpoint, name, tier)
is PairingUiState.Entry, is PairingUiState.Paired -> null
}
@@ -315,7 +469,21 @@ internal object PairingCopy {
"这个端口在运行别的服务,不是 web-terminal。端口对吗"
is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived
is PairingError.CleartextBlocked ->
"系统阻止了到 ${error.host} 的明文连接。仅局域网 / Tailscale 地址允许 ws:// 明文。"
// Two distinct refusals collapse into this one error case (both surface as
// UnknownServiceException, which is what `PairingError.classify` keys on):
// 1. the PLATFORM's network_security_config, which pins the tunnel apex to TLS because it
// always serves a real LE cert (LAN cleartext itself is permitted — the config format has
// no CIDR syntax, so it could not be scoped to RFC1918);
// 2. the app's OWN transport-level cleartext guard (:transport-okhttp
// CleartextNotPermittedException), which is where the CIDR scoping actually happens: a
// plaintext dial to a PUBLIC (or unclassifiable) host is refused before it leaves the
// device. The tier tells the two apart, so each gets copy the user can act on.
if (tier == WarningTier.TUNNEL) {
"${error.host} 必须使用 https / wss —— 该主机由设备证书保护,不允许降级为明文。"
} else {
"应用拒绝以明文连接 ${error.host}http:// 与 ws:// 只允许用于本机、局域网和 Tailscale 地址。" +
"公网主机请改用 https:// / wss://(隧道或 Tailscale"
}
PairingError.TlsFailure ->
// Tunnel hosts present a real LE server cert, so a TLS failure there means OUR client cert
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
@@ -324,4 +492,19 @@ internal object PairingCopy {
PairingError.Timeout ->
"连接超时。主机可能不可达,或响应过慢。"
}
/**
* Copy for the access-token prompt. A wrong token and an unreachable host read differently on
* purpose, and the rate-limit line tells the user to WAIT — the app never retries by itself.
*/
fun describeToken(error: TokenError): String = when (error) {
TokenError.INVALID ->
"访问令牌不正确。请核对主机上 WEBTERM_TOKEN 的值(区分大小写)后重试。"
TokenError.RATE_LIMITED ->
"尝试次数过多,主机已暂时限流(每分钟最多 10 次。请等待约一分钟再试——App 不会自动重试。"
TokenError.UNREACHABLE ->
"提交访问令牌时主机没有正常响应。请检查网络与地址后重试。"
TokenError.UNAVAILABLE ->
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
}
}

View File

@@ -4,9 +4,15 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.SyncState
import wang.yaojia.webterm.api.models.WorktreeInfo
import wang.yaojia.webterm.api.models.WorktreeState
import wang.yaojia.webterm.api.routes.ApiClientError
/**
@@ -30,6 +36,16 @@ public class ProjectDetailViewModel(
private val fetchLog: (suspend () -> GitLogResult)? = null,
/** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */
public val worktree: WorktreeViewModel? = null,
/**
* w6/G2 — `POST /projects/git/fetch` for THIS repo. `null` ⇒ the band hides the Fetch action
* rather than offering a button that cannot work.
*/
private val fetchRemote: (suspend () -> GitWriteOutcome<FetchResult>)? = null,
/**
* w6/G7 — `GET /projects/worktree/state?path=` for ONE worktree. `null` ⇒ rows stay unprobed and
* show no sync chip, which is the honest rendering of "not known".
*/
private val fetchWorktreeState: (suspend (String) -> WorktreeState)? = null,
) {
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
@@ -57,9 +73,23 @@ public class ProjectDetailViewModel(
public data object Unavailable : RecentCommits
}
/** w6/G2 — the Fetch button's own state. A failure NEVER touches [phase] (failure isolation). */
public sealed interface FetchPhase {
public data object Idle : FetchPhase
public data object Working : FetchPhase
/** Succeeded; [lastFetchMs] is the server's post-fetch `FETCH_HEAD` mtime (may be null). */
public data class Done(val lastFetchMs: Long?) : FetchPhase
/** Failed (offline / auth / ≥2 remotes). `lastFetchMs` is left untouched so `stale` stays on. */
public data class Failed(val message: String) : FetchPhase
}
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.Hidden)
private val _fetchPhase = MutableStateFlow<FetchPhase>(FetchPhase.Idle)
private val _worktreeStates = MutableStateFlow<Map<String, WorktreeRowState>>(emptyMap())
/** The main detail snapshot `ProjectDetailScreen` renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow()
@@ -70,6 +100,15 @@ public class ProjectDetailViewModel(
/** The recent-commits snapshot. */
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
/** The Fetch action's snapshot (spinner / result banner). */
public val fetchPhase: StateFlow<FetchPhase> = _fetchPhase.asStateFlow()
/** Per-worktree git state, keyed by worktree path. Absent ⇒ unprobed (renders no sync chip). */
public val worktreeStates: StateFlow<Map<String, WorktreeRowState>> = _worktreeStates.asStateFlow()
/** Whether a Fetch seam is wired at all; a detached HEAD is refused by [SyncBand.canFetch]. */
public val canFetch: Boolean get() = fetchRemote != null
/**
* Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful
* detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail
@@ -93,6 +132,72 @@ public class ProjectDetailViewModel(
}
}
/**
* w6/G2 — refresh the remote-tracking refs for this repo (`git fetch --no-tags`: no pull, no
* merge, no working-tree change). Rules, all pinned by test:
* - **one at a time** — a tap while [FetchPhase.Working] is DROPPED, never queued;
* - **success re-loads the detail**, because `behind`/`lastFetchMs` only move server-side;
* - **failure changes nothing** — no re-load, no invented `lastFetchMs`, so the band's `stale`
* flag stays on. Marking stale data fresh is the one lie this panel exists to prevent.
*/
public suspend fun fetchUpstream() {
val fetcher = fetchRemote ?: return
if (_fetchPhase.value == FetchPhase.Working) return
_fetchPhase.value = FetchPhase.Working
val outcome = try {
fetcher()
} catch (cancel: CancellationException) {
_fetchPhase.value = FetchPhase.Idle
throw cancel
} catch (_: Throwable) {
_fetchPhase.value = FetchPhase.Failed(GitPanelCopy.FETCH_FAILED)
return
}
when (outcome) {
is GitWriteOutcome.Ok -> {
_fetchPhase.value = FetchPhase.Done(outcome.payload.lastFetchMs)
load() // the numbers this button exists for live server-side — re-read them.
}
// The server classifies and sanitizes its own failures (SEC-M10), so its message is shown
// verbatim; a missing one falls back to local copy. Nothing else changes: `lastFetchMs`
// stays where it was, so the band keeps reading "stale" instead of pretending it refreshed.
is GitWriteOutcome.Rejected ->
_fetchPhase.value = FetchPhase.Failed(outcome.message ?: GitPanelCopy.FETCH_FAILED)
GitWriteOutcome.RateLimited ->
_fetchPhase.value = FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED)
}
}
/**
* w6/G7 — probe the git state of every worktree OTHER than the current one (the current row reads
* the project's own `sync`/`dirtyCount`, already loaded and free).
*
* Each probe is isolated to its own row: a failure leaves that row unprobed (no chip) and never
* touches [phase] or another row. Probed once per opened project, never per refresh tick.
*/
public suspend fun probeWorktrees() {
val probe = fetchWorktreeState ?: return
val detail = (_phase.value as? Phase.Loaded)?.detail ?: return
for (worktree in detail.worktrees) {
if (worktree.isCurrent) continue
val state = try {
probe(worktree.path)
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
continue // isolated: this row stays unprobed, the panel still renders
}
_worktreeStates.value = _worktreeStates.value + (
worktree.path to WorktreeRowState(sync = state.sync, dirtyCount = state.dirtyCount)
)
}
}
/** Dismiss the fetch result banner. */
public fun clearFetchBanner() {
_fetchPhase.value = FetchPhase.Idle
}
private suspend fun loadPr() {
val fetcher = fetchPr ?: return
_prChip.value = PrChip.Loading
@@ -137,9 +242,218 @@ public class ProjectDetailViewModel(
fetchPr = { gateway.projectPr(path) },
fetchLog = { gateway.projectLog(path, null) },
worktree = worktree,
fetchRemote = { gateway.gitFetch(path) },
fetchWorktreeState = { gateway.worktreeState(it) },
)
self = vm
return vm
}
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// w6 — the git panel's pure presentation core (port of public/projects.ts `makeSyncBand` /
// `makeWorktreeRow` + public/git-log.ts `renderGitLog`; design: docs/mockups/project-detail-git.html).
// Pure and JVM-tested; the Compose layer only paints what these return.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** [GitLogResult.boundaryIndex] when there is no pushed/unpushed boundary to draw. */
public const val NO_COMMIT_BOUNDARY: Int = -1
/**
* The header sync band, as one of three mutually exclusive shapes.
*
* **The rule the type exists to enforce: exactly ONE shape may ever answer [isAllClear] true** —
* [Tracking] with `ahead == 0 && behind == 0` and a fetch inside [SyncState.FETCH_FRESH_WINDOW_MS].
* Green here means
* "I checked, ignore this", so [Detached] and [NoUpstream] cannot reach it by construction: absent
* numbers are not zeros.
*/
public sealed interface SyncBand {
/** True for the single green "all clear" state and nothing else. */
public val isAllClear: Boolean
/** `git fetch` is meaningless without a branch, so a detached HEAD disables the button. */
public val canFetch: Boolean get() = this !is Detached
/** HEAD is detached: show the short sha, no branch, no ↑↓. */
public data class Detached(val head: String?) : SyncBand {
override val isAllClear: Boolean get() = false
}
/** The branch tracks nothing — stated explicitly, in words as well as colour. */
public data object NoUpstream : SyncBand {
override val isAllClear: Boolean get() = false
}
/** Tracking [upstream]: ↑[ahead] is fact, ↓[behind] is only as fresh as [lastFetchMs]. */
public data class Tracking(
val upstream: String,
val ahead: Int,
val behind: Int,
/** `FETCH_HEAD` older than [SyncState.FETCH_FRESH_WINDOW_MS] (or never) ⇒ [behind] is a guess. */
val isStale: Boolean,
val lastFetchMs: Long?,
) : SyncBand {
override val isAllClear: Boolean get() = ahead == 0 && behind == 0 && !isStale
}
}
/**
* Derive the header band. `null` facts (a non-git project) ⇒ `null` — say nothing rather than guess.
* [head] is the short HEAD sha shown on a detached HEAD.
*/
public fun syncBandOf(
sync: SyncState?,
head: String? = null,
nowMs: Long = System.currentTimeMillis(),
): SyncBand? {
if (sync == null) return null
if (sync.detached) return SyncBand.Detached(head)
// A blank upstream is treated as none: the server should never send one, and "" would otherwise
// label the commit boundary with an empty string and unlock the green path.
val upstream = sync.upstream?.trim()?.takeIf { it.isNotEmpty() } ?: return SyncBand.NoUpstream
return SyncBand.Tracking(
upstream = upstream,
ahead = sync.ahead ?: 0,
behind = sync.behind ?: 0,
isStale = !sync.isFetchFresh(nowMs),
lastFetchMs = sync.lastFetchMs,
)
}
/**
* The header's uncommitted-changes chip, or null when clean/unknown. A count is strictly better than
* a bare dot (`● 3 未提交` answers "how much?"), so it wins whenever the server sent one; `dirty`
* alone falls back to the countless chip (mirror of public/projects.ts:1254-1258).
*/
public fun headerDirtyChip(dirtyCount: Int?, dirty: Boolean?): String? = when {
dirtyCount != null && dirtyCount > 0 -> GitPanelCopy.dirtyChip(dirtyCount)
dirtyCount == null && dirty == true -> GitPanelCopy.DIRTY_NO_COUNT
else -> null
}
// ── Commit list: unpushed marking + ONE upstream boundary ────────────────────────────
/**
* The label for the pushed/unpushed boundary, or null when NO boundary may be drawn.
*
* A blank upstream counts as absent: an empty label would be a boundary claiming a position on the
* timeline without naming what it is the position OF.
*/
public fun GitLogResult.boundaryLabel(): String? = upstream?.trim()?.takeIf { it.isNotEmpty() }
/**
* The row index the boundary is drawn AFTER — exactly once — or [NO_COMMIT_BOUNDARY].
*
* It is the LAST marked row, not "the first N rows": `git log` is date-ordered and a merge of an older
* branch interleaves unpushed commits BELOW pushed ones, so a row-count rule fails in the dangerous
* direction (calling an unpushed commit pushed). Marking itself is a SERVER decision
* ([GitLogResult.upstreamBoundaryIndex] reads `unpushed`, honoured only when strictly `true`) because
* `git log`'s `%h` is abbreviated while `rev-list` yields full SHAs.
*/
public fun GitLogResult.boundaryIndex(): Int =
if (boundaryLabel() == null) NO_COMMIT_BOUNDARY else upstreamBoundaryIndex ?: NO_COMMIT_BOUNDARY
/** Whether this row draws the "not pushed yet" rail: only an explicit `true` counts. */
public fun CommitLogEntry.isUnpushed(): Boolean = unpushed == true
// ── Worktree rows ────────────────────────────────────────────────────────────────────────────────
/** Chip colour role. [OK] is the green all-clear and is reserved for the header band's in-sync chip. */
public enum class GitChipTone { NEUTRAL, ACCENT, WARN, DIRTY, OK }
/** One inert chip on a worktree row. */
public data class WorktreeChip(val label: String, val tone: GitChipTone)
/**
* Per-row git state (w6/G7 — probed per worktree). Absent means UNPROBED, and an unprobed row shows
* no chip at all rather than a guess.
*/
public data class WorktreeRowState(
val sync: SyncState? = null,
val dirtyCount: Int? = null,
/** Live sessions whose cwd sits inside this worktree. */
val sessionCount: Int = 0,
)
/**
* The chips for one worktree row, in the shipped order: main · current · locked · prunable · sync ·
* dirty (mirror of public/projects.ts `makeWorktreeRow`).
*
* Deliberately NO green chip here: a worktree row cannot earn the all-clear, because the only state
* that may read green needs a fresh fetch, and "no upstream" — the normal state of a fresh worktree
* branch — must read as a warning instead.
*/
public fun worktreeChips(worktree: WorktreeInfo, state: WorktreeRowState? = null): List<WorktreeChip> {
val chips = ArrayList<WorktreeChip>(6)
if (worktree.isMain) chips += WorktreeChip(GitPanelCopy.MAIN_CHIP, GitChipTone.NEUTRAL)
if (worktree.isCurrent) chips += WorktreeChip(GitPanelCopy.CURRENT_CHIP, GitChipTone.ACCENT)
if (worktree.locked == true) chips += WorktreeChip(GitPanelCopy.LOCKED_CHIP, GitChipTone.NEUTRAL)
if (worktree.prunable == true) chips += WorktreeChip(GitPanelCopy.PRUNABLE_CHIP, GitChipTone.WARN)
val sync = state?.sync
if (sync != null) {
val ahead = sync.ahead ?: 0
when {
sync.detached -> chips += WorktreeChip(GitPanelCopy.DETACHED_CHIP_SHORT, GitChipTone.WARN)
sync.upstream.isNullOrBlank() ->
chips += WorktreeChip(GitPanelCopy.NO_UPSTREAM_CHIP, GitChipTone.WARN)
ahead > 0 -> chips += WorktreeChip(GitPanelCopy.aheadChip(ahead), GitChipTone.ACCENT)
}
}
val dirtyCount = state?.dirtyCount ?: 0
if (dirtyCount > 0) chips += WorktreeChip(GitPanelCopy.dirtyCountChip(dirtyCount), GitChipTone.DIRTY)
return chips
}
/** User-visible copy for the git panel. Local UI text — no wire contract depends on these strings. */
public object GitPanelCopy {
// Sync band
public const val UPSTREAM_CAPTION: String = "Upstream"
public const val TO_PUSH_CAPTION: String = "待推送"
public const val TO_PULL_CAPTION: String = "待拉取"
public const val HEAD_CAPTION: String = "HEAD"
public const val NO_UPSTREAM_CHIP: String = "无 upstream"
public const val NO_UPSTREAM_NOTE: String = "该分支没有跟踪上游 —— 没有可报告的 ↑↓"
public const val DETACHED_CHIP: String = "detached HEAD"
public const val DETACHED_CHIP_SHORT: String = "detached"
public const val IN_SYNC_CHIP: String = "✓ 已同步"
public const val STALE_CHIP: String = "存疑"
public const val NEVER_FETCHED_NOTE: String = "从未 fetch —— 这个数字不可信"
public const val STALE_NOTE: String = "上次 fetch 已超过一小时 —— 这个数字不可信"
public const val UP_TO_DATE_NOTE: String = "与远端一致"
public const val WAITING_ON_REMOTE_NOTE: String = "远端有新提交"
public const val NOTHING_TO_PUSH_NOTE: String = "没有待推送的提交"
public const val DIRTY_NO_COUNT: String = "● 未提交"
// Fetch
public const val FETCH: String = "Fetch"
public const val FETCH_WORKING: String = "正在 fetch…"
public const val FETCH_DONE: String = "已刷新远端引用(未 pull、未 merge"
public const val FETCH_FAILED: String = "fetch 失败,远端状态仍是上次的数据。"
public const val FETCH_RATE_LIMITED: String = "fetch 太频繁,请稍后再试。"
public const val FETCH_DISABLED_DETACHED: String = "HEAD 游离,无法 fetch"
// Commit list
public const val RECENT_COMMITS: String = "最近提交"
public const val UNPUSHED_MARK: String = ""
// Worktrees
public const val MAIN_CHIP: String = "main"
public const val CURRENT_CHIP: String = "当前"
public const val LOCKED_CHIP: String = "locked"
public const val PRUNABLE_CHIP: String = "prunable"
/** ALWAYS "Worktrees (n)" — one worktree per session means the count is the point, and a section
* that renames itself at n=1 hides the whole model (w6/G5). */
public fun worktreesTitle(count: Int): String = "Worktrees ($count)"
public fun dirtyChip(count: Int): String = "$count 未提交"
public fun dirtyCountChip(count: Int): String = "$count"
public fun aheadChip(count: Int): String = "$count"
public fun behindChip(count: Int): String = "$count"
public fun unpushedBadge(count: Int): String = "$count 未推送"
public fun aheadNote(count: Int): String = "$count 个提交只在本机"
public fun sessionCount(count: Int): String = "$count session"
public fun truncatedNote(count: Int): String = "只显示最近 $count 条提交。"
}

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
@@ -14,6 +15,7 @@ import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.WorktreeState
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
import wang.yaojia.webterm.wire.Validation
@@ -418,6 +420,18 @@ public interface ProjectsGateway {
public suspend fun projectPr(path: String): PrStatus
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
/**
* w6/G7 — one worktree's git state. Its own call (not part of `projectDetail`) so N rows do not
* each pay for a worktree listing and a CLAUDE.md read.
*/
public suspend fun worktreeState(worktreePath: String): WorktreeState
/**
* w6/G2 — refresh remote-tracking refs (no pull, no merge). GUARDED like the other git writes:
* the server derives the remote itself and never accepts one from the client.
*/
public suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult>
// ── W5: guarded worktree write ─────────────────────────────────────────────────────────
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
@@ -432,6 +446,8 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
override suspend fun projectPr(path: String): PrStatus = api.projectPr(path)
override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n)
override suspend fun worktreeState(worktreePath: String): WorktreeState = api.worktreeState(worktreePath)
override suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> = api.gitFetch(path)
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
api.createWorktree(path, branch, base)

View File

@@ -0,0 +1,269 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.api.models.QueueSnapshot
import wang.yaojia.webterm.api.models.QueueWriteOutcome
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
import java.util.UUID
/**
* # QueueViewModel (W2) — the follow-up prompt queue presenter.
*
* Drives the three server routes (`POST` / `GET` / `DELETE /live-sessions/:id/queue`) behind the
* [QueueGateway] seam: queue a prompt Claude will be handed the next time it goes idle, review what is
* pending, and cancel everything. Ports the web client's `public/queue.ts` affordance.
*
* ### The queued text is raw shell input (invariant #9 / byte-shuttle)
* [enqueue] passes [text] to the gateway **verbatim** — no trim, no normalisation, no case folding, and
* **never a client-side `\r`**. `appendEnter` stays a FLAG so the SERVER materialises the carriage
* return, byte-identical to a keystroke. The only refusal is a genuinely EMPTY string (mirroring
* [ApiClientError.QueueTextEmpty], raised before any I/O); a whitespace-only prompt is legitimate shell
* input and is sent as-is.
*
* ### The depth is always the server's number
* [QueueUiState.depth] is only ever assigned from an authoritative source — a write's
* [QueueWriteOutcome.Ok.length], a [refresh] snapshot, or the live `queue` WS frame ([onServerDepth]).
* Nothing here increments a local counter, so the badge can never drift from the FIFO the server holds.
*
* ### Failure honesty (no auto-retry, ever)
* Each [QueueWriteOutcome] maps to its own [QueueNotice] and the pass STOPS there:
* - [QueueNotice.RateLimited] (429, the shared 20/min/IP bucket) is **shown, never retried** — a retry
* loop only burns the budget a real enqueue needs;
* - [QueueNotice.Disabled] (503, `QUEUE_ENABLED=0`) additionally clears [QueueUiState.isSupported], so
* the affordance disappears for this host instead of failing over and over;
* - [QueueNotice.Full] / [QueueNotice.TooLarge] / [QueueNotice.SessionGone] are actionable states, and
* [QueueNotice.Rejected] carries the server's own already-sanitized `error` string, rendered INERT.
* A transport throw becomes [QueueNotice.Unreachable] and the last-good queue is kept — a network blip
* must not make the panel claim the queue is empty.
*
* ### Testability
* A plain presenter (not an `androidx.lifecycle.ViewModel`) so every branch runs under `runTest` with no
* `Dispatchers.Main`/Robolectric. Its Compose surface ([wang.yaojia.webterm.components.QueuePanel]) is
* device-QA (plan §7).
*/
public class QueueViewModel(private val gateway: QueueGateway) {
private val _uiState = MutableStateFlow(QueueUiState())
/** The single snapshot the queue panel + depth badge render from. */
public val uiState: StateFlow<QueueUiState> = _uiState.asStateFlow()
/**
* `GET /live-sessions/:id/queue` — the pending depth plus the queued prompts (verbatim), for the
* "review before cancelling" panel. Read-only and safe to call on every panel open.
*/
public suspend fun refresh(sessionId: UUID) {
_uiState.update { it.copy(isBusy = true) }
val snapshot = try {
gateway.snapshot(sessionId)
} catch (cancel: CancellationException) {
_uiState.update { it.copy(isBusy = false) }
throw cancel
} catch (error: Throwable) {
_uiState.update { it.copy(isBusy = false, notice = noticeFor(error)) }
return
}
publish(snapshot)
}
/**
* `POST /live-sessions/:id/queue` — queue [text] VERBATIM. An empty [text] is refused locally (no
* network I/O), matching the server's own 400 and [ApiClientError.QueueTextEmpty]; the size cap is
* deliberately NOT pre-checked here (it is server config the client cannot know) and surfaces as
* [QueueNotice.TooLarge].
*
* @param appendEnter leave `true` so the SERVER appends the `\r`; never pre-append one.
*/
public suspend fun enqueue(sessionId: UUID, text: String, appendEnter: Boolean = true) {
if (!canSend(text)) return
write { gateway.enqueue(sessionId, text, appendEnter) }
}
/**
* `DELETE /live-sessions/:id/queue` — cancel EVERY pending entry. There is no per-item cancel on the
* server, so the panel must not pretend to offer one.
*/
public suspend fun clearAll(sessionId: UUID) {
write { gateway.clear(sessionId) }
}
/**
* The live `queue` WS frame ([wang.yaojia.webterm.session.Queued]) — the server's authoritative
* depth. A positive depth that no longer matches the cached [QueueUiState.items] marks them STALE
* (the panel then re-reads rather than showing a list that contradicts the badge); a zero depth is
* unambiguous and empties the list outright.
*/
public fun onServerDepth(length: Int) {
val depth = length.coerceAtLeast(0)
_uiState.update { state ->
if (depth == 0) {
state.copy(depth = 0, items = emptyList(), isItemsStale = false)
} else {
state.copy(depth = depth, isItemsStale = depth != state.items.size)
}
}
}
/** Dismiss the current [QueueNotice]; the queue contents and depth are untouched. */
public fun dismissNotice() {
_uiState.update { it.copy(notice = null) }
}
// ── Internals ────────────────────────────────────────────────────────────────────────────────
/** Shared body of the two guarded writes: busy flag → one attempt → typed outcome → NO retry. */
private suspend fun write(attempt: suspend () -> QueueWriteOutcome) {
_uiState.update { it.copy(isBusy = true, notice = null) }
val outcome = try {
attempt()
} catch (cancel: CancellationException) {
_uiState.update { it.copy(isBusy = false) }
throw cancel
} catch (error: Throwable) {
_uiState.update { it.copy(isBusy = false, notice = noticeFor(error)) }
return
}
apply(outcome)
}
/**
* Fold one write outcome into the state. On success the depth is the server's number and the cached
* item list is re-read lazily (marked stale unless the queue is now empty).
*/
private fun apply(outcome: QueueWriteOutcome) {
when (outcome) {
is QueueWriteOutcome.Ok -> {
onServerDepth(outcome.length)
_uiState.update { it.copy(isBusy = false, notice = null) }
}
QueueWriteOutcome.Full -> fail(QueueNotice.Full)
QueueWriteOutcome.TooLarge -> fail(QueueNotice.TooLarge)
QueueWriteOutcome.SessionGone -> fail(QueueNotice.SessionGone)
QueueWriteOutcome.RateLimited -> fail(QueueNotice.RateLimited)
QueueWriteOutcome.Disabled -> {
// A capability answer, not a transient failure: stop offering the affordance on this host.
_uiState.update { it.copy(isBusy = false, isSupported = false, notice = QueueNotice.Disabled) }
}
is QueueWriteOutcome.Rejected -> fail(QueueNotice.Rejected(outcome.message))
}
}
private fun fail(notice: QueueNotice) {
_uiState.update { it.copy(isBusy = false, notice = notice) }
}
private fun publish(snapshot: QueueSnapshot) {
_uiState.update {
it.copy(
depth = snapshot.length.coerceAtLeast(0),
items = snapshot.items,
isBusy = false,
isItemsStale = false,
notice = null,
)
}
}
/** A thrown failure → its notice. A 404 is a real state; everything else is "could not reach". */
private fun noticeFor(error: Throwable): QueueNotice = when (error) {
is ApiClientError.SessionNotFound -> QueueNotice.SessionGone
else -> QueueNotice.Unreachable
}
public companion object {
/**
* May [text] be sent? Only EMPTY is refused — whitespace is legitimate keyboard input for a
* shell, so trimming here would silently change what the user typed (invariant #9).
*/
public fun canSend(text: String): Boolean = text.isNotEmpty()
}
}
/** The queue panel + badge snapshot. [items] is empty when nothing is queued OR nothing was read yet. */
public data class QueueUiState(
/** The server's pending depth — the "N queued" badge number. Never locally incremented. */
val depth: Int = 0,
/** The queued prompts in FIFO order, VERBATIM (rendered inert — untrusted round-tripped text). */
val items: List<String> = emptyList(),
/** A write/read is in flight (the send + cancel buttons disable). */
val isBusy: Boolean = false,
/** False after a 503 — the host has `QUEUE_ENABLED=0`, so hide the affordance entirely. */
val isSupported: Boolean = true,
/** [items] no longer matches [depth] (a live frame moved the queue) — re-read before trusting it. */
val isItemsStale: Boolean = false,
/** The last failure to report, or null. */
val notice: QueueNotice? = null,
)
/**
* What went wrong on the last queue operation. Typed (not a status code) because each one demands
* different UI behaviour; [Rejected] carries the server's own sanitized `error` string, which must be
* rendered INERT (plain text, no linkify/markdown — plan §8).
*/
public sealed interface QueueNotice {
/** 409 — the bounded FIFO is at `QUEUE_MAX_ITEMS`; cancel something first. */
public data object Full : QueueNotice
/** 413 — over the server's per-item byte cap. */
public data object TooLarge : QueueNotice
/** 503 — the queue is switched off on this host. */
public data object Disabled : QueueNotice
/** 404 — the session exited or was killed. */
public data object SessionGone : QueueNotice
/** 429 — the shared 20/min/IP bucket. Shown and NEVER auto-retried. */
public data object RateLimited : QueueNotice
/** A transport failure / unparseable body — the queue state is unknown, the last-good view is kept. */
public data object Unreachable : QueueNotice
/** Any other 4xx/5xx, with the server's inert message (null when the body was empty). */
public data class Rejected(val message: String?) : QueueNotice
}
/**
* The three queue routes as a seam, so [QueueViewModel] is JVM-testable with no transport. Production is
* [ApiClientQueueGateway] over the per-host [ApiClient]; tests script outcomes.
*/
public interface QueueGateway {
/** `GET /live-sessions/:id/queue` — depth + the queued prompts. Throws for 404 / a garbled body. */
public suspend fun snapshot(id: UUID): QueueSnapshot
/** `POST /live-sessions/:id/queue` — [text] passed through VERBATIM. */
public suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome
/** `DELETE /live-sessions/:id/queue` — cancel-all (there is no per-item cancel). */
public suspend fun clear(id: UUID): QueueWriteOutcome
}
/**
* Production [QueueGateway] over one host's [ApiClient] (which stamps `Origin` on the two writes).
*
* [api] is a SUPPLIER and every call hops to [ioDispatcher], for the reason `ApiClientFactory` documents:
* resolving the shared `HttpTransport` builds the mTLS `OkHttpClient` (AndroidKeyStore + Tink reads), and
* this gateway is driven from a Composable's `rememberCoroutineScope()` — i.e. from `Main`. Building the
* gateway therefore stays free, and no keystore work can land on the UI thread.
*/
public class ApiClientQueueGateway(
private val api: () -> ApiClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : QueueGateway {
override suspend fun snapshot(id: UUID): QueueSnapshot = withContext(ioDispatcher) { api().queue(id) }
override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome =
withContext(ioDispatcher) { api().enqueueFollowup(id, text, appendEnter) }
override suspend fun clear(id: UUID): QueueWriteOutcome = withContext(ioDispatcher) { api().clearQueue(id) }
}

View File

@@ -14,6 +14,7 @@ import wang.yaojia.webterm.api.routes.ApiClientError
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
import wang.yaojia.webterm.session.TitleSanitizer
import wang.yaojia.webterm.session.UnreadLedger
import wang.yaojia.webterm.wire.Tunables
@@ -25,9 +26,16 @@ import kotlin.time.Duration
*
* Folds `GET /live-sessions` for the ACTIVE host into a single [collectAsStateWithLifecycle-friendly]
* [uiState] snapshot of [SessionRow]s (status badge · telemetry · thumbnail key · cols×rows · sanitized
* title · unread dot), and owns the four list actions: STARTED-scoped [poll], [refresh]
* (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open) and [killSession]
* (optimistic swipe-to-kill). Ports the iOS session-list surface.
* title · unread dot · W2 queue depth), and owns the list actions: STARTED-scoped [poll], [refresh]
* (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open), [killSession]
* (optimistic swipe-to-kill) and [removeActiveHost] (the confirm-gated un-pair). Ports the iOS
* session-list surface.
*
* ### The unread dot is DURABLE
* Watermarks live behind [UnreadWatermarkStore]; production binds [PersistedUnreadWatermarkStore] over
* `:host-registry`'s DataStore store, so the dot survives process death — exactly when it matters, the
* product premise being "walk away and come back". The reducer stays in `UnreadLedger` (`:session-core`):
* this VM only decides WHERE a watermark lives, never how one is computed.
*
* ### Untrusted server boundary (plan §4 / §8)
* The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` →
@@ -45,14 +53,16 @@ import kotlin.time.Duration
* ### Testability
* A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time with
* no `Dispatchers.Main`/Robolectric. Its collaborators are seams ([SessionListGatewayFactory],
* [UnreadWatermarkStore]) so the fetch→row mapping, unread-dot logic and optimistic-kill path are all
* JVM-tested; swipe/thumbnail/pull gestures are device-QA (plan §7).
* [UnreadWatermarkStore], [HostRemovalGateway]) so the fetch→row mapping, unread-dot logic,
* optimistic-kill path and the all-or-nothing un-pair are all JVM-tested; swipe/thumbnail/pull gestures
* are device-QA (plan §7).
*/
public class SessionListViewModel(
private val hostStore: HostStore,
private val gatewayFactory: SessionListGatewayFactory,
private val watermarkStore: UnreadWatermarkStore = InMemoryUnreadWatermarkStore(),
watermarkStore: UnreadWatermarkStore? = null,
private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL,
removalGateway: HostRemovalGateway? = null,
) {
private val _uiState = MutableStateFlow(SessionListUiState())
@@ -68,10 +78,59 @@ public class SessionListViewModel(
/** The active host id; sticky across polls, defaults to the first host until the user switches. */
private var activeHostId: String? = null
/** Local unread watermarks (persisted through [watermarkStore]); loaded once, lazily. */
/** Local unread watermarks (persisted through [watermarks]); loaded once, lazily. */
private var ledger: UnreadLedger = UnreadLedger()
private var ledgerLoaded = false
/**
* Where the watermarks live. Defaults to memory-only so a test/preview needs no storage; production
* installs the DataStore-backed [PersistedUnreadWatermarkStore] via [useWatermarkStore].
*/
private var watermarks: UnreadWatermarkStore = watermarkStore ?: InMemoryUnreadWatermarkStore()
/** True when the store came from the constructor — [useWatermarkStore] must never override it. */
private var watermarkStoreInstalled: Boolean = watermarkStore != null
/** Un-pairing collaborator, installable post-construction for the same reason as [watermarks]. */
private var removal: HostRemovalGateway? = removalGateway
private var removalInstalled: Boolean = removalGateway != null
// ── Why the two collaborators above are installable AFTER construction ───────────────────────
// Both are app-scoped Hilt singletons, but this VM is constructed by the nav layer, which does not
// reach the Hilt graph; the SCREEN does (`SessionListScreen` resolves them through a Hilt
// `@EntryPoint` — the same escape hatch `TerminalScreen` already uses for its palette store).
// Constructor injection remains the preferred wiring and always WINS: passing either one makes the
// matching installer a no-op, so a test (or a future nav-side wiring) is never overridden.
/**
* Install the DURABLE watermark store, ONCE, before the ledger is first read.
*
* Refused (returning false, changing nothing) when a store is already installed OR the ledger has
* already been loaded — swapping stores mid-flight could resurrect watermarks the live ledger already
* dropped, or silently discard a `markSeen` that was persisted elsewhere.
*
* @return true when [store] became this VM's watermark home.
*/
public fun useWatermarkStore(store: UnreadWatermarkStore): Boolean {
if (watermarkStoreInstalled || ledgerLoaded) return false
watermarks = store
watermarkStoreInstalled = true
return true
}
/**
* Install the host-removal collaborator, ONCE. Without it [removeActiveHost] is a no-op (which is the
* correct behaviour for a preview/test that has no way to un-pair anything).
*
* @return true when [gateway] became this VM's removal path.
*/
public fun useRemovalGateway(gateway: HostRemovalGateway): Boolean {
if (removalInstalled) return false
removal = gateway
removalInstalled = true
return true
}
/**
* STARTED-scoped poll loop (plan §6.6): fetch the active host's list, wait [pollInterval], repeat.
* Cancellation (app backgrounded) stops it cleanly. Run inside `repeatOnLifecycle(STARTED)`.
@@ -107,7 +166,7 @@ public class SessionListViewModel(
val row = _uiState.value.rows.firstOrNull { it.id == sessionId } ?: return
val at = row.info.lastOutputAt ?: return
ledger = ledger.record(sessionId.toString(), at)
runCatching { watermarkStore.save(ledger) }
runCatching { watermarks.save(ledger) }
_uiState.update { it.copy(rows = it.rows.map { r -> r.withUnread(ledger) }) }
}
@@ -134,6 +193,46 @@ public class SessionListViewModel(
}
}
/**
* Un-pair the ACTIVE host: hand it (and the session ids currently listed for it) to the
* [HostRemovalGateway], which is the ONE place that erases every trace keyed off that host —
* the push registration on the host itself (`DELETE /push/fcm-token`, otherwise the phone keeps
* receiving notifications for a host the user dropped), the paired record, the last-session pointer,
* the `webterm_auth` cookie, and those sessions' unread watermarks.
*
* Only the ACTIVE host is removable, because the live session ids — and therefore the watermarks to
* drop — are only known for the host currently listed. Switching first is one tap.
*
* ALL-OR-NOTHING at the UI level: a failure leaves the host exactly where it was and raises
* [SessionListUiState.hasRemoveError], because a half-removed host is worse than none. On success the
* list re-fetches, so the active host re-resolves to a survivor (or the empty pair-a-host state).
*/
public suspend fun removeActiveHost() {
val gateway = removal ?: return
val hostId = activeHostId ?: return
if (!hostsById.containsKey(hostId)) return
val sessionIds = _uiState.value.rows.map { it.id.toString() }
try {
gateway.removeHost(hostId, sessionIds)
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
_uiState.update { it.copy(hasRemoveError = true) }
return
}
// Forget the pointer so the next fetch resolves the first SURVIVING host instead of re-selecting
// a host that no longer exists.
activeHostId = null
_uiState.update { it.copy(hasRemoveError = false) }
fetch(markRefreshing = true)
}
/** Dismiss the removal-failure banner (the host is still paired; nothing else changed). */
public fun dismissRemoveError() {
_uiState.update { it.copy(hasRemoveError = false) }
}
// ── Internals ────────────────────────────────────────────────────────────────────────────────
private suspend fun fetch(markRefreshing: Boolean) {
@@ -202,7 +301,7 @@ public class SessionListViewModel(
private suspend fun ensureLedgerLoaded() {
if (ledgerLoaded) return
ledger = runCatching { watermarkStore.load() }.getOrDefault(UnreadLedger())
ledger = runCatching { watermarks.load() }.getOrDefault(UnreadLedger())
ledgerLoaded = true
}
}
@@ -234,6 +333,11 @@ public data class SessionListUiState(
val isRefreshing: Boolean = false,
/** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */
val hasLoadError: Boolean = false,
/**
* The last host removal failed, so the host is STILL paired (all-or-nothing). The screen shows an
* inline notice; nothing was partially erased.
*/
val hasRemoveError: Boolean = false,
)
/**
@@ -299,7 +403,7 @@ public interface UnreadWatermarkStore {
public suspend fun save(ledger: UnreadLedger)
}
/** In-memory [UnreadWatermarkStore] (default). Watermarks live for the process only until DataStore lands. */
/** In-memory [UnreadWatermarkStore] — the test/preview double. Watermarks live for the process only. */
public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()) : UnreadWatermarkStore {
@Volatile private var current: UnreadLedger = initial
override suspend fun load(): UnreadLedger = current
@@ -307,3 +411,46 @@ public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()
current = ledger
}
}
/**
* The PRODUCTION [UnreadWatermarkStore]: the ledger-typed seam bridged onto `:host-registry`'s
* DataStore-backed [SessionWatermarkStore], so the unread dot survives process death (the whole point —
* the product premise is walking away and coming back).
*
* The two halves are deliberately typed differently and this adapter is the ONLY join:
* - [UnreadLedger] (`:session-core`) owns the *reducer* — the monotonic `record`, the `isUnread`
* comparison, the tie-broken cap;
* - [SessionWatermarkStore] (`:host-registry`) owns *storage* — validation of ids at rest, the growth
* cap, the JSON blob.
* `:host-registry` may not depend on `:session-core` (nothing points sideways), so neither restates the
* other; `:app` is the one module that sees both. Nothing about the reducer is reimplemented here.
*
* The store applies its own sanitize+cap pass, so [load] can legitimately return FEWER watermarks than
* [save] was given (an invalid id at rest is dropped). That is the correct behaviour, not a bug: the
* ledger simply treats a dropped session as never-seen.
*/
public class PersistedUnreadWatermarkStore(
private val store: SessionWatermarkStore,
) : UnreadWatermarkStore {
override suspend fun load(): UnreadLedger = UnreadLedger(store.loadAll())
override suspend fun save(ledger: UnreadLedger) {
store.replaceAll(ledger.watermarks)
}
}
/**
* Un-pairing a host, as ONE operation (plan §8 — a half-removed host is worse than none).
*
* A single seam rather than five calls from the ViewModel, because the steps span four modules
* (`:api-client` push DELETE, `:host-registry` host/last-session/cookie/watermark stores,
* `:transport-okhttp`'s live cookie jar) and the VM must stay JVM-testable with no transport.
* Production is `HostRemover` in the composition root.
*
* @param sessionIds the host's currently-known live session ids, so their unread watermarks are dropped
* too (watermarks are keyed by SESSION, not host — see `HostRemover`).
*/
public fun interface HostRemovalGateway {
/** Erase every trace of [hostId]. Throws if anything essential failed (the UI then keeps the host). */
public suspend fun removeHost(hostId: String, sessionIds: List<String>)
}

View File

@@ -1,15 +1,44 @@
package wang.yaojia.webterm.wiring
import android.util.Log
import dagger.Lazy
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.api.pairing.runPairingProbe
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
import wang.yaojia.webterm.hostregistry.AuthCookieStore
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.LastSessionStore
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.transport.AuthCookieJar
import wang.yaojia.webterm.transport.AuthCookieSnapshot
import wang.yaojia.webterm.transport.authCookieHostKey
import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
import wang.yaojia.webterm.viewmodels.PairingProber
import wang.yaojia.webterm.viewmodels.PersistedUnreadWatermarkStore
import wang.yaojia.webterm.viewmodels.QueueGateway
import wang.yaojia.webterm.viewmodels.TokenSubmission
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
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.TermTransport
import java.net.URI
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
@@ -28,10 +57,11 @@ import javax.inject.Singleton
* Engine state is never touched off-confinement and emulator/scrollback state never off-`Main`.
*
* ### Off-`Main` warm-up (FIX 3)
* The mTLS transports and [identityRepository] are behind `dagger.Lazy` so constructing this root does
* NO AndroidKeyStore/Tink/`OkHttpClient` I/O on the injection (often `Main`) thread. [warmUp] resolves
* them on `Dispatchers.IO`, so the shared client is built and the device identity first-touched off the
* UI thread. A21/A27 call [warmUp] before the first `bind()` / cert-screen open.
* The mTLS transports, [identityRepository], the auth-cookie store and the thumbnail rasterizer are all
* behind `dagger.Lazy` so constructing this root does NO AndroidKeyStore/Tink/`OkHttpClient`/DataStore/
* `android.graphics` work on the injection (often `Main`) thread — `MainActivity` field-injects it in
* `onCreate`. [warmUp] resolves them on `Dispatchers.IO`, so the shared client is built, the device
* identity first-touched and the persisted session cookie rehydrated off the UI thread.
*
* What lives here (all app singletons):
* - [hostStore] / [lastSessionStore] — persisted, non-secret UI state (DataStore).
@@ -39,6 +69,11 @@ import javax.inject.Singleton
* - [apiClientFactory] / [sessionEngineFactory] — per-host / per-session builders over the shared
* transports (lazy).
* - [coldStartPolicy] — the cold-launch route seam (A29).
* - the `webterm_auth` cookie jar + its durable store — the optional `WEBTERM_TOKEN` gate.
* - [unreadWatermarkStore] — the DURABLE unread-dot watermarks (was memory-only; reset on process death).
* - [uiConfigGate] — the host's `allowAutoMode` policy, fail-closed (`GET /config/ui`).
* - [hostRemoval] — un-pair a host and erase every trace keyed off it, push registration included.
* - [runCertificateRotationIfDue] / [rotationOutcomes] — silent device-certificate rotation.
* Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its
* [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder].
*/
@@ -57,7 +92,37 @@ public class AppEnvironment @Inject constructor(
private val identityRepositoryLazy: Lazy<IdentityRepository>,
private val httpTransportLazy: Lazy<HttpTransport>,
private val termTransportLazy: Lazy<TermTransport>,
/** The SAME jar instance installed on the one shared `OkHttpClient` (`di/AuthCookieModule`). */
private val authCookieJarLazy: Lazy<AuthCookieJar>,
/** Durable, Tink-AEAD-encrypted home of the `webterm_auth` cookie (`di/AuthCookieModule`). */
private val authCookieStoreLazy: Lazy<AuthCookieStore>,
/**
* The off-screen preview painter (§6.7). `Lazy` because constructing it measures a `Paint` — an
* `android.graphics` touch that must not happen while the Hilt graph is being built (it is stubbed
* under JVM unit tests).
*/
private val thumbnailRasterizerLazy: Lazy<ThumbnailRasterizer>,
/**
* The durable, DataStore-backed home of the unread watermarks (`di/StorageModule`). Exposed to the
* session list through [unreadWatermarkStore]; without it the unread dot resets on process death.
*/
private val sessionWatermarkStore: SessionWatermarkStore,
/**
* Silent device-certificate rotation (`di/TlsModule`). `Lazy` for the same reason as the transports:
* resolving it must not pull AndroidKeyStore/Tink work onto the injection (often `Main`) thread.
*/
private val rotationSchedulerLazy: Lazy<CertificateRotationScheduler>,
/** Per-host push registration — the `DELETE /push/fcm-token` half is what [removeHost] finally calls. */
private val pushUnregister: HostPushUnregister,
/** Resolves this device's current FCM token, or null when push is unavailable/uninitialised. */
private val pushTokenSource: PushTokenSource,
) {
/** One-shot cold-start restore of the persisted session cookie into the live jar (see [warmUp]). */
private val authCookieHydrator = AuthCookieHydrator(
store = { authCookieStoreLazy.get() },
jar = { authCookieJarLazy.get() },
)
/**
* The mTLS device identity (A27 cert screen). Resolving the [Lazy] merely constructs the repository
* (I/O-free per A11); its FIRST touch (`currentSummary`/`hasInstalledIdentity`/handshake) does the
@@ -74,26 +139,520 @@ public class AppEnvironment @Inject constructor(
public val httpTransport: HttpTransport get() = httpTransportLazy.get()
/**
* Build the two-step pairing prober (A19) over the ONE shared transports. The transports are
* resolved LAZILY inside `probe()` (not at build time) so constructing the prober on the UI thread
* never triggers the mTLS/OkHttp build — the actual resolve happens in the probe's own coroutine,
* after [warmUp].
* Build the pairing network seam (A19) over the ONE shared transports: the two-step probe plus the
* `WEBTERM_TOKEN` gate's detect + submit. The transports are resolved LAZILY inside each call (not at
* build time) so constructing the prober on the UI thread never triggers the mTLS/OkHttp build — the
* actual resolve happens in the caller's own coroutine, after [warmUp].
*
* The submit rides the SAME client as everything else, which is the whole point: the `webterm_auth`
* cookie it earns lands in the shared [AuthCookieJar] and is therefore replayed on every later REST
* call AND on the WS upgrade.
*/
public fun buildPairingProber(): PairingProber =
PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) }
public fun buildPairingProber(): PairingProber = object : PairingProber {
private val gateway = AccessTokenGateway { httpTransportLazy.get() }
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult =
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get())
override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean =
gateway.requiresAccessToken(endpoint)
override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission =
gateway.submit(endpoint, token)
}
/**
* Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving
* either transport builds the one shared client (which reads the mTLS material); touching the
* identity forces its first AndroidKeyStore/Tink read. Idempotent at the singleton level — the
* client/identity are built once and cached. Safe to call from any coroutine; hops to
* `Dispatchers.IO` internally.
* Build the session-list / manage-page thumbnail pipeline for one host (§6.7). Per-host because its
* preview fetch is bound to that host's [ApiClient][wang.yaojia.webterm.api.routes.ApiClient]
* (`Origin` is stamped per endpoint), so it cannot be an app singleton.
*
* The CALLER owns the returned pipeline's lifetime: keep it in a `remember(env, hostId)` and call
* `close()` from a `DisposableEffect` so its in-flight renders are cancelled with the screen.
* Resolving the rasterizer measures a `Paint` — cheap, but `android.graphics`, so call this from the
* composition, never from DI construction.
*/
public fun buildThumbnailPipeline(endpoint: HostEndpoint): ThumbnailPipeline =
ThumbnailPipeline(
previewSource = ApiPreviewSource { apiClientFactory.create(endpoint) },
rasterizer = thumbnailRasterizerLazy.get(),
)
// ── Unread watermarks (durable) ───────────────────────────────────────────────────────────────
/**
* The PRODUCTION unread-watermark store: DataStore-backed, so the session-list dot survives process
* death. The session list installs it via `SessionListViewModel.useWatermarkStore`.
*/
public val unreadWatermarkStore: UnreadWatermarkStore =
PersistedUnreadWatermarkStore(sessionWatermarkStore)
// ── W2 prompt queue ──────────────────────────────────────────────────────────────────────────
/**
* The follow-up-queue seam for one host (`POST|GET|DELETE /live-sessions/:id/queue`). Per-host
* because the two writes are `Origin`-guarded and the header is derived from the endpoint.
*
* Cheap + `Main`-safe to build: the `ApiClient` is minted by a SUPPLIER inside the gateway's own
* `Dispatchers.IO` hop, so resolving the shared mTLS `OkHttpClient` never happens on the UI thread
* (the same discipline `sessionThumbnailPipeline` follows).
*/
public fun buildQueueGateway(endpoint: HostEndpoint): QueueGateway =
ApiClientQueueGateway(api = { apiClientFactory.create(endpoint) })
// ── GET /config/ui (allowAutoMode) ───────────────────────────────────────────────────────────
/**
* The host-policy gate for `approve.mode="acceptEdits"`. One instance for the app so a host is asked
* once per process; it FAILS CLOSED, so an unreachable host never widens what the phone may approve.
*/
public val uiConfigGate: UiConfigGate = UiConfigGate { endpoint ->
apiClientFactory.create(endpoint).uiConfig()
}
// ── Host removal ─────────────────────────────────────────────────────────────────────────────
/**
* Un-pair a host and erase EVERY trace keyed off it — including the `DELETE /push/fcm-token` that had
* no caller at all, which is why a removed host kept pushing notifications to this device. See
* [HostRemover] for the ordering rules.
*/
public val hostRemoval: HostRemovalGateway by lazy {
// `by lazy` because the session-list screen reads this on `Main`. Resolving the auth-cookie
// store here is safe by that module's documented contract — `TinkAuthCookieCipher` defers ALL
// AndroidKeyStore/AEAD work to first use — and in practice `warmUp()` has already resolved it
// off-`Main`. Nothing in this block does I/O; the removal itself runs in the caller's coroutine.
HostRemover(
hostStore = hostStore,
lastSessionStore = lastSessionStore,
authCookieStore = authCookieStoreLazy.get(),
watermarkStore = sessionWatermarkStore,
pushUnregister = pushUnregister,
currentPushToken = { pushTokenSource.currentToken() },
clearLiveCookies = { hostKey -> authCookieJarLazy.get().clear(hostKey) },
)
}
// ── Silent certificate rotation ──────────────────────────────────────────────────────────────
/**
* App-lifetime scope for the fire-and-forget rotation pass. Deliberately NOT the caller's scope:
* [warmUp] is awaited by the terminal screen's readiness gate, and a renewal's network round-trip
* must never be able to hold the UI behind a spinner.
*/
private val rotationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val rotationTrigger = CertificateRotationTrigger(
scope = rotationScope,
scheduler = { rotationSchedulerLazy.get() },
onError = { error ->
// Shape only: a rotation error can carry a control-plane URL or response body.
Log.w(ROTATION_TAG, "certificate rotation could not start (${error::class.simpleName})")
},
)
/**
* Run one silent certificate-rotation pass if one is due (the Android counterpart of iOS
* `AppCoordinator.runCertificateRotationIfDue()`). Safe on launch AND on every foreground: the
* scheduler is idempotent, self-coalescing, and `NotDue` costs one store read. Returns immediately.
*/
public fun runCertificateRotationIfDue() {
rotationTrigger.fire()
}
/**
* The most recent rotation pass's outcome (null before the first pass). Observed by the device-cert
* screen so a silently failing renewal becomes visible instead of only a log line.
*/
public fun rotationOutcomes(): StateFlow<RotationOutcome?> = rotationSchedulerLazy.get().lastOutcome
/**
* Build the shared `OkHttpClient`, rehydrate the persisted session cookie and first-touch the device
* identity OFF `Main` (FIX 3). Resolving either transport builds the one shared client (which reads
* the mTLS material); touching the identity forces its first AndroidKeyStore/Tink read; the cookie
* hydration reads DataStore + Tink. Idempotent at the singleton level — the client/identity are built
* once and cached, and the hydration is one-shot ([AuthCookieHydrator]). Safe to call from any
* coroutine; hops to `Dispatchers.IO` internally.
*/
public suspend fun warmUp() {
withContext(Dispatchers.IO) {
// First: put the stored credential in the jar, so the very first dial is already authenticated
// on a token-gated host (the jar is consulted per request, so this races nothing).
authCookieHydrator.hydrateOnce()
sessionEngineFactory.warm() // builds TermTransport → the shared OkHttpClient → reads SSL material
apiClientFactory.warm() // HttpTransport reuses the SAME already-built client
runCatching { identityRepositoryLazy.get().hasInstalledIdentity() } // first AndroidKeyStore/Tink touch
}
// Rotate the device certificate if it is due. Fired LAST and fire-and-forget: it needs the warmed
// mTLS stack, and its network round-trip must not be awaited by this function's callers (the
// terminal screen blocks its readiness gate on warmUp). The AndroidKeyStore/Tink reads happen on
// the scheduler's own IO dispatcher, never on `Main`.
runCertificateRotationIfDue()
}
private companion object {
const val ROTATION_TAG: String = "WebTermRotation"
}
}
// ── GET /config/ui — the auto-mode host policy ────────────────────────────────────────────────────────
/**
* Whether a host permits `approve.mode="acceptEdits"` (`GET /config/ui` → `{ allowAutoMode }`).
*
* `ApiClient.uiConfig()` existed with no caller, so the plan-gate three-way sheet offered
* "approve + auto-accept edits" even on a host whose operator had set `ALLOW_AUTO_MODE=0`. This is the
* client half of that policy, and it **fails CLOSED**:
* - a transport failure, a pre-`/config/ui` server, or an unparseable body all answer `false`;
* - only a SUCCESSFUL fetch is cached, so a host that was merely down is re-asked rather than being
* permanently marked one way or the other;
* - `CancellationException` propagates — a cancelled scope must never be read as a policy answer.
*
* The read-only `GET` carries no `Origin` header (plan §4.3) and no credential of its own; the answer is
* a single boolean, so nothing here has to be redacted.
*/
public class UiConfigGate(private val fetch: suspend (HostEndpoint) -> UiConfig) {
private val mutex = Mutex()
private var cache: Map<String, Boolean> = emptyMap()
/** `true` only when [endpoint] positively confirmed it. Never throws except for cancellation. */
public suspend fun allowsAutoMode(endpoint: HostEndpoint): Boolean {
mutex.withLock { cache[endpoint.baseUrl] }?.let { return it }
val allowed = try {
fetch(endpoint).allowAutoMode
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
return false // FAIL CLOSED — and deliberately NOT cached, so a recovered host is re-asked.
}
mutex.withLock { cache = cache + (endpoint.baseUrl to allowed) }
return allowed
}
}
// ── Silent certificate rotation: the app-lifecycle trigger ────────────────────────────────────────────
/**
* The lifecycle edge that CALLS [CertificateRotationScheduler] — the Android counterpart of iOS
* `AppCoordinator.runCertificateRotationIfDue()`. Without a trigger the scheduler is dead code and a
* device certificate simply expires, stranding the app behind an mTLS handshake it can no longer make.
*
* [fire] launches and RETURNS: the pass does AndroidKeyStore/Tink reads and possibly a control-plane
* round-trip, and no UI path may wait on that. The scheduler is idempotent and self-coalescing, so
* firing on launch and again on every foreground needs no external guard.
*
* Nothing escapes: the scheduler turns a rotation failure into `RotationOutcome.Failed` itself, and
* [onError] only sees the (unexpected) case where the scheduler could not even be resolved. Both report
* the error's SHAPE only — a rotation error can embed a control-plane URL or a response body.
*/
public class CertificateRotationTrigger(
private val scope: CoroutineScope,
private val scheduler: () -> CertificateRotationScheduler,
private val onError: (Throwable) -> Unit = {},
) {
/** Start a rotation pass in the background. Idempotent by way of the scheduler's single-flight. */
public fun fire() {
scope.launch {
try {
scheduler().runIfDue()
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
onError(error)
}
}
}
}
// ── Host removal ──────────────────────────────────────────────────────────────────────────────────────
/**
* The `DELETE /push/fcm-token` half of [wang.yaojia.webterm.push.PushRegistrar], as a seam so
* [HostRemover] is JVM-testable with no transport. `di/PushModule` binds it to
* `PushRegistrar.unregisterHost` — whose FIRST caller this is.
*/
public fun interface HostPushUnregister {
/** Best-effort: tell [host] to forget [token]. [token] is never logged (plan §8). */
public suspend fun unregister(host: Host, token: String)
}
/** This device's current FCM registration token, or null when push is unavailable. */
public fun interface PushTokenSource {
public suspend fun currentToken(): String?
}
/**
* Un-pairing a host, as ONE ordered operation. A half-removed host is worse than none, so both the order
* and the failure policy are load-bearing:
*
* 1. **`DELETE /push/fcm-token` FIRST**, while the host record and its `webterm_auth` cookie still
* exist — the call cannot be addressed without the endpoint, nor authenticated without the cookie.
* `PushRegistrar.unregisterHost` had ZERO call sites before this, which is why a dropped host kept
* pushing notifications to the device forever. It is **best-effort**: a host that is switched off
* must still be removable, and the local state is what the user asked us to forget.
* 2. **Then the local state**, in the order that keeps a failure retryable: the session watermarks, the
* last-session pointer, the persisted + live `webterm_auth` cookie, and the paired record LAST. Any
* throw propagates with the host still paired, so the user can simply try again — the UI treats that
* as "nothing was removed" ([HostRemovalGateway]).
*
* ### What is deliberately NOT removed
* - **The device certificate / AndroidKeyStore identity** — it is ONE app-wide mTLS identity shared by
* every host (`IdentityRepository`), not per-host state. Removing it while un-pairing one host would
* silently break every other tunnel host. It has its own explicit affordance on the cert screen.
* - **The quick-reply palette** — global user content, not host state.
*
* ### Watermarks are keyed by SESSION, not host
* `SessionWatermarkStore` maps `sessionId → last-seen`, so there is no host-scoped sweep to run. The
* caller passes the ids it can attribute to this host (its currently-listed sessions); anything it could
* not see is bounded by the store's own 512-entry cap rather than leaking without limit.
*/
public class HostRemover(
private val hostStore: HostStore,
private val lastSessionStore: LastSessionStore,
private val authCookieStore: AuthCookieStore,
private val watermarkStore: SessionWatermarkStore,
private val pushUnregister: HostPushUnregister,
private val currentPushToken: suspend () -> String?,
private val clearLiveCookies: (hostKey: String) -> Unit,
private val log: (String) -> Unit = { Log.w(REMOVER_TAG, it) },
/** Everything below is storage + network I/O; the caller is a Composable scope on `Main`. */
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : HostRemovalGateway {
override suspend fun removeHost(hostId: String, sessionIds: List<String>): Unit =
withContext(ioDispatcher) {
val host = hostStore.loadAll().firstOrNull { it.id == hostId }
?: return@withContext // already gone → no-op
unregisterPushBestEffort(host)
// Local erasure. Ordered so `hostStore.remove` is LAST: an earlier failure leaves the host
// paired, which the UI reports as "nothing removed" and the user can retry.
sessionIds.forEach { watermarkStore.remove(it) }
lastSessionStore.setLastSessionId(null, hostId)
authCookieHostKey(host.endpoint.baseUrl)?.let { hostKey ->
authCookieStore.removeHost(hostKey)
clearLiveCookies(hostKey) // the in-memory jar too, or the credential rides the next dial
}
hostStore.remove(hostId)
}
/**
* Tell the host to forget this device's push token. Best-effort by design; the token is passed only
* to the call and never logged (plan §8), and a failure is reported by SHAPE.
*/
private suspend fun unregisterPushBestEffort(host: Host) {
val token = try {
currentPushToken()
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
log("push token unavailable while removing a host (${error::class.simpleName})")
null
} ?: return
try {
pushUnregister.unregister(host, token)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
log("push de-registration failed for the removed host (${error::class.simpleName})")
}
}
private companion object {
const val REMOVER_TAG: String = "WebTermHostRemoval"
}
}
// ── The WEBTERM_TOKEN gate (server src/http/auth.ts) ─────────────────────────────────────────────────
/**
* The client half of the server's optional shared-access-token gate.
*
* Server contract (`src/http/auth.ts` + the `/auth` route in `src/server.ts`):
* - with `WEBTERM_TOKEN` set, EVERY remote route (and the WS upgrade) needs the `webterm_auth` cookie;
* an unauthed non-HTML request gets **401** with an `authentication required` JSON body;
* - `POST /auth` is always reachable and rate-limited to 10/min/IP. A non-HTML request answers **204**
* with `Set-Cookie: webterm_auth=…` on a match, **401** on a mismatch, **429** over the limit;
* - `GET /?token=` exists too and is deliberately NOT used here — it would put a shell credential in a
* URL (access logs, `Referer`, history).
*
* The cookie itself is never handled here: the shared client's [AuthCookieJar] captures `Set-Cookie` and
* replays it, so "the submit succeeded" and "the credential is held" are the same event.
*
* @param http resolves the shared [HttpTransport] LAZILY (resolving it builds the mTLS `OkHttpClient`, so
* it must happen inside the calling coroutine, not when this gateway is constructed).
*/
public class AccessTokenGateway(private val http: () -> HttpTransport) {
/**
* Is [endpoint] behind the token gate? Probes the unauthenticated read and looks for exactly 401.
*
* Never throws for a network problem: an unreachable host answers `false`, so a host that is merely
* down can never make the app ask the user for a credential. Cancellation still propagates.
*/
public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean {
val response = try {
http().send(HttpRequest(method = HttpMethod.GET, url = endpoint.hostBaseUrl() + LIVE_SESSIONS_PATH))
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
return false
}
return response.status == HTTP_UNAUTHORIZED
}
/**
* `POST /auth` with [token] in an urlencoded form body.
*
* [token] appears in the BODY only — never the URL, never a header, never the returned outcome, never
* a log line — and nothing here retains it.
*/
public suspend fun submit(endpoint: HostEndpoint, token: String): TokenSubmission {
val request = HttpRequest(
method = HttpMethod.POST,
url = endpoint.hostBaseUrl() + AUTH_PATH,
// No `Accept: text/html`: the server answers an HTML request with 302 redirects instead of
// the 204/401 status pair this mapping depends on. No `Origin` either — /auth is registered
// before the gate and is not an Origin-guarded route (plan §4.3: Origin only where required).
headers = mapOf(CONTENT_TYPE_HEADER to FORM_CONTENT_TYPE),
body = "$TOKEN_FIELD=${formEncode(token)}".toByteArray(Charsets.UTF_8),
)
val response = try {
http().send(request)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
// The TYPE only: an exception message is host-influenced and could echo the request we sent.
return TokenSubmission.Unreachable(reason = error::class.simpleName ?: UNKNOWN_ERROR)
}
return when {
response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES -> TokenSubmission.Accepted
response.status == HTTP_UNAUTHORIZED -> TokenSubmission.Rejected
response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited
else -> TokenSubmission.Unreachable(reason = "HTTP ${response.status}")
}
}
private companion object {
const val LIVE_SESSIONS_PATH = "/live-sessions"
const val AUTH_PATH = "/auth"
const val TOKEN_FIELD = "token"
const val CONTENT_TYPE_HEADER = "Content-Type"
const val FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
const val HTTP_OK = 200
const val HTTP_MULTIPLE_CHOICES = 300
const val HTTP_UNAUTHORIZED = 401
const val HTTP_TOO_MANY_REQUESTS = 429
const val UNKNOWN_ERROR = "unknown"
}
}
/**
* `scheme://host[:port]` from the dialed URL — path/query/fragment/credentials dropped. Mirrors
* `:api-client`'s probe derivation (private there, hence re-derived rather than shared); the dialed port
* is kept verbatim and IPv6 literals arrive already bracketed from [java.net.URI].
*/
private fun HostEndpoint.hostBaseUrl(): String {
val uri = URI(baseUrl)
val scheme = (uri.scheme ?: "http").lowercase()
val host = uri.host ?: ""
val portPart = if (uri.port != -1) ":${uri.port}" else ""
return "$scheme://$host$portPart"
}
/**
* Percent-encode a form-field value, allowing ONLY RFC 3986 unreserved characters through.
*
* Stricter than necessary on purpose: the server's token charset (`[A-Za-z0-9._~+/=-]`) includes `+`,
* `/` and `=`, and in an `application/x-www-form-urlencoded` body a raw `+` decodes to a SPACE while `=`
* would split the field — so a correct token would be rejected. `java.net.URLEncoder` is avoided for the
* same reason (it emits `+` for spaces).
*/
private fun formEncode(value: String): String {
val out = StringBuilder(value.length)
for (byte in value.toByteArray(Charsets.UTF_8)) {
val octet = byte.toInt() and BYTE_MASK
val char = octet.toChar()
if (char.isUnreservedUrlChar()) {
out.append(char)
} else {
// Hand-rolled hex, NOT String.format: `%02X` is locale-sensitive and would emit non-ASCII
// digits under some locales, corrupting the credential.
out.append('%').append(HEX_DIGITS[octet shr HEX_SHIFT]).append(HEX_DIGITS[octet and LOW_NIBBLE])
}
}
return out.toString()
}
private fun Char.isUnreservedUrlChar(): Boolean =
this in 'A'..'Z' || this in 'a'..'z' || this in '0'..'9' || this in "-._~"
private const val HEX_DIGITS: String = "0123456789ABCDEF"
private const val BYTE_MASK: Int = 0xFF
private const val LOW_NIBBLE: Int = 0x0F
private const val HEX_SHIFT: Int = 4
// ── Auth-cookie durability bridge (:transport-okhttp ⇄ :host-registry) ───────────────────────────────
/**
* Cold-start restore of the durable `webterm_auth` cookie into the live jar, EXACTLY ONCE.
*
* Once, because the durable write is asynchronous ([AuthCookieJar]'s persister hands off): a second
* hydration could overwrite a freshly re-authenticated in-memory cookie with the stale persisted one and
* silently put the app back on a dead credential.
*
* A failure is swallowed (warm-up must never fail on this) but does NOT consume the one shot, so the next
* warm-up retries. Worst case the user re-enters the token — never a crash, never a plaintext fallback.
*/
internal class AuthCookieHydrator(
private val store: () -> AuthCookieStore,
private val jar: () -> AuthCookieJar,
) {
private val hydrated = AtomicBoolean(false)
/** Reads the store and restores each host's cookies under that host's own key. Call off `Main`. */
internal suspend fun hydrateOnce() {
if (!hydrated.compareAndSet(false, true)) return
val records = try {
store().loadAll()
} catch (error: Throwable) {
hydrated.set(false) // not consumed — a later warm-up may succeed
if (error is CancellationException) throw error
return
}
val cookieJar = jar()
records.groupBy { it.hostKey }.forEach { (hostKey, forHost) ->
cookieJar.restore(hostKey, forHost.map { it.toSnapshot() })
}
}
}
/**
* `:transport-okhttp`'s jar DTO → `:host-registry`'s at-rest record, stamped with the isolation
* [hostKey]. The two modules must not depend on each other (`android/README.md`: dependencies only flow
* down), so `:app` owns this field-for-field mapping. `expiresAtEpochMillis` stays ABSOLUTE — persisting
* a `Max-Age` duration would restart the credential's lifetime on every restore.
*/
internal fun AuthCookieSnapshot.toRecord(hostKey: String): AuthCookieRecord = AuthCookieRecord(
hostKey = hostKey,
name = name,
value = value,
domain = domain,
path = path,
expiresAtEpochMillis = expiresAtEpochMillis,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
)
/** The reverse mapping (cold-start hydration). `hostKey` is dropped — it is the jar's partition key. */
internal fun AuthCookieRecord.toSnapshot(): AuthCookieSnapshot = AuthCookieSnapshot(
name = name,
value = value,
domain = domain,
path = path,
expiresAtEpochMillis = expiresAtEpochMillis,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
)

View File

@@ -0,0 +1,328 @@
package wang.yaojia.webterm.wiring
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.api.enroll.RotationDecision
import wang.yaojia.webterm.api.enroll.RotationPolicy
import wang.yaojia.webterm.tlsandroid.CertStore
import wang.yaojia.webterm.tlsandroid.DeviceEnroller
import wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot
import wang.yaojia.webterm.tlsandroid.DeviceRotationStore
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
import wang.yaojia.webterm.transport.OkHttpClientFactory
import wang.yaojia.webterm.transport.OkHttpHttpTransport
import wang.yaojia.webterm.wire.HttpTransport
import java.time.Duration
import java.time.Instant
import java.util.concurrent.atomic.AtomicReference
/** Which step of a rotation pass an outcome refers to — so a failure names what actually failed. */
public enum class RotationStage {
/** Reading the installed identity's timing out of the encrypted stores. */
READ_STATE,
/** `POST /device/:id/renew` over mTLS with the current (still valid) certificate. */
RENEW,
/** `POST /device/:id/recover` over plain HTTPS with the lapsed certificate in the body. */
RECOVER,
}
/**
* The total result of one rotation pass. Every case is safe to log and to render in the UI: none of
* them can carry a private key, a certificate, a CSR or a token (see [RotationOutcome.Failed]).
*/
public sealed interface RotationOutcome {
/** No enrolled device identity (fresh install / legacy `.p12`): nothing to rotate. */
public data object NotEnrolled : RotationOutcome
/**
* Enrolled, but no control-plane URL is persisted (a record written before it was recorded), so
* there is no host to rotate against. Surfaced rather than guessed — a compiled-in default would
* silently talk to the wrong control plane. A re-enroll records the URL and clears this.
*/
public data class NotConfigured(val deviceId: String) : RotationOutcome
/** The renew window has not opened yet — the cheap, common case. */
public data class NotDue(val renewAfter: Instant?) : RotationOutcome
/** An attempt is due but the previous one failed too recently; the next try is at [retryAt]. */
public data class BackingOff(val retryAt: Instant) : RotationOutcome
/** Renewed silently; the new leaf is presented on the next handshake. */
public data object Renewed : RotationOutcome
/** An expired leaf was recovered over plain HTTPS; the new leaf is live. */
public data object Recovered : RotationOutcome
/**
* TERMINAL — the leaf expired [expiredFor] ago, past the recovery grace. No request can succeed;
* only a fresh enroll can. Reported once per pass and never retried, so one real failure cannot
* become thousands of identical warnings.
*/
public data class ReEnrollRequired(val expiredFor: Duration) : RotationOutcome
/**
* The pass failed at [stage]. The PRIOR identity is fully live and unchanged (the atomic
* live-pointer flip only happens after a successful re-issuance), so a transient failure is
* recoverable on a later pass.
*
* [errorShape] is deliberately the error's SHAPE — its class name, or `Http(status,code)` for a
* server rejection — and never `Throwable.message`, which can embed a URL, a response body or
* credential material.
*/
public data class Failed(val stage: RotationStage, val errorShape: String) : RotationOutcome
}
/**
* Silent device-certificate rotation: on a trigger (app launch / foreground) read the installed
* identity's timing, ask the pure [RotationPolicy] what to do, and drive it. This is the
* "one bootstrap enroll, then never again" half of zero-touch — the Android counterpart of iOS
* `ClientTLS/CertificateRotationScheduler.swift`, which Android was missing entirely (so a device
* certificate simply expired and stranded the app).
*
* It goes beyond the iOS version in one way that matters: iOS can only renew, and `/device/:id/renew`
* is authenticated by the very certificate it renews — so an iOS device whose leaf lapses is stuck
* needing a manual re-enroll. This driver routes a lapsed leaf to `/device/:id/recover` instead
* ([RotationStage.RECOVER]), which takes the expired certificate in the request BODY over PLAIN
* HTTPS because no TLS terminator will forward an expired client certificate at all.
*
* ### The one place allowed to know both halves
* `:api-client` must never gain a `:client-tls-android` edge. This file is the composition point that
* knows both: the pure policy/HTTP client on one side, the keystore-backed enroller on the other.
*
* ### Idempotent, single-flight, and honest
* - Safe to call on EVERY foreground: `NotDue` is the cheap common case and costs no I/O beyond one
* store read.
* - A second trigger while a pass is in flight COALESCES onto it (an `AtomicReference` holding the
* in-flight result), so two foregrounds cannot stampede two renewals into a control plane that
* rate-limits them.
* - A failure never throws out of [runIfDue]: it is recorded as [RotationOutcome.Failed], published
* on [lastOutcome], and used to throttle the next pass. The prior identity stays fully live —
* `IdentityRepository`'s atomic live-pointer flip guarantees that, and nothing here weakens it.
* - A cancelled pass releases the in-flight slot, so cancellation cannot wedge rotation for the
* lifetime of the process.
*
* ### Threading
* The class itself is dispatcher-free (so every branch is unit-testable under virtual time); the
* store/network hop is inside the seams [create] builds, which move the AndroidKeyStore + Tink reads
* off `Dispatchers.Main`.
*
* ### Step-up policy does NOT apply here
* The relay's step-up gate lives in `relay-auth`'s `enforce/onUpgrade.ts` — the terminal-session
* WebSocket upgrade. Renewal and recovery are plain control-plane HTTPS routes and consult no
* step-up policy, so a step-up-required host does not block certificate rotation.
*/
public class CertificateRotationScheduler(
/** Current rotation timing + target, or null when nothing is enrolled. May throw on a store fault. */
private val readState: suspend () -> DeviceRotationSnapshot?,
/** `POST /device/:id/renew` over mTLS against the given control-plane base URL. */
private val renew: suspend (controlPlaneUrl: String) -> Unit,
/** `POST /device/:id/recover` over PLAIN HTTPS against the given control-plane base URL. */
private val recover: suspend (controlPlaneUrl: String) -> Unit,
private val now: () -> Instant = { Instant.now() },
private val expiredRecoveryGrace: Duration = RotationPolicy.DEFAULT_EXPIRED_RECOVERY_GRACE,
private val failureBackoff: Duration = RotationPolicy.DEFAULT_FAILURE_BACKOFF,
private val log: (String) -> Unit = { Log.i(TAG, it) },
) {
private val outcomes = MutableStateFlow<RotationOutcome?>(null)
/**
* The most recent pass's outcome, or null before the first pass. Observable so a silently failing
* renewal becomes visible state (iOS surfaces the same as `isCertificateRenewalFailing`) instead
* of only a log line.
*/
public val lastOutcome: StateFlow<RotationOutcome?> = outcomes.asStateFlow()
/** Written only by the in-flight pass (single-flight); volatile so any dispatcher sees it. */
@Volatile
private var lastFailureAt: Instant? = null
/** Non-null while a pass is running — the shared result concurrent triggers coalesce onto. */
private val inFlight = AtomicReference<CompletableDeferred<RotationOutcome>?>(null)
/**
* Run one pass: read timing → decide → renew/recover if due. Idempotent and safe on every
* foreground; never throws for a rotation failure (that is [RotationOutcome.Failed]). Only
* cancellation propagates.
*/
public suspend fun runIfDue(): RotationOutcome {
while (true) {
inFlight.get()?.let { return it.await() }
val pass = CompletableDeferred<RotationOutcome>()
if (!inFlight.compareAndSet(null, pass)) continue // lost the race — join the winner
try {
val outcome = runPass()
outcomes.value = outcome
inFlight.set(null) // release BEFORE completing so a later trigger starts a fresh pass
pass.complete(outcome)
return outcome
} catch (throwable: Throwable) {
// Cancellation (the only thing that reaches here) must not leave a slot that no
// future pass can ever get past.
inFlight.set(null)
pass.completeExceptionally(throwable)
throw throwable
}
}
}
private suspend fun runPass(): RotationOutcome {
val snapshot = try {
readState()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Exception) {
return recordFailure(RotationStage.READ_STATE, error)
} ?: return RotationOutcome.NotEnrolled
val state = snapshot.renewalState
val at = now()
val failedAt = lastFailureAt
val decision = RotationPolicy.decide(
state = state,
now = at,
lastFailureAt = failedAt,
expiredRecoveryGrace = expiredRecoveryGrace,
failureBackoff = failureBackoff,
)
return when (decision) {
RotationDecision.NOT_DUE -> RotationOutcome.NotDue(state.renewAfter)
RotationDecision.BACKING_OFF ->
// The policy only answers BACKING_OFF when a failure was recorded, so `failedAt` is
// present; fall back to `at` rather than assert, so a logic change cannot crash here.
RotationOutcome.BackingOff((failedAt ?: at).plus(failureBackoff))
RotationDecision.RENEW ->
attempt(RotationStage.RENEW, snapshot, renew, RotationOutcome.Renewed)
RotationDecision.RECOVER ->
attempt(RotationStage.RECOVER, snapshot, recover, RotationOutcome.Recovered)
RotationDecision.RE_ENROLL_REQUIRED -> reEnrollRequired(snapshot, at)
}
}
/**
* Run one re-issuance [operation] against the persisted control plane. The operation is passed in
* rather than selected from [stage] so there is no unreachable "stage that is not an attempt"
* branch to get wrong.
*/
private suspend fun attempt(
stage: RotationStage,
snapshot: DeviceRotationSnapshot,
operation: suspend (controlPlaneUrl: String) -> Unit,
success: RotationOutcome,
): RotationOutcome {
val deviceId = snapshot.renewalState.deviceId
val controlPlaneUrl = snapshot.controlPlaneUrl.trim()
if (controlPlaneUrl.isEmpty()) {
log("rotation: device $deviceId has no persisted control-plane URL; re-enroll to record it")
return RotationOutcome.NotConfigured(deviceId)
}
return try {
operation(controlPlaneUrl)
lastFailureAt = null
log("rotation: device $deviceId certificate ${stage.name.lowercase()} succeeded")
success
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Exception) {
recordFailure(stage, error)
}
}
private fun reEnrollRequired(snapshot: DeviceRotationSnapshot, at: Instant): RotationOutcome {
val notAfter = snapshot.renewalState.notAfter
val expiredFor = if (notAfter == null) Duration.ZERO else Duration.between(notAfter, at)
log(
"rotation: device ${snapshot.renewalState.deviceId} certificate expired ${expiredFor.toDays()}d ago, " +
"past the ${expiredRecoveryGrace.toDays()}d recovery window; a fresh enroll is required",
)
return RotationOutcome.ReEnrollRequired(expiredFor)
}
/**
* Record + report a failure. The prior identity is untouched, so the next pass can succeed. Only
* the error's SHAPE is kept — never `message`, which can embed a URL, a body or a credential.
*/
private fun recordFailure(stage: RotationStage, error: Exception): RotationOutcome {
lastFailureAt = now()
val shape = errorShapeOf(error)
log("rotation: ${stage.name.lowercase()} failed ($shape); the current certificate stays live")
return RotationOutcome.Failed(stage, shape)
}
public companion object {
private const val TAG = "CertRotation"
/**
* The error's non-secret shape. A server rejection keeps its status + uniform `error` code
* (401 = expired beyond grace / untrusted, 403 = revoked or mismatched, 429 = rate limited),
* which is exactly the diagnostic value needed; everything else degrades to the class name.
*/
private fun errorShapeOf(error: Exception): String = when (error) {
is DeviceEnrollmentError.Http ->
"Http(${error.status}${error.code?.let { ",$it" } ?: ""})"
else -> error.javaClass.simpleName
}
/**
* Production wiring — the single composition point, so a DI module needs one provider.
*
* [mtlsTransport] is the app's shared REST transport, which presents the current device
* certificate; [plainTransport] builds a SEPARATE client with NO client identity, used only by
* the recovery path. That separation is load-bearing: an expired leaf presented on a handshake
* makes nginx answer a bare 400 (it will not forward an expired client cert in any
* `ssl_verify_client` mode), which is the deadlock recovery exists to escape. The plain client
* is built lazily, so the common (renew) path never pays for it.
*
* Store reads and both HTTP calls are hopped onto [ioDispatcher] — the first identity touch
* does synchronous AndroidKeyStore + Tink work and must never run on `Dispatchers.Main`.
*
* [sharedClient] / [mtlsTransport] / [plainTransport] are SUPPLIERS, not values, for the same
* reason [EnrollmentFlowFactory] takes `dagger.Lazy`: resolving the shared client builds the
* mTLS `SSLSocketFactory` (keystore I/O). As suppliers they are invoked only inside the
* [ioDispatcher] hop, so a DI provider for this scheduler is itself I/O-free and can be
* resolved from the main thread without risking an ANR.
*/
public fun create(
certStore: CertStore,
recordStore: EnrollmentRecordStore,
cacheRefresher: IdentityCacheRefresher,
sharedClient: () -> OkHttpClient,
mtlsTransport: () -> HttpTransport,
plainTransport: () -> HttpTransport = { OkHttpHttpTransport(OkHttpClientFactory.create()) },
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
now: () -> Instant = { Instant.now() },
log: (String) -> Unit = { Log.i(TAG, it) },
): CertificateRotationScheduler {
val rotationStore = DeviceRotationStore(certStore, recordStore)
fun enroller(controlPlaneUrl: String, withRecovery: Boolean): DeviceEnroller =
DeviceEnroller(
client = DeviceEnrollmentClient(controlPlaneUrl, mtlsTransport()),
certStore = certStore,
recordStore = recordStore,
sharedClient = sharedClient(),
cacheRefresher = cacheRefresher,
recoveryClient =
if (withRecovery) DeviceEnrollmentClient(controlPlaneUrl, plainTransport()) else null,
)
return CertificateRotationScheduler(
readState = { withContext(ioDispatcher) { rotationStore.read() } },
renew = { url -> withContext(ioDispatcher) { enroller(url, withRecovery = false).renew() } },
recover = { url -> withContext(ioDispatcher) { enroller(url, withRecovery = true).recover() } },
now = now,
log = log,
)
}
}
}

View File

@@ -62,8 +62,10 @@ public class TerminalSessionControllerImpl(
) : TerminalSessionController by base {
/**
* The forked Termux emulator for this session (no local process). The screen puts it on-screen via
* `attachView` and drives `updateSize`; A21 feeds it the remote output and routes its replies out.
* The forked Termux emulator for this session (no local process). The screen only puts it on-screen
* (`attachView`) / takes it off (`detachView`); the grid is driven from INSIDE `:terminal-view`
* (`RemoteTerminalHostView.onSizeChanged` → `TerminalResizeDriver` → `updateSize`), so nothing in `:app`
* computes cols×rows. A21's own two jobs are feeding it the remote output and routing its replies out.
*/
public val remoteSession: RemoteTerminalSession =
remoteSessionFactory(::routeEmulatorMessage) { raw -> onSanitizedTitle(TitleSanitizer.sanitize(raw)) }

View File

@@ -24,56 +24,129 @@ import kotlin.math.ceil
*
* `TerminalRenderer`'s own `mFontWidth`/`mFontLineSpacing` are package-private, so this computes its own
* cell metrics from the [Paint] (`ceil(measureText("X"))` and the ascent-to-descent span), exactly as
* `TerminalRenderer` derives them.
* `TerminalRenderer` derives them — as a TEMPLATE that [ThumbnailBudget.cellMetrics] then shrinks to the
* thumbnail budget, so the raster is born at roughly the size the card displays.
*
* ### Two bounds, both server-input driven (both are pure and JVM-tested)
* - [ThumbnailGrid] clamps `cols`/`rows` (SERVER-supplied; `src/protocol.ts` validates resize only to
* 1..1000) and — load-bearing — derives the emulator's `transcriptRows` so it is never below the screen
* height. Termux's `TerminalBuffer.externalToInternalRow` is `(mScreenFirstRow + row) % mTotalRows`, so
* a `transcriptRows < rows` buffer ALIASES external rows onto each other and the thumbnail draws
* duplicated rows (this was the bug: a fixed `TERMINAL_TRANSCRIPT_ROWS_MIN` = 100 against `rows` ≤ 200).
* - [ThumbnailBudget.cellMetrics] shrinks the CELL so the full-resolution intermediate can never exceed
* [ThumbnailBudget.MAX_WIDTH_PX] × [ThumbnailBudget.MAX_HEIGHT_PX] (≤ 900 KiB ARGB_8888) — previously a
* legitimately-sized 1000×1000 session forced a 31 MB allocation per render, ×2 concurrent permits, on
* every 5 s poll tick, which degrades safely (OOM is caught) but invites an LMK kill of the whole app.
*
* android.graphics is stubbed in JVM unit tests, so this class is exercised by device QA (plan §7,
* `:terminal-view` instrumented "thumbnail rasterisation non-null"); the pipeline policy around it is
* JVM-tested via the [ThumbnailRasterizer] seam.
* JVM-tested via the [ThumbnailRasterizer] seam, and its two size bounds via [ThumbnailGrid] /
* [ThumbnailBudget] (pure) plus a real off-screen [TerminalEmulator] (pure Java — no android.graphics).
*/
public class CanvasThumbnailRasterizer(
private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX,
typeface: Typeface = Typeface.MONOSPACE,
private val maxWidthPx: Int = ThumbnailBudget.MAX_WIDTH_PX,
private val maxHeightPx: Int = ThumbnailBudget.MAX_HEIGHT_PX,
) : ThumbnailRasterizer {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
/**
* METRICS ONLY — never used to draw. `ThumbnailPipeline` renders up to `MAX_CONCURRENT_RENDERS` (2)
* thumbnails at once on ONE rasterizer instance, so a shared draw `Paint` would be mutated
* (colour/bold/style per cell) by two coroutines concurrently — a data race producing wrong colours or
* a torn draw. Each [rasterize] therefore copies this template into its own local `Paint`.
*/
private val metricsPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.typeface = typeface
textSize = fontSizePx
}
private val cellWidth: Int = ceil(paint.measureText("X").toDouble()).toInt().coerceAtLeast(1)
private val cellHeight: Int = (paint.fontMetricsInt.descent - paint.fontMetricsInt.ascent).coerceAtLeast(1)
private val baselineOffset: Int = -paint.fontMetricsInt.ascent
/** The UNSCALED cell size at [fontSizePx]; [ThumbnailBudget.cellMetrics] shrinks it per render. */
private val templateCellWidth: Int = ceil(metricsPaint.measureText("X").toDouble()).toInt().coerceAtLeast(1)
private val templateCellHeight: Int =
(metricsPaint.fontMetricsInt.descent - metricsPaint.fontMetricsInt.ascent).coerceAtLeast(1)
private val templateBaseline: Int = -metricsPaint.fontMetricsInt.ascent
override fun rasterize(preview: SessionPreview): Bitmap {
val cols = preview.cols.coerceIn(1, MAX_COLS)
val rows = preview.rows.coerceIn(1, MAX_ROWS)
val emulator = TerminalEmulator(NoOpOutput, cols, rows, TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN, NoOpClient)
val grid = ThumbnailGrid.of(preview.cols, preview.rows)
val cell = ThumbnailBudget.cellMetrics(
cols = grid.cols,
rows = grid.rows,
templateWidth = templateCellWidth,
templateHeight = templateCellHeight,
maxWidth = maxWidthPx,
maxHeight = maxHeightPx,
)
val emulator = TerminalEmulator(NoOpOutput, grid.cols, grid.rows, grid.transcriptRows, NoOpClient)
val bytes = preview.data.toByteArray(Charsets.UTF_8)
emulator.append(bytes, bytes.size)
val bitmap = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
// Per-render Paint (see [metricsPaint]); its text size follows the shrunken cell, so a glyph never
// smears across the neighbouring cells of a downsized grid.
val paint = Paint(metricsPaint).apply { textSize = fontSizePx * cell.textScale }
val geometry = RenderGeometry(
cols = grid.cols,
cellWidth = cell.width,
cellHeight = cell.height,
baseline = (templateBaseline * cell.textScale).toInt().coerceIn(1, cell.height),
)
val full = Bitmap.createBitmap(
grid.cols * cell.width,
grid.rows * cell.height,
Bitmap.Config.ARGB_8888,
)
val canvas = Canvas(full)
val palette = emulator.mColors.mCurrentColors
val defaultBg = palette[TextStyle.COLOR_INDEX_BACKGROUND]
canvas.drawColor(defaultBg)
for (row in 0 until rows) {
drawRow(canvas, emulator, row, cols, palette, defaultBg)
for (row in 0 until grid.rows) {
drawRow(canvas, paint, emulator, row, geometry, palette, defaultBg)
}
return bitmap
return downscaleToBudget(full)
}
override fun placeholder(): Bitmap {
val bitmap = Bitmap.createBitmap(cellWidth, cellHeight, Bitmap.Config.ARGB_8888)
val bitmap = Bitmap.createBitmap(templateCellWidth, templateCellHeight, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(PLACEHOLDER_COLOR)
return bitmap
}
private fun drawRow(canvas: Canvas, emulator: TerminalEmulator, row: Int, cols: Int, palette: IntArray, defaultBg: Int) {
/**
* Safety net over [ThumbnailBudget.cellMetrics]: the cell shrink already keeps the raster inside the
* budget, so this is normally a no-op (`scaledSize` returns `null` ⇒ no second allocation). It stays
* because it is the ONE place that bounds a bitmap that arrived larger than the budget by any route,
* and it recycles the oversized source immediately rather than caching multiple MiB per session.
*/
private fun downscaleToBudget(full: Bitmap): Bitmap {
val target = ThumbnailBudget.scaledSize(full.width, full.height, maxWidthPx, maxHeightPx) ?: return full
val scaled = Bitmap.createScaledBitmap(full, target.width, target.height, true)
if (scaled !== full) full.recycle()
return scaled
}
/** Immutable per-render draw geometry (the grid width plus the px size of one cell). */
private data class RenderGeometry(
val cols: Int,
val cellWidth: Int,
val cellHeight: Int,
val baseline: Int,
)
private fun drawRow(
canvas: Canvas,
paint: Paint,
emulator: TerminalEmulator,
row: Int,
geometry: RenderGeometry,
palette: IntArray,
defaultBg: Int,
) {
val screen = emulator.screen
val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row))
val text = line.mText
val top = (row * cellHeight).toFloat()
val baseline = (row * cellHeight + baselineOffset).toFloat()
for (col in 0 until cols) {
val top = (row * geometry.cellHeight).toFloat()
val baseline = (row * geometry.cellHeight + geometry.baseline).toFloat()
for (col in 0 until geometry.cols) {
val style = line.getStyle(col)
val effect = TextStyle.decodeEffect(style)
val bold = (effect and (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0
@@ -83,11 +156,11 @@ public class CanvasThumbnailRasterizer(
var bg = resolveColor(TextStyle.decodeBackColor(style), palette, boldBright = false)
if (inverse) { val swap = fg; fg = bg; bg = swap }
val left = (col * cellWidth).toFloat()
val left = (col * geometry.cellWidth).toFloat()
if (bg != defaultBg) {
paint.color = bg
paint.style = Paint.Style.FILL
canvas.drawRect(left, top, left + cellWidth, top + cellHeight, paint)
canvas.drawRect(left, top, left + geometry.cellWidth, top + geometry.cellHeight, paint)
}
val start = line.findStartOfColumn(col)
val end = line.findStartOfColumn(col + 1)
@@ -111,13 +184,9 @@ public class CanvasThumbnailRasterizer(
}
public companion object {
/** Default glyph size for a thumbnail (px). Small — the card downscales anyway. */
/** Default glyph size for a thumbnail (px). Small — the cell shrink and the card scale it anyway. */
public const val DEFAULT_FONT_SIZE_PX: Float = 12f
/** Clamp the off-screen grid so a rogue preview geometry can't allocate an enormous bitmap. */
public const val MAX_COLS: Int = 400
public const val MAX_ROWS: Int = 200
/** Opaque dark-grey placeholder for the fetch/render-failure fallback. */
public const val PLACEHOLDER_COLOR: Int = -0xdfdfe0 // 0xFF202020
@@ -126,6 +195,148 @@ public class CanvasThumbnailRasterizer(
}
}
/**
* The off-screen emulator's GRID, derived from a preview's SERVER-supplied geometry. Pure, so the two
* things that are easy to get catastrophically wrong are JVM-testable (android.graphics is stubbed
* off-device).
*
* `cols`/`rows` come off the wire: `src/protocol.ts` validates a resize only to 1..1000, so a preview may
* legitimately claim 1000×1000 and the grid must be clamped before anything is allocated from it.
*/
public object ThumbnailGrid {
/**
* Grid clamp, tied to the raster budget rather than picked by feel: one cell can never be narrower or
* shorter than 1 px, so a grid wider than [ThumbnailBudget.MAX_WIDTH_PX] (or taller than
* [ThumbnailBudget.MAX_HEIGHT_PX]) could not fit the budget at ANY cell size. Everything a real
* terminal reports — 80×24 up to a 4K-monitor 400×120 — is far inside this, so the clamp only ever
* crops geometry no terminal actually has.
*/
public const val MAX_COLS: Int = ThumbnailBudget.MAX_WIDTH_PX
public const val MAX_ROWS: Int = ThumbnailBudget.MAX_HEIGHT_PX
/** The clamped screen grid plus the emulator's scrollback depth. [transcriptRows] is always ≥ [rows]. */
public data class Grid(val cols: Int, val rows: Int, val transcriptRows: Int)
/**
* Clamp [cols]/[rows] into the budget and pick the emulator's `transcriptRows`.
*
* **`transcriptRows` MUST be ≥ `rows`.** Termux's `TerminalBuffer` keeps `mTotalRows = transcriptRows`
* and maps `externalToInternalRow(row) = (mScreenFirstRow + row) % mTotalRows`, so a buffer with fewer
* total rows than screen rows silently aliases external row *n* onto *n mTotalRows* — the thumbnail
* would draw duplicated rows and the emulator would scroll against invalid state during `append`.
* The floor is `TERMINAL_TRANSCRIPT_ROWS_MIN` because `TerminalEmulator` itself rejects anything below
* it (falling back to its 2000-row default), which is more scrollback than a preview ever needs.
*/
public fun of(cols: Int, rows: Int): Grid {
val clampedRows = rows.coerceIn(1, MAX_ROWS)
return Grid(
cols = cols.coerceIn(1, MAX_COLS),
rows = clampedRows,
transcriptRows = clampedRows.coerceAtLeast(TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN),
)
}
}
/**
* The pure raster-size budget for a preview thumbnail: how large a rasterised terminal screen may be, both
* as drawn ([cellMetrics]) and as kept ([scaledSize]). Split out of [CanvasThumbnailRasterizer] so the
* arithmetic is JVM-unit-testable (android.graphics is stubbed off-device), while the `Bitmap`/`Canvas`
* work stays device-QA.
*
* All of it is integer arithmetic on purpose: a float `floor` could round a cell size *up* past the bound
* it is supposed to enforce, and the bound is the thing standing between a rogue geometry and an LMK kill.
*/
public object ThumbnailBudget {
/**
* Max thumbnail width (px). The row tile is ~96dp (≈288px at 3× density) and drawn with
* `ContentScale.Crop`, so 480px keeps it crisp on any density.
*/
public const val MAX_WIDTH_PX: Int = 480
/**
* Max thumbnail height (px). A terminal screen is wider than tall (80×24 lands near 480×288), so this
* only binds a tall/narrow session — but it MUST exist: with a width-only bound a 20×200 session was
* unbounded in height (it rasterised to 140×2800 = 1.5 MiB and was cached at full size, because its
* width was already inside the budget).
*/
public const val MAX_HEIGHT_PX: Int = 480
/** A raster target size in px. */
public data class RasterSize(val width: Int, val height: Int)
/** The px size of ONE cell, plus the text-size factor that keeps glyphs inside it. */
public data class CellMetrics(val width: Int, val height: Int, val textScale: Float)
/**
* The cell size to draw [cols]×[rows] at, shrunk from the font's own [templateWidth]×[templateHeight]
* so the whole raster fits [maxWidth]×[maxHeight] — the screen is never cropped to fit, the cells are.
*
* Guarantees, for `cols ≤ maxWidth` and `rows ≤ maxHeight` (which [ThumbnailGrid] enforces):
* - `cols * width ≤ maxWidth` and `rows * height ≤ maxHeight` — so the ARGB_8888 intermediate is at
* most `maxWidth * maxHeight * 4` bytes (900 KiB at the defaults) whatever the server reports;
* - the cell aspect ratio is preserved (both axes take the SAME scale factor), so text keeps its
* shape instead of being squeezed on one axis;
* - never upscales (the factor is capped at 1) and never returns a 0-px cell.
*/
public fun cellMetrics(
cols: Int,
rows: Int,
templateWidth: Int,
templateHeight: Int,
maxWidth: Int = MAX_WIDTH_PX,
maxHeight: Int = MAX_HEIGHT_PX,
): CellMetrics {
val cellWidth = templateWidth.coerceAtLeast(1)
val cellHeight = templateHeight.coerceAtLeast(1)
val scale = fitScale(
width = cols.coerceAtLeast(1).toLong() * cellWidth,
height = rows.coerceAtLeast(1).toLong() * cellHeight,
maxWidth = maxWidth,
maxHeight = maxHeight,
)
return CellMetrics(
width = scale.scaled(cellWidth),
height = scale.scaled(cellHeight),
textScale = scale.factor,
)
}
/**
* The size [width]×[height] should be scaled to, or `null` when it already fits [maxWidth]×[maxHeight]
* (draw as rendered — no extra allocation). Aspect ratio is preserved (the tighter axis wins) and
* neither side ever collapses to 0.
*/
public fun scaledSize(
width: Int,
height: Int,
maxWidth: Int = MAX_WIDTH_PX,
maxHeight: Int = MAX_HEIGHT_PX,
): RasterSize? {
if (width <= 0 || height <= 0) return null
if (width <= maxWidth && height <= maxHeight) return null
val scale = fitScale(width.toLong(), height.toLong(), maxWidth, maxHeight)
return RasterSize(width = scale.scaled(width), height = scale.scaled(height))
}
/**
* The largest factor ≤ 1 that fits [width]×[height] inside [maxWidth]×[maxHeight], as an EXACT fraction
* (see the class note on integer arithmetic — a float would round a size up past the bound it enforces).
*/
private fun fitScale(width: Long, height: Long, maxWidth: Int, maxHeight: Int): Scale = when {
width <= maxWidth && height <= maxHeight -> Scale(1L, 1L)
maxWidth.toLong() * height <= maxHeight.toLong() * width -> Scale(maxWidth.toLong(), width)
else -> Scale(maxHeight.toLong(), height)
}
/** A shrink factor `num/den` (≤ 1), applied to px sizes by exact integer division. */
private data class Scale(private val num: Long, private val den: Long) {
/** [px] shrunk by this factor, floored, never below 1 px. */
fun scaled(px: Int): Int = (px * num / den).toInt().coerceAtLeast(1)
val factor: Float get() = num.toFloat() / den.toFloat()
}
}
/** Off-screen emulator sink: the preview emulator produces no wire output and needs no delegates. */
private object NoOpOutput : TerminalOutput() {
override fun write(data: ByteArray?, offset: Int, count: Int) = Unit

View File

@@ -6,17 +6,24 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.routes.ApiClient
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Off-screen terminal-preview rasterisation for the session-list / manage-page thumbnails (plan §6.7).
@@ -28,7 +35,7 @@ import java.util.UUID
* [ThumbnailRasterizer] seam so this policy (cache, concurrency, dedup, failure fallback) stays
* JVM-unit-testable while the pixel raster is verified on-device (android.graphics is stubbed off-device).
*
* ### The four policies (plan §6.7)
* ### The policies (plan §6.7)
* - **Cache** — an [LruCache] keyed on `(sessionId, lastOutputAt)`. `lastOutputAt` is the server's
* last-PTY-output timestamp, so an UNCHANGED `lastOutputAt` means the screen is byte-identical and we
* return the cached bitmap WITHOUT re-fetching or re-rendering. Keying goes through the [BitmapCache]
@@ -37,8 +44,16 @@ import java.util.UUID
* grid of many cards never spawns dozens of concurrent emulators / large-bitmap allocations.
* - **In-flight dedup** — a `Map<Key, Deferred<Bitmap>>` under a [Mutex]: a second request for a key
* already rendering AWAITS the first's [Deferred] instead of starting a duplicate fetch/render.
* - **Failure fallback** — any fetch/render failure caches the rasterizer's PLACEHOLDER bitmap under the
* same key, so a broken/oversized session is not retried in a hot scroll loop.
* - **Supersede (coalesce per session)** — `lastOutputAt` advances on every PTY output and the list polls
* every 5 s, so an ACTIVE session mints a new key on every tick. Only the newest key per session id may
* be in flight: a newer key CANCELS the older render, which both frees its permit for the key the row is
* actually showing (otherwise obsolete work queues ahead of it and the tile visibly lags) and stops the
* unbounded growth of `inFlight` once per-render latency exceeds the 5 s arrival rate on a slow link.
* - **Timeout** — the fetch+render of one thumbnail is bounded (see [DEFAULT_RENDER_TIMEOUT_MS]). Without
* it two slow-trickle previews hold both permits forever (OkHttp sets no `callTimeout`, and its
* per-socket read timeout is reset by every trickled byte) and NO thumbnail renders again until [close].
* - **Failure fallback** — any fetch/render failure, timeout included, caches the rasterizer's PLACEHOLDER
* bitmap under the same key, so a broken/oversized session is not retried in a hot scroll loop.
*
* The render coroutine runs in this pipeline's own [scope] (not the caller's), so a caller that scrolls
* a card off-screen and cancels its collect never cancels a shared render other cards still await.
@@ -49,6 +64,7 @@ public class ThumbnailPipeline(
private val cache: BitmapCache = LruBitmapCache(DEFAULT_CACHE_BYTES),
dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Default,
maxConcurrent: Int = MAX_CONCURRENT_RENDERS,
private val renderTimeoutMs: Long = DEFAULT_RENDER_TIMEOUT_MS,
) {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@@ -56,16 +72,36 @@ public class ThumbnailPipeline(
private val renderGate = Semaphore(maxConcurrent)
/** In-flight renders by key; guarded by [inFlightMutex]. A second same-key request shares the entry. */
private val inFlight = HashMap<ThumbnailKey, Deferred<Bitmap>>()
private val inFlight = HashMap<ThumbnailKey, Deferred<Bitmap?>>()
private val inFlightMutex = Mutex()
/**
* The cached (or freshly rendered) thumbnail for [session]. Returns the cached bitmap immediately when
* `(id, lastOutputAt)` is unchanged; otherwise fetches the preview over mTLS, rasterises off-screen,
* caches, and returns it. Never throws for a fetch/render failure — a placeholder is cached and
* returned instead (only cooperative cancellation of [scope] propagates).
* The newest key requested per session id — the supersede rule's state, and the cache-publish gate.
* Concurrent (not [inFlightMutex]-guarded) because [retainOnly] is a plain, non-suspending call from a
* `LaunchedEffect` and must be able to drop a killed session's entry without a coroutine.
*/
public suspend fun thumbnail(session: LiveSessionInfo): Bitmap {
private val latestKeyBySession = ConcurrentHashMap<UUID, ThumbnailKey>()
/**
* Orders a cache PUBLISH against a concurrent [retainOnly] prune, so a render that finishes after its
* session was pruned cannot re-insert a bitmap the prune just dropped. Always the INNERMOST lock (never
* held across a suspension), so it cannot participate in a cycle with [inFlightMutex].
*/
private val publishLock = Any()
/**
* The cached (or freshly rendered) thumbnail for [session], or `null` when no bitmap at all could be
* produced. Returns the cached bitmap immediately when `(id, lastOutputAt)` is unchanged; otherwise
* fetches the preview over mTLS, rasterises off-screen, caches, and returns it.
*
* **Never throws for a fetch/render failure** — a fetch/raster failure or a timeout caches the
* rasterizer's placeholder (no retry storm), and the residual case where even the placeholder cannot be
* allocated (a `Bitmap` OOM) degrades to `null` rather than an exception: the caller is a row's
* `LaunchedEffect`, so a throw would take down the composition. Only cancellation of the CALLER
* propagates; a render killed by [close] or superseded by a newer key for the same session also
* surfaces as `null` (the caller is still alive — it just has no thumbnail for this key).
*/
public suspend fun thumbnail(session: LiveSessionInfo): Bitmap? {
val key = ThumbnailKey(session.id, session.lastOutputAt)
// Fast path: an unchanged lastOutputAt ⇒ identical screen ⇒ never re-render (§6.7).
cache.get(key)?.let { return it }
@@ -74,9 +110,40 @@ public class ThumbnailPipeline(
// Re-check under the lock: a render that finished between the fast path and here has already
// populated the cache and removed its in-flight entry.
cache.get(key)?.let { return it }
// A CLOSED pipeline must not insert: `scope.async` on a cancelled scope never runs the body, so
// the finally that frees the slot never runs and the key would keep a dead Deferred forever
// (every later request awaiting it for null, and `inFlight` growing by every distinct key).
if (!scope.isActive) return null
supersedeOlderRenders(key)
inFlight[key] ?: startRender(key).also { inFlight[key] = it }
}
return deferred.await()
return try {
deferred.await()
} catch (cancel: CancellationException) {
// Distinguish "the render was closed/superseded under us" (→ no thumbnail) from "OUR caller was
// cancelled" (→ propagate, so the collector tears down as structured concurrency requires).
if (currentCoroutineContext().isActive) null else throw cancel
}
}
/**
* Release every cached bitmap that is not part of the CURRENT live list — killed/exited sessions and
* superseded `lastOutputAt` keys — so the cache holds only what the chooser can still display. The
* [LruCache] byte budget already bounds worst-case memory; this returns the memory promptly instead of
* waiting for eviction pressure, and keeps dead sessions from occupying the budget at all.
*
* A render still in flight for a session that just disappeared can no longer re-insert its bitmap after
* this prune: dropping the session's [latestKeyBySession] entry closes the publish gate, and both sides
* run under [publishLock] so the check cannot straddle the prune. (A caller that KEEPS asking for a
* pruned session is a different thing and still caches — it asked.)
*/
public fun retainOnly(live: Collection<LiveSessionInfo>) {
val liveIds = live.mapTo(HashSet()) { it.id }
val liveKeys = live.mapTo(HashSet()) { ThumbnailKey(it.id, it.lastOutputAt) }
synchronized(publishLock) {
latestKeyBySession.keys.retainAll(liveIds)
cache.retainOnly(liveKeys)
}
}
/** Cancel all in-flight renders and release the pipeline scope (call from the owner's teardown). */
@@ -84,40 +151,133 @@ public class ThumbnailPipeline(
scope.cancel()
}
private fun startRender(key: ThumbnailKey): Deferred<Bitmap> = scope.async {
val bitmap = renderGuarded(key.sessionId)
// Publish under the lock so a concurrent [thumbnail] re-check sees the result the instant this
// key stops being in-flight — success AND placeholder are cached identically (§6.7 no-retry-storm).
inFlightMutex.withLock {
cache.put(key, bitmap)
inFlight.remove(key)
}
bitmap
/** Test-only: live in-flight entries. A non-zero count after [close] would be a leaked key. */
internal suspend fun inFlightCount(): Int = inFlightMutex.withLock { inFlight.size }
/**
* Make [key] the session's newest key and abandon any older in-flight render for the SAME session — the
* supersede rule. Caller MUST hold [inFlightMutex]; `Deferred.cancel` does not suspend, so cancelling
* from inside the critical section is safe (the render's own `finally` takes the mutex afterwards).
*
* An out-of-order request for an OLDER screen than one already in flight is left alone: it still renders
* and returns a bitmap to its caller, but it does not become the newest key, so the publish gate keeps
* its result out of the cache instead of overwriting fresher work.
*/
private fun supersedeOlderRenders(key: ThumbnailKey) {
val previous = latestKeyBySession[key.sessionId]
if (previous == key) return
if (previous != null && depictsOlderScreen(key, previous)) return
latestKeyBySession[key.sessionId] = key
previous?.let { inFlight.remove(it)?.cancel() }
}
private suspend fun renderGuarded(sessionId: UUID): Bitmap =
private fun startRender(key: ThumbnailKey): Deferred<Bitmap?> = scope.async {
var rendered: Bitmap? = null
try {
rendered = renderGuarded(key.sessionId)
rendered
} finally {
// Release the in-flight slot on EVERY path the body can leave by — normal return, cancellation
// (closed/superseded) and an unexpected throw — because a retained entry would poison the key:
// every later request for it would await a dead Deferred forever. NonCancellable so the cleanup
// still runs while cancelling. (The one path that cannot run this is a scope cancelled between
// the `isActive` check in [thumbnail] and `scope.async` itself, where the body never starts —
// the pipeline is closed by then and this whole map is discarded with it.)
withContext(NonCancellable) {
inFlightMutex.withLock {
// Publish under the same lock, so a concurrent [thumbnail] re-check sees the result the
// instant the key stops being in-flight. Success AND placeholder cache identically
// (§6.7 no-retry-storm); an unproducible bitmap caches nothing and may be retried.
publish(key, rendered)
inFlight.remove(key)
}
}
}
}
/**
* Cache [bitmap] under [key] unless the key is no longer the one the UI wants: superseded by newer
* output for the same session, or pruned by [retainOnly] because the session is gone. Caching either
* would spend the LRU budget on a bitmap nothing will ever request again.
*/
private fun publish(key: ThumbnailKey, bitmap: Bitmap?) {
if (bitmap == null) return
synchronized(publishLock) {
if (latestKeyBySession[key.sessionId] != key) return
cache.put(key, bitmap)
}
}
private suspend fun renderGuarded(sessionId: UUID): Bitmap? =
try {
renderGate.withPermit {
val preview = previewSource.fetch(sessionId)
rasterizer.rasterize(preview)
// The timeout is INSIDE the permit so it budgets the work, not the wait for a permit, and
// the catch/fallback is OUTSIDE it so no placeholder is ever allocated holding a permit.
withTimeout(renderTimeoutMs) {
val preview = previewSource.fetch(sessionId)
rasterizer.rasterize(preview)
}
}
} catch (timeout: TimeoutCancellationException) {
// MUST precede the CancellationException branch — a timeout IS one. Rethrowing it would turn a
// slow host into a permanently dead feature: the two permits are the only ones there are, and a
// render is deliberately not cancellable by any caller.
placeholderOrNull()
} catch (e: CancellationException) {
throw e // never swallow cooperative cancellation
throw e // never swallow cooperative cancellation (closed pipeline / superseded key)
} catch (t: Throwable) {
// Any fetch/render failure (network, oversized body, malformed escape) → cache a placeholder
// so the broken session isn't retried on every scroll frame (§6.7).
rasterizer.placeholder()
// so the broken session isn't retried on every scroll frame (§6.7). If even the placeholder
// cannot be allocated, give up with null — never throw at a UI collector.
placeholderOrNull()
}
/**
* The failure placeholder, or `null` when even that cannot be allocated (a `Bitmap` OOM). The failure
* itself is deliberately NOT logged: a preview failure can carry attacker-influenced text, and logcat
* must never see preview bytes (plan §8).
*/
private fun placeholderOrNull(): Bitmap? = runCatching { rasterizer.placeholder() }.getOrNull()
/** `true` when [key] depicts an older screen than [other] (a smaller server `lastOutputAt`). */
private fun depictsOlderScreen(key: ThumbnailKey, other: ThumbnailKey): Boolean =
(key.lastOutputAt ?: 0L) < (other.lastOutputAt ?: 0L)
public companion object {
/** Plan §6.7: the render/fetch fan-out is capped at 2 concurrent renders. */
public const val MAX_CONCURRENT_RENDERS: Int = 2
/** Default LRU budget for cached thumbnails (bytes). Small — thumbnails are transient UI cache. */
public const val DEFAULT_CACHE_BYTES: Int = 8 * 1024 * 1024
/**
* Budget for ONE fetch+render once it holds a permit. Generous next to a small preview GET over the
* shared client (OkHttp's own defaults are 10 s connect / 10 s per-read) — this is the liveness
* backstop for the trickle case those per-socket timeouts cannot catch, not a latency target.
*/
public const val DEFAULT_RENDER_TIMEOUT_MS: Long = 15_000L
}
}
/**
* Build the production preview pipeline for ONE host: the read-only [ApiPreviewSource] over that host's
* [ApiClient] (the shared mTLS `OkHttpClient`) feeding the [CanvasThumbnailRasterizer] off-screen painter,
* with the default LRU budget / `Semaphore(2)` cap / `Dispatchers.Default` render pool.
*
* This is the ONE construction point (`SessionsHome` wires the active host through it), so nothing else
* has to know which rasterizer or which cache the session chooser uses. [api] is a PROVIDER and is
* resolved lazily on the first fetch — inside the pipeline's own background dispatcher — so calling this
* from a composition does NO `OkHttpClient` / AndroidKeyStore / Tink work on `Main`.
*
* The owner MUST call [ThumbnailPipeline.close] when the surface goes away (the render scope outlives any
* single caller by design).
*/
public fun sessionThumbnailPipeline(api: () -> ApiClient): ThumbnailPipeline =
ThumbnailPipeline(
previewSource = ApiPreviewSource(api),
rasterizer = CanvasThumbnailRasterizer(),
)
/**
* Cache key: an unchanged `(sessionId, lastOutputAt)` pair means the server's last PTY output is
* unchanged ⇒ the preview is byte-identical ⇒ the cached bitmap is reused verbatim (plan §6.7). A `null`
@@ -144,10 +304,17 @@ public interface ThumbnailRasterizer {
public fun placeholder(): Bitmap
}
/** The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable. */
/**
* The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable.
* Implementations MUST be thread-safe: [ThumbnailPipeline] reads it from an unsynchronised fast path and
* writes it from its render coroutines ([LruCache] is internally synchronised).
*/
public interface BitmapCache {
public fun get(key: ThumbnailKey): Bitmap?
public fun put(key: ThumbnailKey, bitmap: Bitmap)
/** Drop every entry whose key is not in [keys] (dead sessions / superseded `lastOutputAt`). */
public fun retainOnly(keys: Set<ThumbnailKey>)
}
/**
@@ -164,27 +331,57 @@ public class LruBitmapCache(maxBytes: Int) : BitmapCache {
override fun put(key: ThumbnailKey, bitmap: Bitmap) {
lru.put(key, bitmap)
}
/** `snapshot()` is a copy, so removing while iterating it is safe (and `remove` is synchronised). */
override fun retainOnly(keys: Set<ThumbnailKey>) {
for (key in lru.snapshot().keys) {
if (key !in keys) lru.remove(key)
}
}
}
/**
* Production [PreviewSource] over an [ApiClient] (which sends through the shared mTLS `HttpTransport`, so
* tunnel-host previews traverse nginx). Re-caps the body at 256 KiB client-side — defence-in-depth over
* the server's own cap (plan §6.7 / §4.2).
* tunnel-host previews traverse nginx). `GET /live-sessions/:id/preview` is a READ-ONLY route: it does NOT
* attach, register a client, or touch the session's idle clock. Bounds the emulator's input with
* [PreviewCap] (plan §6.7 / §4.2 — read its note on what that bound can and cannot do).
*
* [api] is a PROVIDER, not a client: resolving it resolves the shared `HttpTransport`, which builds the one
* `OkHttpClient` and first-touches the AndroidKeyStore/Tink mTLS material. That must never happen on the
* composition (`Main`) thread, so the client is materialised lazily inside [fetch], which runs on the
* pipeline's own background dispatcher (plan §"off-`Main` warm-up").
*/
public class ApiPreviewSource(private val apiClient: ApiClient) : PreviewSource {
public class ApiPreviewSource(api: () -> ApiClient) : PreviewSource {
private val apiClient: ApiClient by lazy(api)
override suspend fun fetch(sessionId: UUID): SessionPreview =
PreviewCap.cap(apiClient.preview(sessionId))
}
/** Client-side preview-size guard (plan §6.7: cap data at 256 KiB). Pure — JVM-testable. */
/**
* Client-side bound on how much preview text reaches the off-screen emulator. Pure — JVM-testable.
*
* **What this is NOT:** it is not a defence against an oversized RESPONSE BODY. By the time it runs, the
* transport has already buffered the whole body into a `ByteArray` (`OkHttpHttpTransport` calls
* `body.bytes()`) and `ApiClient.preview` has decoded that into JSON and a `String` — so a rogue multi-MB
* body has already been allocated two or three times over, and capping here allocates one more copy. A real
* body bound has to live in the transport (an OkHttp response-body byte limit), which this class cannot
* reach; until then, the cap here only bounds the *downstream* cost: the VT parse in
* [TerminalEmulator][com.termux.terminal.TerminalEmulator]`.append` and the retained tail string.
*/
public object PreviewCap {
/** 256 KiB — mirrors the server's `GET /live-sessions/:id/preview` data cap. */
/**
* 256 KiB. This is the CLIENT's own ceiling, not a mirror of the server's: `src/config.ts` defaults
* `PREVIEW_BYTES` to **24 KiB** and lets an operator raise it with no upper bound, so this sits ~10× the
* default — big enough never to clip a normally-configured host's tail, small enough to keep the VT
* parse per poll tick bounded whatever a host is configured to (or lies about) sending.
*/
public const val MAX_PREVIEW_BYTES: Int = 256 * 1024
/**
* Cap [preview]'s UTF-8 byte length at [MAX_PREVIEW_BYTES], keeping the TAIL (the most recent screen)
* so a rogue/huge body can't drive an unbounded emulator append. A tail cut on a UTF-8 boundary at
* worst mangles one leading glyph — harmless for a thumbnail.
* so a huge body can't drive an unbounded emulator append. A tail cut on a UTF-8 boundary at worst
* mangles one leading glyph — harmless for a thumbnail.
*/
public fun cap(preview: SessionPreview): SessionPreview {
val bytes = preview.data.toByteArray(Charsets.UTF_8)

View File

@@ -1,10 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- STUB (plan §8 / A19). Cleartext is OFF globally. A19 will add
<domain-config cleartextTrafficPermitted="true"> entries for the private-LAN
and Tailscale CIDRs that legitimately need ws:// (10/8, 172.16/12,
192.168/16, 100.64/10). Tunnel (*.terminal.yaojia.wang) and Tailscale
MagicDNS hosts present real LE certs and use default system trust (§6.9),
so they stay under this base-config. -->
<!--
Cleartext posture (plan §6.9 + §8 checklist "Cleartext posture"). READ BEFORE EDITING.
── PLATFORM CONSTRAINT (verified against the schema, NOT assumed) ───────────────────────────
The network-security-config format has NO address-range syntax. Its entire vocabulary is
<network-security-config> / <base-config> / <domain-config> / <debug-overrides> / <domain> /
<trust-anchors> / <certificates> / <pin-set> / <pin>, with the attributes src,
includeSubdomains, cleartextTrafficPermitted, expiration and digest — that is the complete
set the validator shipping with our AGP accepts (com.android.tools.lint.checks.
NetworkSecurityConfigDetector, lint 32.2.1). A <domain> element holds exactly ONE hostname or
ONE IP literal, matched by exact / label-suffix comparison; there is no netmask, prefix or
CIDR attribute anywhere in the format, and no wildcard beyond includeSubdomains (which walks
DNS labels and therefore cannot generalise IPv4 literals — 192.168.1.5 is not a "subdomain"
of 192.168).
So the previous STUB comment here ("A19 will add <domain-config> entries for the private-LAN
and Tailscale CIDRs 10/8, 172.16/12, 192.168/16, 100.64/10") described a plan the platform
forbids: those four ranges are ~18.9M addresses and cannot be expressed at all. It has been
replaced by what is actually achievable.
── DECISION: cleartext is PERMITTED in base-config ──────────────────────────────────────────
Bare-LAN `ws://` to a user-typed private address (http://192.168.1.5:3000) is this product's
PRIMARY use case (CLAUDE.md "What This Is"); HostEndpoint.fromBaseUrl accepts `http` and
derives `ws://`, and PairingViewModel treats RFC1918 as a legitimate tier. targetSdk is 35, so
the platform default is deny — with no CIDR syntax available, keeping the denial would make
EVERY LAN connect fail on a real device with UnknownServiceException ("CLEARTEXT communication
to <ip> not permitted by network security policy"), i.e. the app could only ever talk to the
tunnel. There is no narrower posture: the LAN address is user-supplied at runtime and this
file is a build-time resource, so it cannot be scoped to "the IP the user actually typed".
── WHAT STILL GUARDS THE USER (the guard moved into the app; it did not disappear) ──────────
1. §5.4 warning tiers + confirm-before-network (PairingTiers / PairingViewModel): a PUBLIC
host needs an explicit risk acknowledge, PRIVATE_LAN gets the non-blocking `ws://`
cleartext notice, TAILSCALE gets none (WireGuard already encrypts). No dial happens
without a deliberate, warned confirm — that is the real UX guard, and it is unit-tested,
unlike a platform block that can only say "denied" after the fact.
2. Cleartext is DENIED below for the one hostname we control and always serve over TLS.
3. Trust anchors are pinned to SYSTEM certificates only (see <base-config>) — user-installed
CAs are NOT trusted, and there is deliberately NO <debug-overrides> trust-anchor block, so
no build variant of this app can be MITM'd by an added device CA.
── SECURITY TRADEOFF, STATED PLAINLY ────────────────────────────────────────────────────────
Permitting cleartext in base-config means the platform will no longer refuse a plaintext
connection to ANY host — including a public one — if the user pushes past the pairing
warning. On bare `ws://` the traffic (terminal bytes, and the WEBTERM_TOKEN if set) is
readable and replayable by anyone on the path. This mirrors the honest tradeoff already
recorded in CLAUDE.md for the server side: it is a LAN/Tailscale posture, never an
internet-exposure posture. TLS remains the default wherever a hostname is known.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<!--
Cleartext ON (rationale above). Trust anchors are stated explicitly rather than inherited:
"system" only is already the default for targetSdk >= 24, but declaring it locks the app
out of user-installed CAs even if that default ever changes. Tunnel and Tailscale MagicDNS
hosts present real Let's Encrypt certs and are verified against exactly these anchors —
default system trust, no custom X509TrustManager, no pinning (§6.9).
-->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!--
The native tunnel (`*.terminal.yaojia.wang`, PairingTiers.WarningTier.TUNNEL) always
terminates TLS with a real LE cert and is mTLS-gated by the device client cert, so a
plaintext dial there is never legitimate — it can only be a downgrade or a typo. This is
the one place a hostname IS known at build time, so it keeps the platform-level block that
LAN literals cannot have. includeSubdomains covers the apex and every per-host subdomain
(e.g. h7fd8.terminal.yaojia.wang). A blocked attempt surfaces as UnknownServiceException,
which PairingError already maps to CleartextBlocked.
Deliberately NOT listed: *.ts.net (Tailscale MagicDNS). Tailscale carries the connection
inside a WireGuard tunnel, so `http://<host>.<tailnet>.ts.net:3000` is already encrypted
end-to-end and is a supported way to reach a host (HostClassifier tiers it TAILSCALE, "no
cleartext warning"). Blocking cleartext there would break a safe, intended path.
-->
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">terminal.yaojia.wang</domain>
</domain-config>
</network-security-config>

View File

@@ -0,0 +1,112 @@
package wang.yaojia.webterm
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.w3c.dom.Element
import org.w3c.dom.Document
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
/**
* Regression lock for the A19 cleartext posture (BLOCKER 3).
*
* The shipped `network_security_config.xml` used to be a STUB that denied cleartext globally, which made
* every bare-LAN `ws://` connect impossible on a real device — while `HostEndpoint.fromBaseUrl` accepts
* `http` and `PairingViewModel` treats RFC1918 as a first-class tier. Bare LAN is this product's PRIMARY
* use case (CLAUDE.md "What This Is"), so that contradiction made the app tunnel-only.
*
* These are resource-shape assertions, deliberately NOT a behavioural test: the behaviour they guard
* (does a socket to 192.168.x.x open?) is only observable on a device and lives in DEVICE_QA_CHECKLIST.
* What CAN regress silently in a code review is the XML, so that is what is pinned here. Each assertion
* corresponds to a way the fix could be undone without any other test going red.
*/
@DisplayName("network_security_config.xml — A19 cleartext posture")
class NetworkSecurityConfigTest {
private fun parse(path: String): Document =
DocumentBuilderFactory.newInstance()
.apply { isNamespaceAware = true }
.newDocumentBuilder()
.parse(File(path))
private fun nsc(): Document = parse("src/main/res/xml/network_security_config.xml")
private fun elements(doc: Document, tag: String): List<Element> {
val nodes = doc.getElementsByTagName(tag)
return (0 until nodes.length).map { nodes.item(it) as Element }
}
@Test
@DisplayName("base-config permits cleartext, so a user-typed LAN address can still connect")
fun baseConfigPermitsCleartext() {
val base = elements(nsc(), "base-config").single()
assertEquals(
"true",
base.getAttribute("cleartextTrafficPermitted"),
"Denying cleartext in base-config makes every ws:// LAN connect fail with " +
"UnknownServiceException. The platform has no CIDR syntax, so this CANNOT be " +
"narrowed to RFC1918 — see the header comment in the resource.",
)
}
@Test
@DisplayName("the tunnel domain keeps its cleartext block, so it can never be downgraded")
fun tunnelDomainDeniesCleartext() {
val denying = elements(nsc(), "domain-config")
.filter { it.getAttribute("cleartextTrafficPermitted") == "false" }
assertTrue(denying.isNotEmpty(), "the tunnel domain-config that pins TLS was removed")
val tunnelDomain = denying
.flatMap { cfg ->
val kids = cfg.getElementsByTagName("domain")
(0 until kids.length).map { kids.item(it) as Element }
}
.singleOrNull { it.textContent.trim() == "terminal.yaojia.wang" }
assertTrue(
tunnelDomain != null,
"terminal.yaojia.wang must stay under a cleartextTrafficPermitted=false domain-config: " +
"it always serves a real LE cert and is mTLS-gated, so a plaintext dial there can only " +
"be a downgrade or a typo.",
)
assertEquals(
"true",
tunnelDomain!!.getAttribute("includeSubdomains"),
"per-host tunnel subdomains (e.g. h7fd8.terminal.yaojia.wang) must be covered too",
)
}
@Test
@DisplayName("no user-installed CA can ever be trusted")
fun trustAnchorsAreSystemOnly() {
val doc = nsc()
assertTrue(
elements(doc, "debug-overrides").isEmpty(),
"a <debug-overrides> trust-anchor block would let an added device CA MITM a build",
)
val certs = elements(doc, "certificates")
assertTrue(certs.isNotEmpty(), "trust anchors must stay declared, not inherited")
certs.forEach {
assertEquals(
"system",
it.getAttribute("src"),
"only system CAs may be trusted (plan §6.9); 'user' would trust an added device CA",
)
}
}
@Test
@DisplayName("the manifest attribute agrees with the config — they can never drift apart")
fun manifestAgreesWithConfig() {
val app = elements(parse("src/main/AndroidManifest.xml"), "application").single()
assertEquals(
"true",
app.getAttributeNS("http://schemas.android.com/apk/res/android", "usesCleartextTraffic"),
"the manifest must not state the opposite of what network_security_config ships. The " +
"config wins at runtime on minSdk 29, so a stale 'false' here is a lie to the reader, " +
"not a second layer of defence.",
)
}
}

View File

@@ -204,6 +204,109 @@ class QuickReplyTest {
assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = false))
}
// ── 4. The palette presenter (the production state holder behind the chips) ────
@Test
fun `palette load publishes the stored chips`() = runTest {
val palette = QuickReplyPalette(InMemoryQuickReplyStore())
assertTrue(palette.chips.value.isEmpty(), "nothing is published before load()")
palette.load()
assertEquals(QuickReplyDefaults.chips, palette.chips.value)
}
@Test
fun `palette CRUD republishes the store's new list every time`() = runTest {
val palette = QuickReplyPalette(InMemoryQuickReplyStore(newId = sequentialId()))
palette.load()
palette.add("run tests")
assertEquals("run tests", palette.chips.value.last().text)
palette.edit("qr-yes", "yes please")
assertEquals("yes please", palette.chips.value.single { it.id == "qr-yes" }.text)
val addedId = palette.chips.value.last().id
palette.remove(addedId)
assertFalse(palette.chips.value.any { it.id == addedId })
val idsBeforeMove = palette.chips.value.map { it.id }
palette.move(0, idsBeforeMove.lastIndex)
assertEquals(idsBeforeMove.drop(1) + idsBeforeMove.first(), palette.chips.value.map { it.id })
}
@Test
fun `palette rejects a blank add without touching the published list`() = runTest {
val palette = QuickReplyPalette(InMemoryQuickReplyStore())
palette.load()
val before = palette.chips.value
palette.add(" ")
assertEquals(before, palette.chips.value)
}
@Test
fun `an emptied palette stays empty (no default re-seed) and hides the row`() = runTest {
val palette = QuickReplyPalette(InMemoryQuickReplyStore())
palette.load()
palette.chips.value.forEach { palette.remove(it.id) }
assertTrue(palette.chips.value.isEmpty())
assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = palette.chips.value.isNotEmpty()))
}
// ── 5. The editor's one-field select / commit reducer ─────────────────────────
@Test
fun `a fresh editor state adds`() {
val state = QuickReplyEditorState()
assertNull(state.selectedId)
assertEquals("", state.draft)
assertEquals(QuickReplyCommit.NONE, state.commitKind()) // blank draft → nothing to commit
}
@Test
fun `a non-blank draft with no selection is an ADD`() {
assertEquals(QuickReplyCommit.ADD, QuickReplyEditorState(draft = "deploy").commitKind())
}
@Test
fun `selecting a chip loads its text and switches the commit to EDIT`() {
val state = QuickReplyEditorState().selecting(QuickReplyChip("qr-yes", "yes"))
assertEquals("qr-yes", state.selectedId)
assertEquals("yes", state.draft)
assertEquals(QuickReplyCommit.EDIT, state.commitKind())
}
@Test
fun `a blank draft never commits, selected or not`() {
assertEquals(QuickReplyCommit.NONE, QuickReplyEditorState(selectedId = "qr-yes", draft = " ").commitKind())
assertEquals(QuickReplyCommit.NONE, QuickReplyEditorState(draft = "").commitKind())
}
@Test
fun `clearing drops the selection and the draft, and never mutates the previous state`() {
val selected = QuickReplyEditorState().selecting(QuickReplyChip("qr-no", "no"))
val cleared = selected.cleared()
assertNull(cleared.selectedId)
assertEquals("", cleared.draft)
// The previous snapshot is untouched (immutability).
assertEquals("qr-no", selected.selectedId)
assertEquals("no", selected.draft)
}
@Test
fun `the draft is carried verbatim into the commit (surrounding whitespace preserved)`() = runTest {
val state = QuickReplyEditorState(draft = " spaced ")
assertEquals(QuickReplyCommit.ADD, state.commitKind())
val palette = QuickReplyPalette(InMemoryQuickReplyStore(initial = emptyList(), newId = sequentialId()))
palette.load()
palette.add(state.draft)
assertEquals(" spaced ", palette.chips.value.single().text)
}
// ── Pure transforms never mutate the receiver ─────────────────────────────────
@Test

View File

@@ -0,0 +1,89 @@
package wang.yaojia.webterm.nav
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.api.models.LiveSessionInfo
import wang.yaojia.webterm.components.sessionRowMenuActions
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.viewmodels.SessionRow
import java.util.UUID
/**
* The pointer-context-menu ACTION SET for a session row (A26 — the large-screen right-click menu).
*
* The menu's *visibility* gate is [PointerMenuPolicy] (already covered by `LayoutPolicyTest`); this covers
* the other half — which entries a row offers and what each one does. Pure JVM: the entries are built by
* [sessionRowMenuActions], so the wiring is verified without a device (the pointer gesture itself is
* device-QA, plan §7).
*
* NOTE the "copy" entry iOS has is deliberately ABSENT: copy-out belongs to the terminal's own text
* selection `ActionMode`, and a list row has no selected text — so the menu must not advertise it.
*
* (This file sits in the `nav` test package because it covers the row-menu half of the adaptive shell
* wired here; the function itself lives beside [wang.yaojia.webterm.components.PointerContextMenu] and
* [wang.yaojia.webterm.components.ContextMenuAction], which is its cohesive home.)
*/
class SessionRowMenuTest {
private fun row(cwd: String?): SessionRow = SessionRow(
info = LiveSessionInfo(
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
createdAt = 0L,
clientCount = 1,
exited = false,
cwd = cwd,
cols = 80,
rows = 24,
lastOutputAt = 1L,
),
displayStatus = DisplayStatus.Working,
title = "web-terminal",
isUnread = false,
)
@Test
fun `a row with a cwd offers open, new-session-in-cwd and kill — and never copy`() {
val actions = sessionRowMenuActions(row("/srv/app"), onOpen = {}, onNewSessionInCwd = {}, onKill = {})
assertEquals(3, actions.size)
assertTrue(actions[0].label.contains("打开"), "the first entry opens the session: ${actions[0].label}")
assertTrue(actions[1].label.contains("开新会话"), "cwd spawn entry missing: ${actions[1].label}")
assertTrue(actions[2].label.contains("终止"), "kill entry missing: ${actions[2].label}")
assertFalse(actions.any { it.label.contains("复制") }, "copy is a documented gap — must not appear")
}
@Test
fun `a row without a usable cwd drops the new-session entry`() {
assertEquals(2, sessionRowMenuActions(row(null), onOpen = {}, onNewSessionInCwd = {}, onKill = {}).size)
assertEquals(2, sessionRowMenuActions(row(" "), onOpen = {}, onNewSessionInCwd = {}, onKill = {}).size)
}
@Test
fun `no spawn handler wired means no new-session entry, even with a cwd`() {
val actions = sessionRowMenuActions(row("/srv/app"), onOpen = {}, onNewSessionInCwd = null, onKill = {})
assertEquals(2, actions.size)
assertFalse(actions.any { it.label.contains("开新会话") })
}
@Test
fun `each entry invokes its callback with the row's own id and cwd`() {
var opened: UUID? = null
var spawnedIn: String? = null
var killed: UUID? = null
val target = row("/home/dev/project")
val actions = sessionRowMenuActions(
row = target,
onOpen = { opened = it },
onNewSessionInCwd = { spawnedIn = it },
onKill = { killed = it },
)
actions.forEach { it.onClick() }
assertEquals(target.id, opened)
assertEquals("/home/dev/project", spawnedIn)
assertEquals(target.id, killed)
}
}

View File

@@ -0,0 +1,67 @@
package wang.yaojia.webterm.screens
import androidx.lifecycle.Lifecycle
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.session.FailureReason
import kotlin.time.Duration.Companion.seconds
/**
* The §6.4 latest-writer-wins reclaim policy — the pure half of the device-switch resize path
* (plan §6.4 / A21). The Compose/lifecycle plumbing that feeds it is device-QA (§7); WHICH events
* must reach `SessionEngine.notifyForegrounded` is decided here, in JVM-testable data.
*
* The rule that makes this non-obvious: `:terminal-view`'s `TerminalResizeDriver` now owns pushing
* the measured grid on a layout change (and dedups sub-cell churn), so an extra engine-level
* re-assert on the SAME size-changed event would double every rotation's `resize` frame. The engine
* call therefore belongs to the events the view layer cannot see — becoming the active device again
* (resume / window-focus gain) — plus the disconnected case, where it is the connect-now nudge.
*/
class TerminalReclaimPolicyTest {
// ── Lifecycle: only ON_RESUME means "this device is active again" ────────────────────────────
@Test
fun `ON_RESUME reclaims the shared PTY size`() {
assertTrue(TerminalReclaimPolicy.reclaimsOnLifecycle(Lifecycle.Event.ON_RESUME))
}
@Test
fun `no other lifecycle event reclaims`() {
Lifecycle.Event.values()
.filter { it != Lifecycle.Event.ON_RESUME }
.forEach { event ->
assertFalse(TerminalReclaimPolicy.reclaimsOnLifecycle(event), "$event must not reclaim")
}
}
// ── Window focus: gain only (a blur must never resize — plan §6.4 removed the size-vote) ────
@Test
fun `window-focus GAIN reclaims and a blur does not`() {
assertTrue(TerminalReclaimPolicy.reclaimsOnWindowFocus(focused = true))
assertFalse(TerminalReclaimPolicy.reclaimsOnWindowFocus(focused = false))
}
// ── View size change: nudge only while disconnected (the driver owns the connected case) ────
@Test
fun `a size change while connected does NOT re-assert (the view driver already pushed it)`() {
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Hidden))
}
@Test
fun `a size change while connecting or reconnecting nudges connect-now`() {
assertTrue(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Connecting))
assertTrue(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Reconnecting(2, 4.seconds)))
}
@Test
fun `a size change on a terminal banner never nudges (no reconnect is owed)`() {
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE)))
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Exited(0, null)))
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Exited(-1, "spawn failed")))
}
}

View File

@@ -1,6 +1,7 @@
package wang.yaojia.webterm.viewmodels
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
@@ -9,6 +10,7 @@ import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.WorktreeState
/**
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
@@ -18,6 +20,8 @@ import wang.yaojia.webterm.api.models.UiPrefs
*/
class FakeWorktreeGateway(
private val detail: ProjectDetail? = null,
private val worktreeStates: Map<String, WorktreeState> = emptyMap(),
private val fetchOutcome: GitWriteOutcome<FetchResult> = GitWriteOutcome.Ok(FetchResult()),
private val prResult: PrStatus? = null,
private val prThrows: Boolean = false,
private val logResult: GitLogResult? = null,
@@ -29,6 +33,8 @@ class FakeWorktreeGateway(
val createCalls = mutableListOf<Triple<String, String, String?>>()
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
val pruneCalls = mutableListOf<String>()
val worktreeStateCalls = mutableListOf<String>()
val fetchCalls = mutableListOf<String>()
var detailCalls = 0
private set
@@ -51,6 +57,16 @@ class FakeWorktreeGateway(
return logResult ?: throw NotImplementedError("no log configured")
}
override suspend fun worktreeState(worktreePath: String): WorktreeState {
worktreeStateCalls += worktreePath
return worktreeStates[worktreePath] ?: throw RuntimeException("probe failed for $worktreePath")
}
override suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> {
fetchCalls += path
return fetchOutcome
}
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
createCalls += Triple(path, branch, base)
return createOutcome

View File

@@ -0,0 +1,184 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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.session.Adopted
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.Queued
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wiring.TerminalSessionController
/**
* [GateViewModel] — the `GET /config/ui` `allowAutoMode` gate plus the two cockpit signals the VM used
* to DROP on the floor (`queue` depth and the adopted session id).
*
* The `allowAutoMode` gate is security-relevant: `approve.mode="acceptEdits"` puts Claude into
* auto-accept-edits, and an operator who set `ALLOW_AUTO_MODE=0` on the host must not be overridden from
* a phone. The gate **fails CLOSED** — auto is refused until a successful fetch says otherwise — because
* the permissive default is the dangerous one.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class GateAutoModeTest {
private class FakeController : TerminalSessionController {
private val channel = Channel<SessionEvent>(Channel.UNLIMITED)
val decisions = mutableListOf<Pair<Int, ClientMessage>>()
override fun controlEvents(): Flow<SessionEvent> = channel.receiveAsFlow()
override val output: Flow<ByteArray> = emptyFlow()
override fun start() = Unit
override fun sendInput(data: String) = Unit
override fun resize(cols: Int, rows: Int) = Unit
override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit
override fun decideGate(epoch: Int, message: ClientMessage) {
decisions += epoch to message
}
override fun close() = Unit
fun emit(event: SessionEvent) {
channel.trySend(event)
}
}
private fun planGate(epoch: Int) = Gate(GateState(kind = GateKind.PLAN, detail = "plan", epoch = epoch))
// ── allowAutoMode fails CLOSED ───────────────────────────────────────────────────────────────
@Test
fun `auto-accept is refused until the host says it is allowed`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(planGate(1))
assertFalse(vm.uiState.value.allowAutoMode) // fail-closed default
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1)
assertTrue(fake.decisions.isEmpty()) // NOTHING was sent
}
@Test
fun `the other two plan affordances still work while auto is disabled`() =
runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(planGate(1))
vm.decide(GateDecision.APPROVE, epoch = 1)
vm.decide(GateDecision.REJECT, epoch = 1)
assertEquals(2, fake.decisions.size)
assertEquals(
ClientMessage.Approve(mode = ApproveMode.DEFAULT),
fake.decisions[0].second,
)
assertEquals(ClientMessage.Reject, fake.decisions[1].second)
}
@Test
fun `auto-accept is sent once the host allows it`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
vm.setAutoModeAllowed(true)
fake.emit(planGate(7))
assertTrue(vm.uiState.value.allowAutoMode)
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 7)
assertEquals(
listOf(7 to ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS)),
fake.decisions.toList(),
)
}
@Test
fun `revoking auto mode drops a later auto decision`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
vm.setAutoModeAllowed(true)
vm.setAutoModeAllowed(false)
fake.emit(planGate(3))
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 3)
assertTrue(fake.decisions.isEmpty())
}
@Test
fun `the epoch stale-guard still outranks an allowed auto decision`() =
runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
vm.setAutoModeAllowed(true)
fake.emit(planGate(2))
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1) // the gate on screen was epoch 1
assertTrue(fake.decisions.isEmpty())
}
// ── The `queue` frame (previously dropped) ───────────────────────────────────────────────────
@Test
fun `the queue frame publishes the pending depth`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
assertNull(vm.uiState.value.queueDepth) // unknown before the first frame
fake.emit(Queued(3))
assertEquals(3, vm.uiState.value.queueDepth)
fake.emit(Queued(0))
assertEquals(0, vm.uiState.value.queueDepth)
}
// ── The adopted session id (the queue routes need it) ────────────────────────────────────────
@Test
fun `the adopted session id is published so a fresh spawn can be queued to`() =
runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
assertNull(vm.uiState.value.sessionId)
fake.emit(Adopted("11111111-2222-4333-8444-555555555555"))
assertEquals("11111111-2222-4333-8444-555555555555", vm.uiState.value.sessionId)
}
@Test
fun `an exit clears the queue depth along with the gate`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(planGate(1))
fake.emit(Queued(2))
fake.emit(Exited(code = 0))
assertNull(vm.uiState.value.gate)
assertNull(vm.uiState.value.queueDepth) // an exited session has no queue to show
}
}

View File

@@ -0,0 +1,192 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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.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.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.PreviewDiffFile
import wang.yaojia.webterm.wire.PreviewDiffHunk
import wang.yaojia.webterm.wire.PreviewDiffLine
import wang.yaojia.webterm.wiring.TerminalSessionController
/**
* W1 approval preview — a remote one-tap approval must show WHAT it approves (the command or the
* diff), not just a tool name. The content is derived server-side from the hook `tool_input`, i.e. it
* is **attacker-influenced**: it renders strictly inert, and this test pins the client-side
* defence-in-depth sanitizer plus the rule that an ABSENT preview is a NORMAL state (the name-only
* gate stays fully decidable — a missing preview never blocks or breaks a gate).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class GatePreviewTest {
private class FakeController : TerminalSessionController {
private val channel = Channel<SessionEvent>(Channel.UNLIMITED)
val decisions = mutableListOf<Pair<Int, ClientMessage>>()
override fun controlEvents(): Flow<SessionEvent> = channel.receiveAsFlow()
override val output: Flow<ByteArray> = emptyFlow()
override fun start() = Unit
override fun sendInput(data: String) = Unit
override fun resize(cols: Int, rows: Int) = Unit
override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit
override fun decideGate(epoch: Int, message: ClientMessage) { decisions += epoch to message }
override fun close() = Unit
fun emit(event: SessionEvent) { channel.trySend(event) }
}
// ── Absent preview is a NORMAL state ─────────────────────────────────────────────────────────
@Test
fun `a gate with no preview stays fully decidable and shows no preview block`() =
runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(Gate(GateState(kind = GateKind.TOOL, detail = "Bash", epoch = 1)))
assertNotNull(vm.uiState.value.gate, "the gate itself is unaffected")
assertNull(vm.uiState.value.preview, "no preview → the name-only bar, not an error")
vm.decide(GateDecision.APPROVE, 1)
assertEquals(1, fake.decisions.size, "a preview-less gate is still approvable")
}
@Test
fun `a held gate carries its preview onto the ui state`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(
Gate(
GateState(
kind = GateKind.TOOL,
detail = "Bash",
epoch = 1,
preview = ApprovalPreview.Command("rm -rf build/"),
),
),
)
val preview = vm.uiState.value.preview
assertTrue(preview is ApprovalPreview.Command, "expected a command preview, got $preview")
assertEquals("rm -rf build/", (preview as ApprovalPreview.Command).text)
}
@Test
fun `a lifted gate clears any preview with it`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(
Gate(
GateState(
kind = GateKind.TOOL,
detail = "Bash",
epoch = 1,
preview = ApprovalPreview.Command("rm -rf build/"),
),
),
)
assertNotNull(vm.uiState.value.preview)
fake.emit(Gate(null))
assertNull(vm.uiState.value.gate)
assertNull(vm.uiState.value.preview, "a stale preview must never outlive its gate")
}
@Test
fun `a new gate replaces the previous gate's preview`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(Gate(gateWithCommand(epoch = 1)))
fake.emit(Gate(gateWithCommand(epoch = 2)))
assertEquals("cmd for epoch 2", (vm.uiState.value.preview as ApprovalPreview.Command).text)
}
private fun gateWithCommand(epoch: Int) = GateState(
kind = GateKind.TOOL,
detail = "Bash",
epoch = epoch,
preview = ApprovalPreview.Command("cmd for epoch $epoch"),
)
// ── Inert rendering (§8) ────────────────────────────────────────────────────────────────────
@Test
fun `command preview text keeps newlines and strips control and ANSI bytes`() {
val raw = "rm -rf build/\n\u001B[31mecho\u0007 boom\u0000"
val safe = inertPreviewText(raw)
assertTrue(safe.contains("\n"), "a shell command's line structure is meaningful")
assertFalse(safe.contains("\u001B"), "no ESC \u2014 preview text must never drive the terminal")
assertFalse(safe.contains("\u0007"), "no BEL")
assertFalse(safe.contains("\u0000"), "no NUL")
assertEquals("rm -rf build/\n[31mecho boom", safe)
}
@Test
fun `command preview leaves ordinary text and markup characters literal`() {
val raw = "grep -n '<script>alert(1)</script>' *.ts && echo \"a&b\""
assertEquals(raw, inertPreviewText(raw), "no escaping, no linkify — the UI renders it as text")
}
@Test
fun `tabs survive but carriage returns do not`() {
assertEquals("a\tb", inertPreviewText("a\tb"))
assertEquals("ab", inertPreviewText("a\rb"), "a bare CR would rewrite the rendered line")
}
@Test
fun `the display text of a command preview is the sanitized one`() {
val preview = ApprovalPreview.Command("echo \u001Bx", truncated = true)
assertEquals("echo x", preview.inertText(), "what the UI paints")
assertTrue(preview.truncated)
}
@Test
fun `a command preview with nothing legible left is not a preview at all`() {
assertNull(ApprovalPreview.Command(" \u0000 ").inertText())
assertEquals("ls", ApprovalPreview.Command("ls").inertText())
}
@Test
fun `a diff preview carries its one synthetic file and truncation flag`() {
val file = PreviewDiffFile(
oldPath = "a.ts",
newPath = "a.ts",
status = "modified",
added = 1,
removed = 0,
binary = false,
hunks = listOf(PreviewDiffHunk("@@ -1 +1 @@", listOf(PreviewDiffLine("added", "+x")))),
)
val preview = ApprovalPreview.Diff(file, truncated = true)
assertEquals("a.ts", preview.file.newPath)
assertEquals("+x", preview.file.hunks.single().lines.single().text)
assertTrue(preview.truncated)
}
}

View File

@@ -141,6 +141,9 @@ class GateViewModelTest {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
// `acceptEdits` is gated by the host's `GET /config/ui` `allowAutoMode`, which fails CLOSED — so a
// test about the WIRE MAPPING has to opt in explicitly. The gate itself is `GateAutoModeTest`.
vm.setAutoModeAllowed(true)
fake.emit(planGate(3))
vm.decide(GateDecision.APPROVE, epoch = 3) // default review

View File

@@ -0,0 +1,275 @@
package wang.yaojia.webterm.viewmodels
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.CommitLogEntry
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.SyncState
import wang.yaojia.webterm.api.models.WorktreeInfo
/**
* w6 git panel — the pure presentation core (`syncBandOf` / `boundaryIndex` / `worktreeChips` /
* `headerDirtyChip`), ported from the shipped web behaviour in `public/projects.ts` +
* `public/git-log.ts` and the design in `docs/mockups/project-detail-git.html`.
*
* The load-bearing rule these tests exist for: **exactly one state may read as the green
* "all clear"** — `ahead == 0 && behind == 0` AND a fetch inside SyncState.FETCH_FRESH_WINDOW_MS. "No upstream"
* leaves ahead/behind undefined and MUST fall through to an explicit `no upstream`, never to green
* (docs/plans/w6-project-git-panel.md: "the single easiest bug to ship").
*/
class GitSyncPanelTest {
private val now = 1_800_000_000_000L
private val fresh = now - 60_000L // 1 min ago → inside the 1 h staleness window
private val ancient = now - (19L * 24 * 60 * 60 * 1000) // 19 days ago
// ── The green path: exactly one state ────────────────────────────────────────────────────────
@Test
fun `ahead zero behind zero with a fresh fetch is the one green all-clear`() {
val band = syncBandOf(
SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = fresh),
nowMs = now,
)
val tracking = assertTracking(band)
assertFalse(tracking.isStale, "a 1-minute-old fetch is fresh")
assertTrue(tracking.isAllClear, "↑0 ↓0 + fresh fetch is the single green state")
}
@Test
fun `ahead zero behind zero with a stale fetch is never green and marks the behind number`() {
val band = syncBandOf(
SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = ancient),
nowMs = now,
)
val tracking = assertTracking(band)
assertTrue(tracking.isStale, "a 19-day-old FETCH_HEAD is stale")
assertFalse(tracking.isAllClear, "a stale ↓0 is a guess, not a fact — never green")
}
@Test
fun `never fetched is stale, not fresh`() {
val band = syncBandOf(
SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = null),
nowMs = now,
)
val tracking = assertTracking(band)
assertTrue(tracking.isStale)
assertFalse(tracking.isAllClear)
}
/** THE fall-through this feature is most likely to get wrong (plan §"Design rule"). */
@Test
fun `no upstream renders an explicit no-upstream state and never the green path`() {
val band = syncBandOf(
SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh),
nowMs = now,
)
assertEquals(SyncBand.NoUpstream, band, "a branch that tracks nothing has no ↑↓ to report")
assertFalse(band!!.isAllClear, "absent numbers must never read as 'in sync'")
}
@Test
fun `a blank upstream string is treated as no upstream`() {
val band = syncBandOf(SyncState(upstream = " ", lastFetchMs = fresh), nowMs = now)
assertEquals(SyncBand.NoUpstream, band)
}
@Test
fun `only the tracking-in-sync state is ever all clear`() {
val allClear = listOf(
syncBandOf(SyncState(detached = true), nowMs = now),
syncBandOf(SyncState(upstream = null), nowMs = now),
syncBandOf(SyncState(upstream = "origin/x", ahead = 1, behind = 0, lastFetchMs = fresh), nowMs = now),
syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 3, lastFetchMs = fresh), nowMs = now),
syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = ancient), nowMs = now),
syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = fresh), nowMs = now),
).map { it?.isAllClear }
assertEquals(listOf(false, false, false, false, false, true), allClear)
}
// ── Degraded shapes ─────────────────────────────────────────────────────────────────────────
@Test
fun `a detached HEAD shows the short sha, no branch, and disables fetch`() {
val band = syncBandOf(SyncState(detached = true, lastFetchMs = fresh), head = "1dbed54", nowMs = now)
assertEquals(SyncBand.Detached("1dbed54"), band)
assertFalse(band!!.canFetch, "fetch is not available on a detached HEAD")
}
@Test
fun `fetch stays available for tracking and no-upstream states`() {
assertTrue(syncBandOf(SyncState(upstream = null), nowMs = now)!!.canFetch)
assertTrue(syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now)!!.canFetch)
}
@Test
fun `absent sync facts render no band at all (non-git project)`() {
assertNull(syncBandOf(null, nowMs = now))
}
@Test
fun `absent ahead and behind on a tracking branch count as zero`() {
val tracking = assertTracking(
syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now),
)
assertEquals(0, tracking.ahead)
assertEquals(0, tracking.behind)
assertTrue(tracking.isAllClear, "a tracking branch with no counts and a fresh fetch IS in sync")
}
// ── Header dirty chip (● 3, not a bare dot) ─────────────────────────────────────────────────
@Test
fun `a dirty count renders the number, not a bare dot`() {
assertEquals(GitPanelCopy.dirtyChip(3), headerDirtyChip(dirtyCount = 3, dirty = true))
assertTrue(headerDirtyChip(dirtyCount = 3, dirty = true)!!.contains("3"))
}
@Test
fun `dirty without a count falls back to the countless chip, and clean renders nothing`() {
assertEquals(GitPanelCopy.DIRTY_NO_COUNT, headerDirtyChip(dirtyCount = null, dirty = true))
assertNull(headerDirtyChip(dirtyCount = 0, dirty = true), "0 changed files is not dirty")
assertNull(headerDirtyChip(dirtyCount = null, dirty = false))
assertNull(headerDirtyChip(dirtyCount = null, dirty = null))
}
// ── Commit list: unpushed marking + ONE boundary ─────────────────────────────────────────────
private fun log(vararg unpushed: Boolean, upstream: String? = null, truncated: Boolean = false) =
GitLogResult(
commits = unpushed.mapIndexed { i, flag ->
CommitLogEntry(hash = "hash$i", at = now - i * 1000L, subject = "s$i", unpushed = flag)
},
truncated = truncated,
upstream = upstream,
)
@Test
fun `the boundary is drawn once, after the last unpushed commit`() {
val log = log(true, true, false, false, upstream = "origin/develop")
assertEquals(1, log.boundaryIndex(), "one boundary, right after the last unpushed row")
assertEquals("origin/develop", log.boundaryLabel())
assertEquals(2, log.unpushedCount)
}
/**
* A merge of an older branch interleaves an unpushed commit BELOW a pushed one. The per-commit flag
* is authoritative — never the row index — and the single boundary sits after the LAST unpushed row
* (plan §TDD step 19).
*/
@Test
fun `an unpushed commit sorting below a pushed one still moves the boundary`() {
val log = log(true, false, true, false, upstream = "origin/develop")
assertEquals(2, log.boundaryIndex())
assertEquals(listOf(true, false, true, false), log.commits.map { it.isUnpushed() })
}
@Test
fun `no unpushed commits means no boundary`() {
val log = log(false, false, upstream = "origin/develop")
assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex())
assertEquals(0, log.unpushedCount)
}
@Test
fun `without an upstream no boundary is drawn even when rows are marked`() {
val log = log(true, true, upstream = null)
assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex(), "nothing to draw the boundary against")
assertNull(log.boundaryLabel())
}
@Test
fun `a blank upstream draws no boundary and no label`() {
val log = log(true, upstream = " ")
assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex())
assertNull(log.boundaryLabel(), "an empty label would claim a position it cannot name")
}
@Test
fun `an absent unpushed flag reads as pushed, never the other way round`() {
val commit = CommitLogEntry(hash = "h", at = now, subject = "s", unpushed = null)
assertFalse(commit.isUnpushed(), "unknown must not be marked; only an explicit true counts")
}
// ── Worktree section ────────────────────────────────────────────────────────────────────────
@Test
fun `the worktree section always reads Worktrees with its count, even at one`() {
assertEquals("Worktrees (1)", GitPanelCopy.worktreesTitle(1))
assertEquals("Worktrees (2)", GitPanelCopy.worktreesTitle(2))
assertEquals("Worktrees (0)", GitPanelCopy.worktreesTitle(0))
}
private val mainWorktree = WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true)
@Test
fun `a worktree with no upstream shows no-upstream and never a green chip`() {
val chips = worktreeChips(
WorktreeInfo(path = "/repo/.claude/worktrees/x", branch = "worktree-x"),
WorktreeRowState(sync = SyncState(upstream = null, lastFetchMs = fresh)),
)
assertTrue(chips.any { it.label == GitPanelCopy.NO_UPSTREAM_CHIP })
assertFalse(chips.any { it.tone == GitChipTone.OK }, "a worktree row has no green all-clear chip")
}
@Test
fun `worktree chips report main current locked prunable then sync then dirty`() {
val chips = worktreeChips(
mainWorktree.copy(locked = true, prunable = true),
WorktreeRowState(sync = SyncState(upstream = "origin/develop", ahead = 9, lastFetchMs = fresh), dirtyCount = 3),
)
assertEquals(
listOf(
GitPanelCopy.MAIN_CHIP,
GitPanelCopy.CURRENT_CHIP,
GitPanelCopy.LOCKED_CHIP,
GitPanelCopy.PRUNABLE_CHIP,
GitPanelCopy.aheadChip(9),
GitPanelCopy.dirtyCountChip(3),
),
chips.map { it.label },
)
}
@Test
fun `an unprobed worktree row shows no sync chip at all rather than a guess`() {
val chips = worktreeChips(mainWorktree, WorktreeRowState())
assertEquals(listOf(GitPanelCopy.MAIN_CHIP, GitPanelCopy.CURRENT_CHIP), chips.map { it.label })
}
@Test
fun `a detached worktree row says detached`() {
val chips = worktreeChips(
WorktreeInfo(path = "/repo/wt", branch = null, head = "1dbed54"),
WorktreeRowState(sync = SyncState(detached = true)),
)
assertEquals(listOf(GitPanelCopy.DETACHED_CHIP_SHORT), chips.map { it.label })
}
private fun assertTracking(band: SyncBand?): SyncBand.Tracking {
assertTrue(band is SyncBand.Tracking, "expected a tracking band, got $band")
return band as SyncBand.Tracking
}
}

View File

@@ -0,0 +1,177 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CompletableDeferred
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 wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.WorktreeInfo
import wang.yaojia.webterm.api.models.WorktreeState
/**
* w6/G2+G3 — the Fetch action on the project-detail sync band.
*
* `git fetch` only moves remote-tracking refs (no pull, no merge), so it is the ONE write the panel
* offers. The rules pinned here: one in-flight fetch at a time (a second tap is dropped, never
* queued), a success re-loads the detail so `behind`/`lastFetchMs` actually move, and a FAILURE
* changes nothing — `lastFetchMs` stays where it was so the `stale` flag stays ON (never silently
* mark the data fresh).
*/
class ProjectDetailFetchTest {
private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "develop")
private fun okFetch(lastFetchMs: Long?): GitWriteOutcome<FetchResult> =
GitWriteOutcome.Ok(FetchResult(lastFetchMs))
private fun vmWith(
fetchRemote: (suspend () -> GitWriteOutcome<FetchResult>)?,
): Pair<ProjectDetailViewModel, IntArray> {
val detailCalls = intArrayOf(0)
val vm = ProjectDetailViewModel(
path = "/repo",
fetch = { detailCalls[0]++; detail },
fetchRemote = fetchRemote,
)
return vm to detailCalls
}
@Test
fun `no fetch seam wired means the button is not offered`() {
val (vm, _) = vmWith(null)
assertFalse(vm.canFetch, "without a fetch gateway the band must not offer the action")
assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value)
}
@Test
fun `a successful fetch reports the new lastFetchMs and re-loads the detail`() = runTest {
val (vm, detailCalls) = vmWith { okFetch(42L) }
vm.load()
val callsAfterLoad = detailCalls[0]
vm.fetchUpstream()
assertTrue(vm.canFetch)
assertEquals(ProjectDetailViewModel.FetchPhase.Done(42L), vm.fetchPhase.value)
assertEquals(callsAfterLoad + 1, detailCalls[0], "a fetch refreshes ahead/behind")
}
@Test
fun `a failing fetch surfaces a short message and does NOT re-load or mark data fresh`() = runTest {
val (vm, detailCalls) = vmWith { throw RuntimeException("offline") }
vm.load()
val callsAfterLoad = detailCalls[0]
vm.fetchUpstream()
val phase = vm.fetchPhase.value
assertTrue(phase is ProjectDetailViewModel.FetchPhase.Failed, "expected Failed, got $phase")
assertEquals(GitPanelCopy.FETCH_FAILED, (phase as ProjectDetailViewModel.FetchPhase.Failed).message)
assertEquals(callsAfterLoad, detailCalls[0], "a failed fetch must not claim fresh data")
}
@Test
fun `a second fetch while one is in flight is dropped, not queued`() = runTest {
val gate = CompletableDeferred<GitWriteOutcome<FetchResult>>()
var invocations = 0
val vm = ProjectDetailViewModel(
path = "/repo",
fetch = { detail },
fetchRemote = { invocations++; gate.await() },
)
val first = launch { vm.fetchUpstream() }
// Let the first call reach the suspension point, then tap again.
kotlinx.coroutines.yield()
assertEquals(ProjectDetailViewModel.FetchPhase.Working, vm.fetchPhase.value)
vm.fetchUpstream()
gate.complete(okFetch(7L))
first.join()
assertEquals(1, invocations, "the in-flight fetch is not re-entered")
assertEquals(ProjectDetailViewModel.FetchPhase.Done(7L), vm.fetchPhase.value)
}
@Test
fun `the fetch banner is dismissable back to idle`() = runTest {
val (vm, _) = vmWith { okFetch(1L) }
vm.fetchUpstream()
vm.clearFetchBanner()
assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value)
}
@Test
fun `a rejected fetch surfaces the server's own safe message verbatim`() = runTest {
val (vm, detailCalls) = vmWith { GitWriteOutcome.Rejected(status = 400, message = "两个 remote无法确定目标") }
vm.load()
val callsAfterLoad = detailCalls[0]
vm.fetchUpstream()
assertEquals(
ProjectDetailViewModel.FetchPhase.Failed("两个 remote无法确定目标"),
vm.fetchPhase.value,
)
assertEquals(callsAfterLoad, detailCalls[0], "a rejected fetch must not claim fresh data")
}
@Test
fun `a rate-limited fetch says so and does not retry`() = runTest {
val (vm, _) = vmWith { GitWriteOutcome.RateLimited }
vm.fetchUpstream()
assertEquals(
ProjectDetailViewModel.FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED),
vm.fetchPhase.value,
)
}
@Test
fun `a fetch failure never fails the detail load`() = runTest {
val (vm, _) = vmWith { throw RuntimeException("offline") }
vm.load()
vm.fetchUpstream()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "isolation: detail stays loaded")
}
// ── w6/G7: the per-worktree probe ────────────────────────────────────────────────────────────
@Test
fun `probing skips the current worktree and isolates a failing row`() = runTest {
val worktrees = listOf(
WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true),
WorktreeInfo(path = "/repo/wt-a", branch = "worktree-a"),
WorktreeInfo(path = "/repo/wt-b", branch = "worktree-b"),
)
val gateway = FakeWorktreeGateway(
detail = detail.copy(worktrees = worktrees),
prResult = PrStatus(availability = PrAvailability.NO_PR),
logResult = GitLogResult(),
// wt-b is missing on purpose: its probe throws, and must cost only its own row.
worktreeStates = mapOf("/repo/wt-a" to WorktreeState(path = "/repo/wt-a", dirtyCount = 2)),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
vm.probeWorktrees()
assertEquals(listOf("/repo/wt-a", "/repo/wt-b"), gateway.worktreeStateCalls, "the current row is free")
assertEquals(setOf("/repo/wt-a"), vm.worktreeStates.value.keys)
assertEquals(2, vm.worktreeStates.value.getValue("/repo/wt-a").dirtyCount)
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "a failed probe never fails the panel")
}
}

View File

@@ -204,6 +204,8 @@ class ProjectsViewModelTest {
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
override suspend fun projectPr(path: String) = throw NotImplementedError()
override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError()
override suspend fun worktreeState(worktreePath: String) = throw NotImplementedError()
override suspend fun gitFetch(path: String) = throw NotImplementedError()
override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError()
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError()
override suspend fun pruneWorktrees(path: String) = throw NotImplementedError()

View File

@@ -0,0 +1,245 @@
package wang.yaojia.webterm.viewmodels
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.QueueSnapshot
import wang.yaojia.webterm.api.models.QueueWriteOutcome
import wang.yaojia.webterm.api.routes.ApiClientError
import java.util.UUID
/**
* [QueueViewModel] (W2 follow-up queue) — the write/read side of `POST|GET|DELETE
* /live-sessions/:id/queue`.
*
* The load-bearing properties asserted here are the ones a screenshot cannot show:
* - the prompt reaches the gateway **VERBATIM** (no trim, no normalisation, no client-side `\r`) and
* `appendEnter` stays a FLAG, so the SERVER materialises the carriage return (invariant #9);
* - every [QueueWriteOutcome] maps to its own UI notice, and a **429 is never auto-retried**;
* - a 503 permanently hides the affordance for the host (`isSupported = false`);
* - the authoritative depth comes from the server (`Ok(length)` / the `queue` WS frame), never from a
* local increment.
* The panel's Compose presentation is device-QA (plan §7).
*/
class QueueViewModelTest {
private companion object {
val SESSION: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
}
/** Records every call and replays scripted outcomes in order (the last one repeats). */
private class FakeGateway(
private val snapshots: List<Any> = listOf(QueueSnapshot()),
private val writes: List<QueueWriteOutcome> = listOf(QueueWriteOutcome.Ok(1)),
) : QueueGateway {
val enqueued = mutableListOf<Triple<UUID, String, Boolean>>()
val cleared = mutableListOf<UUID>()
var snapshotCalls: Int = 0
override suspend fun snapshot(id: UUID): QueueSnapshot {
val next = snapshots[minOf(snapshotCalls, snapshots.lastIndex)]
snapshotCalls += 1
if (next is Throwable) throw next
return next as QueueSnapshot
}
override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome {
enqueued += Triple(id, text, appendEnter)
return writes[minOf(enqueued.size - 1, writes.lastIndex)]
}
override suspend fun clear(id: UUID): QueueWriteOutcome {
cleared += id
return writes[minOf(cleared.size - 1, writes.lastIndex)]
}
}
// ── Verbatim discipline (invariant #9) ───────────────────────────────────────────────────────
@Test
fun `the prompt reaches the gateway byte-for-byte with appendEnter as a flag`() = runTest {
val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Ok(2)))
val vm = QueueViewModel(gateway)
val raw = " run the tests\nthen commit "
vm.enqueue(SESSION, raw)
assertEquals(1, gateway.enqueued.size)
val (id, text, appendEnter) = gateway.enqueued.single()
assertEquals(SESSION, id)
assertEquals(raw, text) // no trim, no normalisation
assertFalse(text.contains('\r')) // the CR is the SERVER's job
assertTrue(appendEnter)
assertEquals(2, vm.uiState.value.depth) // authoritative depth from Ok(length)
}
@Test
fun `an empty prompt is never sent`() = runTest {
val gateway = FakeGateway()
val vm = QueueViewModel(gateway)
vm.enqueue(SESSION, "")
assertTrue(gateway.enqueued.isEmpty())
assertFalse(QueueViewModel.canSend(""))
// whitespace IS legitimate shell input — only EMPTY is refused
assertTrue(QueueViewModel.canSend(" "))
}
// ── Outcome → UI notice ──────────────────────────────────────────────────────────────────────
@Test
fun `a full queue is reported and nothing is retried`() = runTest {
val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Full))
val vm = QueueViewModel(gateway)
vm.enqueue(SESSION, "one more")
assertEquals(QueueNotice.Full, vm.uiState.value.notice)
assertEquals(1, gateway.enqueued.size)
}
@Test
fun `a rate limit is surfaced and NEVER auto-retried`() = runTest {
val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.RateLimited))
val vm = QueueViewModel(gateway)
vm.enqueue(SESSION, "prompt")
assertEquals(QueueNotice.RateLimited, vm.uiState.value.notice)
assertEquals(1, gateway.enqueued.size) // exactly one attempt — a retry burns the shared bucket
assertTrue(vm.uiState.value.isSupported) // 429 is transient, not a capability answer
}
@Test
fun `a 503 hides the affordance for this host`() = runTest {
val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Disabled))
val vm = QueueViewModel(gateway)
vm.enqueue(SESSION, "prompt")
assertFalse(vm.uiState.value.isSupported)
assertEquals(QueueNotice.Disabled, vm.uiState.value.notice)
}
@Test
fun `an oversized prompt asks for a shorter one`() = runTest {
val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.TooLarge)))
vm.enqueue(SESSION, "x".repeat(64))
assertEquals(QueueNotice.TooLarge, vm.uiState.value.notice)
}
@Test
fun `a gone session is reported as gone`() = runTest {
val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.SessionGone)))
vm.enqueue(SESSION, "prompt")
assertEquals(QueueNotice.SessionGone, vm.uiState.value.notice)
}
@Test
fun `a server rejection carries the server's own inert message`() = runTest {
val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.Rejected(403, "forbidden"))))
vm.enqueue(SESSION, "prompt")
assertEquals(QueueNotice.Rejected("forbidden"), vm.uiState.value.notice)
}
@Test
fun `a transport failure surfaces as unreachable and keeps the last-good queue`() = runTest {
val vm = QueueViewModel(
object : QueueGateway {
override suspend fun snapshot(id: UUID): QueueSnapshot = QueueSnapshot(2, listOf("a", "b"))
override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome =
throw java.io.IOException("boom")
override suspend fun clear(id: UUID): QueueWriteOutcome = QueueWriteOutcome.Ok(0)
},
)
vm.refresh(SESSION)
assertEquals(listOf("a", "b"), vm.uiState.value.items)
vm.enqueue(SESSION, "prompt")
assertEquals(QueueNotice.Unreachable, vm.uiState.value.notice)
assertEquals(listOf("a", "b"), vm.uiState.value.items) // last-good kept
}
// ── Read side + cancel-all ───────────────────────────────────────────────────────────────────
@Test
fun `refresh publishes the queued prompts verbatim`() = runTest {
val queued = listOf("first prompt", " second\twith tabs")
val vm = QueueViewModel(FakeGateway(snapshots = listOf(QueueSnapshot(2, queued))))
vm.refresh(SESSION)
assertEquals(2, vm.uiState.value.depth)
assertEquals(queued, vm.uiState.value.items)
assertNull(vm.uiState.value.notice)
}
@Test
fun `a 404 on refresh reports the session gone`() = runTest {
val vm = QueueViewModel(FakeGateway(snapshots = listOf(ApiClientError.SessionNotFound)))
vm.refresh(SESSION)
assertEquals(QueueNotice.SessionGone, vm.uiState.value.notice)
}
@Test
fun `cancel-all empties the queue and drives the depth from the server`() = runTest {
val gateway = FakeGateway(
snapshots = listOf(QueueSnapshot(2, listOf("a", "b"))),
writes = listOf(QueueWriteOutcome.Ok(0)),
)
val vm = QueueViewModel(gateway)
vm.refresh(SESSION)
vm.clearAll(SESSION)
assertEquals(listOf(SESSION), gateway.cleared)
assertEquals(0, vm.uiState.value.depth)
assertTrue(vm.uiState.value.items.isEmpty())
}
// ── The live `queue` WS frame ────────────────────────────────────────────────────────────────
@Test
fun `the queue frame is the authoritative depth and invalidates stale items`() = runTest {
val gateway = FakeGateway(snapshots = listOf(QueueSnapshot(1, listOf("a"))))
val vm = QueueViewModel(gateway)
vm.refresh(SESSION)
vm.onServerDepth(3) // the server drained/accepted entries we did not send
assertEquals(3, vm.uiState.value.depth)
// The items list is now known-stale: it must NOT claim 1 entry while the depth says 3.
assertTrue(vm.uiState.value.isItemsStale)
}
@Test
fun `a zero-depth frame clears the item list outright`() = runTest {
val vm = QueueViewModel(FakeGateway(snapshots = listOf(QueueSnapshot(1, listOf("a")))))
vm.refresh(SESSION)
vm.onServerDepth(0)
assertEquals(0, vm.uiState.value.depth)
assertTrue(vm.uiState.value.items.isEmpty())
assertFalse(vm.uiState.value.isItemsStale)
}
@Test
fun `dismissing a notice leaves the queue itself untouched`() = runTest {
val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.Full)))
vm.refresh(SESSION)
vm.enqueue(SESSION, "prompt")
assertEquals(QueueNotice.Full, vm.uiState.value.notice)
vm.dismissNotice()
assertNull(vm.uiState.value.notice)
assertEquals(0, vm.uiState.value.depth)
}
}

View File

@@ -0,0 +1,293 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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.LiveSessionInfo
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore
import wang.yaojia.webterm.session.UnreadLedger
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* [SessionListViewModel] — the two things that make it stop lying across a process death and a host
* removal:
* 1. the DURABLE watermark seam ([PersistedUnreadWatermarkStore] over `:host-registry`'s
* `SessionWatermarkStore`), including the one-shot [SessionListViewModel.useWatermarkStore] install
* the composition root uses and the rule that it can never swap a store out from under a loaded
* ledger;
* 2. host removal — which must reach the [HostRemovalGateway] with the removed host's live session ids
* (so its watermarks go too) and must NOT drop the row on failure.
*
* The reducer itself ([UnreadLedger]) is NOT retested here — this is only about WHERE the watermark
* lives.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class SessionListPersistenceTest {
private companion object {
const val BASE_A = "http://a:3000"
const val BASE_B = "http://b:3000"
val ID_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
val ID_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")
fun host(id: String, name: String, baseUrl: String): Host =
Host.create(id = id, name = name, baseUrl = baseUrl)!!
fun session(id: UUID, lastOutputAt: Long? = null) = LiveSessionInfo(
id = id,
createdAt = 1_700_000_000_000L,
clientCount = 1,
status = ClaudeStatus.WORKING,
exited = false,
cwd = null,
title = null,
cols = 80,
rows = 24,
telemetry = null,
lastOutputAt = lastOutputAt,
)
}
private class FakeHostStore(initial: List<Host>) : HostStore {
var hosts: List<Host> = initial
override suspend fun loadAll(): List<Host> = hosts
override suspend fun upsert(host: Host): List<Host> = hosts
override suspend fun remove(id: String): List<Host> {
hosts = hosts.filterNot { it.id == id }
return hosts
}
}
private class FakeGateway(private val list: List<LiveSessionInfo>) : SessionListGateway {
override suspend fun liveSessions(): List<LiveSessionInfo> = list
override suspend fun killSession(id: UUID) = Unit
}
/**
* Stands in for the production `HostRemover`: it OWNS the removal, so it drops the host from the
* store exactly like the real one does (a fake that only records would let the VM's re-resolve pass
* against a host list no device would ever show).
*/
private class FakeRemovalGateway(
private val hosts: FakeHostStore? = null,
private val error: Throwable? = null,
) : HostRemovalGateway {
val calls = mutableListOf<Pair<String, List<String>>>()
override suspend fun removeHost(hostId: String, sessionIds: List<String>) {
calls += hostId to sessionIds
error?.let { throw it }
hosts?.remove(hostId)
}
}
// ── The durable watermark seam ────────────────────────────────────────────────────────────────
@Test
fun `a persisted watermark survives a new view model over the same store`() =
runTest(UnconfinedTestDispatcher()) {
val backing = InMemorySessionWatermarkStore()
val store = PersistedUnreadWatermarkStore(backing)
val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A)))
val gateway = FakeGateway(listOf(session(ID_1, lastOutputAt = 100L)))
val first = SessionListViewModel(hostStore = hosts, gatewayFactory = { gateway }, watermarkStore = store)
first.refresh()
assertTrue(first.uiState.value.rows.single().isUnread)
first.markSeen(ID_1)
assertFalse(first.uiState.value.rows.single().isUnread)
// A fresh VM over the SAME durable store == the process restarted.
val second = SessionListViewModel(hostStore = hosts, gatewayFactory = { gateway }, watermarkStore = store)
second.refresh()
assertFalse(second.uiState.value.rows.single().isUnread)
}
@Test
fun `the adapter round-trips the ledger map through the host-registry store`() = runTest {
val backing = InMemorySessionWatermarkStore()
val store = PersistedUnreadWatermarkStore(backing)
store.save(UnreadLedger(mapOf(ID_1.toString() to 42L)))
assertEquals(mapOf(ID_1.toString() to 42L), store.load().watermarks)
assertEquals(mapOf(ID_1.toString() to 42L), backing.loadAll())
}
@Test
fun `the adapter drops ids the durable store refuses`() = runTest {
val store = PersistedUnreadWatermarkStore(InMemorySessionWatermarkStore())
// "not-a-uuid" fails the frozen v4 session-id validation inside the store.
store.save(UnreadLedger(mapOf(ID_1.toString() to 7L, "not-a-uuid" to 9L)))
assertEquals(mapOf(ID_1.toString() to 7L), store.load().watermarks)
}
@Test
fun `useWatermarkStore installs the durable store before the first load`() =
runTest(UnconfinedTestDispatcher()) {
val backing = InMemorySessionWatermarkStore()
backing.replaceAll(mapOf(ID_1.toString() to 100L)) // already seen up to 100
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
)
assertTrue(vm.useWatermarkStore(PersistedUnreadWatermarkStore(backing)))
vm.refresh()
assertFalse(vm.uiState.value.rows.single().isUnread) // the persisted watermark was honoured
}
@Test
fun `useWatermarkStore never overrides an explicitly injected store`() =
runTest(UnconfinedTestDispatcher()) {
val injected = InMemoryUnreadWatermarkStore(UnreadLedger(mapOf(ID_1.toString() to 100L)))
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
watermarkStore = injected,
)
assertFalse(vm.useWatermarkStore(InMemoryUnreadWatermarkStore()))
vm.refresh()
assertFalse(vm.uiState.value.rows.single().isUnread) // still the injected store's watermark
}
@Test
fun `useWatermarkStore is refused once the ledger has loaded`() = runTest(UnconfinedTestDispatcher()) {
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
)
vm.refresh() // loads the ledger from the default in-memory store
assertFalse(vm.useWatermarkStore(InMemoryUnreadWatermarkStore()))
}
@Test
fun `a durable store that throws degrades to no watermarks instead of crashing`() =
runTest(UnconfinedTestDispatcher()) {
val exploding = object : UnreadWatermarkStore {
override suspend fun load(): UnreadLedger = throw IllegalStateException("keystore hiccup")
override suspend fun save(ledger: UnreadLedger) = throw IllegalStateException("disk full")
}
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
watermarkStore = exploding,
)
vm.refresh()
assertTrue(vm.uiState.value.rows.single().isUnread) // no watermarks → unread, never a crash
vm.markSeen(ID_1) // must not throw either
}
// ── Host removal ─────────────────────────────────────────────────────────────────────────────
@Test
fun `removing the active host hands the gateway its live session ids`() =
runTest(UnconfinedTestDispatcher()) {
val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A), host("h2", "desktop", BASE_B)))
val removal = FakeRemovalGateway(hosts)
val vm = SessionListViewModel(
hostStore = hosts,
gatewayFactory = { FakeGateway(listOf(session(ID_1), session(ID_2))) },
removalGateway = removal,
)
vm.refresh()
vm.removeActiveHost()
assertEquals(
listOf("h1" to listOf(ID_1.toString(), ID_2.toString())),
removal.calls.toList(),
)
// The list re-resolves onto the surviving host.
assertEquals("h2", vm.uiState.value.activeHostId)
assertEquals(listOf("h2"), vm.uiState.value.hosts.map { it.id })
}
@Test
fun `removing the only host lands on the empty pair-a-host state`() =
runTest(UnconfinedTestDispatcher()) {
val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A)))
val vm = SessionListViewModel(
hostStore = hosts,
gatewayFactory = { FakeGateway(listOf(session(ID_1))) },
removalGateway = FakeRemovalGateway(hosts),
)
vm.refresh()
vm.removeActiveHost()
assertNull(vm.uiState.value.activeHostId)
assertTrue(vm.uiState.value.rows.isEmpty())
}
@Test
fun `a failed removal leaves the host in place rather than half-removing it`() =
runTest(UnconfinedTestDispatcher()) {
val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A)))
val removal = FakeRemovalGateway(hosts, error = IllegalStateException("push DELETE failed"))
val vm = SessionListViewModel(
hostStore = hosts,
gatewayFactory = { FakeGateway(listOf(session(ID_1))) },
removalGateway = removal,
)
vm.refresh()
vm.removeActiveHost()
assertEquals(1, removal.calls.size)
assertEquals("h1", vm.uiState.value.activeHostId)
assertTrue(vm.uiState.value.hasRemoveError)
}
@Test
fun `useRemovalGateway enables removal for a view model built without one`() =
runTest(UnconfinedTestDispatcher()) {
val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A)))
val removal = FakeRemovalGateway(hosts)
val vm = SessionListViewModel(
hostStore = hosts,
gatewayFactory = { FakeGateway(listOf(session(ID_1))) },
)
vm.refresh()
vm.removeActiveHost() // no gateway installed yet → no-op
assertTrue(removal.calls.isEmpty())
assertEquals(1, hosts.hosts.size)
assertTrue(vm.useRemovalGateway(removal))
assertFalse(vm.useRemovalGateway(FakeRemovalGateway(hosts))) // one-shot
vm.removeActiveHost()
assertEquals(listOf("h1" to listOf(ID_1.toString())), removal.calls.toList())
assertTrue(hosts.hosts.isEmpty())
}
@Test
fun `removeActiveHost with no active host is a no-op`() = runTest(UnconfinedTestDispatcher()) {
val removal = FakeRemovalGateway()
val vm = SessionListViewModel(
hostStore = FakeHostStore(emptyList()),
gatewayFactory = { FakeGateway(emptyList()) },
removalGateway = removal,
)
vm.refresh()
vm.removeActiveHost()
assertTrue(removal.calls.isEmpty())
}
}

View File

@@ -0,0 +1,106 @@
package wang.yaojia.webterm.viewmodels
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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.components.queueNoticeText
import wang.yaojia.webterm.screens.copyResultText
import wang.yaojia.webterm.screens.rotationStatusText
import wang.yaojia.webterm.terminalview.TerminalCopyResult
import wang.yaojia.webterm.wiring.RotationOutcome
import wang.yaojia.webterm.wiring.RotationStage
import java.time.Duration
import java.time.Instant
/**
* The pure copy mappings behind three surfaces whose PIXELS are device-QA but whose *choice of what to
* say* is a correctness property:
* - [queueNoticeText] — every queue failure has its own honest line, and a server-supplied message is
* never allowed to render as the literal "null";
* - [rotationStatusText] — a silent renewal stays SILENT, and only the three actionable outcomes speak;
* - [copyResultText] — a refused clipboard write says so instead of pretending it copied.
* None of them may leak a secret: no copied text, no `Throwable.message`, no credential.
*/
class SurfaceCopyMappingTest {
// ── Queue notices ────────────────────────────────────────────────────────────────────────────
@Test
fun `every queue notice has its own non-blank line`() {
val notices = listOf(
QueueNotice.Full,
QueueNotice.TooLarge,
QueueNotice.Disabled,
QueueNotice.SessionGone,
QueueNotice.RateLimited,
QueueNotice.Unreachable,
QueueNotice.Rejected("forbidden"),
)
val texts = notices.map(::queueNoticeText)
assertTrue(texts.all { it.isNotBlank() })
assertEquals(notices.size, texts.distinct().size, "each notice needs its own copy: $texts")
}
@Test
fun `the rate-limit line states the shared limit and offers no retry`() {
val text = queueNoticeText(QueueNotice.RateLimited)
assertTrue(text.contains("20"), text) // the shared 20/min/IP bucket, named honestly
assertTrue(text.contains("手动"), text) // any retry is the USER's, never automatic
}
@Test
fun `a rejection with no server message falls back to app copy`() {
val text = queueNoticeText(QueueNotice.Rejected(null))
assertTrue(text.isNotBlank())
assertFalse(text.contains("null"), text)
}
@Test
fun `a server message is surfaced verbatim`() {
assertTrue(queueNoticeText(QueueNotice.Rejected("worktree is dirty")).contains("worktree is dirty"))
}
// ── Rotation status ──────────────────────────────────────────────────────────────────────────
@Test
fun `a healthy or in-progress rotation says nothing`() {
assertNull(rotationStatusText(null))
assertNull(rotationStatusText(RotationOutcome.NotEnrolled))
assertNull(rotationStatusText(RotationOutcome.Renewed))
assertNull(rotationStatusText(RotationOutcome.Recovered))
assertNull(rotationStatusText(RotationOutcome.NotDue(Instant.EPOCH)))
assertNull(rotationStatusText(RotationOutcome.BackingOff(Instant.EPOCH)))
}
@Test
fun `the three actionable outcomes all speak`() {
assertNotNull(rotationStatusText(RotationOutcome.ReEnrollRequired(Duration.ofDays(40))))
assertNotNull(rotationStatusText(RotationOutcome.NotConfigured("device-1")))
assertNotNull(rotationStatusText(RotationOutcome.Failed(RotationStage.RENEW, "Http(429)")))
}
@Test
fun `a failure line carries the error SHAPE and says the current cert still works`() {
val text = rotationStatusText(RotationOutcome.Failed(RotationStage.RECOVER, "Http(403,revoked)"))
assertNotNull(text)
assertTrue(text!!.contains("Http(403,revoked)"), text)
assertTrue(text.contains("recover"), text) // which STEP failed
assertTrue(text.contains("仍然有效"), text) // the prior identity is untouched
}
// ── Copy-out ─────────────────────────────────────────────────────────────────────────────────
@Test
fun `every copy outcome is reported distinctly and a refusal is not called success`() {
val texts = TerminalCopyResult.entries.map(::copyResultText) + copyResultText(null)
assertTrue(texts.all { it.isNotBlank() })
assertEquals(texts.size, texts.distinct().size, "each copy outcome needs its own copy: $texts")
// A refused clipboard write must NOT read like a success.
assertFalse(copyResultText(TerminalCopyResult.CLIPBOARD_UNAVAILABLE).contains("已复制"))
}
}

View File

@@ -0,0 +1,183 @@
package wang.yaojia.webterm.wiring
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.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.viewmodels.TokenSubmission
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
import java.io.IOException
/**
* [AccessTokenGateway] — the `WEBTERM_TOKEN` half of pairing, verified against the server contract in
* `src/http/auth.ts` + `src/server.ts` (`POST /auth`: urlencoded `token` field; XHR ⇒ 204 valid / 401
* invalid / 429 over the 10-per-minute limit; the global gate answers an unauthed read with 401).
*
* The credential-hygiene assertions are as load-bearing as the status mapping: the token is a SHELL
* credential, so it must appear ONLY in the request body and never in a URL, a header, a returned
* outcome, or any `toString()` that could reach a log or a crash report.
*/
@DisplayName("AccessTokenGateway — POST /auth + 401 gate detection")
class AccessTokenGatewayTest {
private companion object {
/** A token using the full server-side charset (`[A-Za-z0-9._~+/=-]`) — `+`, `/`, `=` are the
* form-encoding traps: a raw `+` in an urlencoded body decodes to a SPACE server-side. */
const val TOKEN = "ab+cd/ef=gh-ij._~"
const val BASE = "http://10.0.0.5:3000"
}
private class RecordingTransport(
private val handler: (HttpRequest) -> HttpResponse,
) : HttpTransport {
val requests = mutableListOf<HttpRequest>()
override suspend fun send(request: HttpRequest): HttpResponse {
requests += request
return handler(request)
}
}
private class ThrowingTransport(private val error: Throwable) : HttpTransport {
override suspend fun send(request: HttpRequest): HttpResponse = throw error
}
private fun endpoint(url: String = BASE): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
private fun status(code: Int): (HttpRequest) -> HttpResponse =
{ HttpResponse(status = code, body = ByteArray(0)) }
private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport }
// ── Gate detection: is this host token-gated, or is it just not a web-terminal? ──────────────────
@Test
fun `a 401 on the unauthenticated read means the host is token-gated`() = runTest {
val transport = RecordingTransport(status(401))
assertTrue(gateway(transport).requiresAccessToken(endpoint()))
assertEquals("$BASE/live-sessions", transport.requests.single().url)
assertEquals(HttpMethod.GET, transport.requests.single().method)
// The read is Origin-free (plan §4.3: Origin only on guarded routes).
assertTrue(transport.requests.single().headers.keys.none { it.equals("Origin", ignoreCase = true) })
}
@Test
fun `a 200 host is not token-gated`() = runTest {
assertFalse(gateway(RecordingTransport(status(200))).requiresAccessToken(endpoint()))
}
@Test
fun `some other 4xx or 5xx is not read as the token gate`() = runTest {
for (code in listOf(403, 404, 418, 500, 503)) {
assertFalse(
gateway(RecordingTransport(status(code))).requiresAccessToken(endpoint()),
"HTTP $code must not be mistaken for the WEBTERM_TOKEN gate",
)
}
}
@Test
fun `an unreachable host is not reported as token-gated`() = runTest {
// "Nothing answered" must stay distinguishable from "answered 401" — never prompt for a
// credential because the host is down.
assertFalse(gateway(ThrowingTransport(IOException("connect refused"))).requiresAccessToken(endpoint()))
}
// ── POST /auth status mapping ────────────────────────────────────────────────────────────────────
@Test
fun `204 accepts the token`() = runTest {
assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN))
}
@Test
fun `401 is a wrong token, distinct from an unreachable host`() = runTest {
assertEquals(TokenSubmission.Rejected, gateway(RecordingTransport(status(401))).submit(endpoint(), TOKEN))
val unreachable = gateway(ThrowingTransport(IOException("no route"))).submit(endpoint(), TOKEN)
assertInstanceOf(TokenSubmission.Unreachable::class.java, unreachable)
}
@Test
fun `429 is surfaced as rate-limited and never auto-retried`() = runTest {
val transport = RecordingTransport(status(429))
assertEquals(TokenSubmission.RateLimited, gateway(transport).submit(endpoint(), TOKEN))
assertEquals(1, transport.requests.size, "a 429 must not be retried inside the gateway")
}
@Test
fun `an unexpected status is reported as unreachable, not as a wrong token`() = runTest {
val outcome = gateway(RecordingTransport(status(500))).submit(endpoint(), TOKEN)
assertInstanceOf(TokenSubmission.Unreachable::class.java, outcome)
assertEquals("HTTP 500", (outcome as TokenSubmission.Unreachable).reason)
}
// ── Request shape (the server contract) ──────────────────────────────────────────────────────────
@Test
fun `the token is posted as an urlencoded form field with every reserved byte escaped`() = runTest {
val transport = RecordingTransport(status(204))
gateway(transport).submit(endpoint(), TOKEN)
val request = transport.requests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/auth", request.url)
assertEquals(
"application/x-www-form-urlencoded",
request.headers.entries.single { it.key.equals("Content-Type", ignoreCase = true) }.value,
)
// `+` MUST NOT survive raw (Express's qs decodes it to a space) and `/`/`=` would split the field.
assertEquals("token=ab%2Bcd%2Fef%3Dgh-ij._~", request.body?.decodeToString())
}
@Test
fun `no Accept text-html header is sent, so the server answers 204 or 401 instead of redirecting`() =
runTest {
val transport = RecordingTransport(status(204))
gateway(transport).submit(endpoint(), TOKEN)
val accept = transport.requests.single().headers.entries
.firstOrNull { it.key.equals("Accept", ignoreCase = true) }?.value.orEmpty()
assertFalse(accept.contains("text/html"), "an HTML Accept makes /auth answer 302, not 204/401")
}
// ── Credential hygiene ──────────────────────────────────────────────────────────────────────────
@Test
fun `the token never reaches the URL, a header, the outcome or any toString`() = runTest {
val transport = RecordingTransport(status(401))
val outcome = gateway(transport).submit(endpoint(), TOKEN)
val request = transport.requests.single()
assertFalse(request.url.contains(TOKEN), "never put the credential in a URL (logs / Referer)")
assertFalse(request.headers.toString().contains(TOKEN))
assertFalse(outcome.toString().contains(TOKEN))
// The data class renders `body=[B@…` — the bytes are never stringified.
assertFalse(request.toString().contains(TOKEN))
}
@Test
fun `a transport failure reports only the error type, never the message or the token`() = runTest {
// An exception message is attacker/host-influenced and could echo a request; carry the type only.
val error = IOException("failed posting token=$TOKEN")
val outcome = gateway(ThrowingTransport(error)).submit(endpoint(), TOKEN)
val reason = (outcome as TokenSubmission.Unreachable).reason
assertEquals("IOException", reason)
assertFalse(outcome.toString().contains(TOKEN))
}
}

View File

@@ -0,0 +1,174 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
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.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.hostregistry.AuthCookieId
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
import wang.yaojia.webterm.hostregistry.AuthCookieStore
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME
import wang.yaojia.webterm.transport.AuthCookieJar
/**
* [AuthCookieHydrator] — the cold-start half of the `WEBTERM_TOKEN` gate: without it the durable
* `webterm_auth` cookie never reaches the live [AuthCookieJar], so a paired token-gated host would ask
* for the token again on every launch.
*
* Two properties are security-load-bearing and asserted directly:
* - **per-host isolation** — a record filed under host A must never be presented to host B;
* - **hydrate exactly once** — a second hydration could overwrite a FRESHER in-memory cookie with the
* stale persisted one (the durable write is asynchronous), silently reverting to a dead credential.
*/
@DisplayName("AuthCookieHydrator — cold-start restore of the webterm_auth cookie")
class AuthCookieHydratorTest {
private companion object {
const val HOST_A = "http://10.0.0.5:3000"
const val HOST_B = "http://10.0.0.9:3000"
const val TOKEN = "persisted-shell-credential"
const val FAR_FUTURE = 4_000_000_000_000L // ~2096
}
private fun record(
hostKey: String,
host: String,
value: String = TOKEN,
expiresAt: Long = FAR_FUTURE,
) = AuthCookieRecord(
hostKey = hostKey,
name = AUTH_COOKIE_NAME,
value = value,
domain = host,
path = "/",
expiresAtEpochMillis = expiresAt,
secure = false,
httpOnly = true,
hostOnly = true,
)
private fun hydrator(store: AuthCookieStore, jar: AuthCookieJar) =
AuthCookieHydrator(store = { store }, jar = { jar })
private fun AuthCookieJar.valuesFor(baseUrl: String): List<String> =
loadForRequest("$baseUrl/live-sessions".toHttpUrl()).map { it.value }
@Test
fun `a persisted cookie is restored onto its own host and nowhere else`() = runTest {
val store = InMemoryAuthCookieStore(
listOf(record(HOST_A, "10.0.0.5"), record(HOST_B, "10.0.0.9", value = "other-host-token")),
)
val jar = AuthCookieJar()
hydrator(store, jar).hydrateOnce()
assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A))
assertEquals(listOf("other-host-token"), jar.valuesFor(HOST_B))
}
@Test
fun `an expired record is not resurrected`() = runTest {
val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5", expiresAt = 1L)))
val jar = AuthCookieJar()
hydrator(store, jar).hydrateOnce()
assertTrue(jar.valuesFor(HOST_A).isEmpty())
}
@Test
fun `hydration happens exactly once, so a fresher in-memory cookie is never clobbered`() = runTest {
val store = CountingStore(listOf(record(HOST_A, "10.0.0.5", value = "stale")))
val jar = AuthCookieJar()
val hydrator = hydrator(store, jar)
hydrator.hydrateOnce()
assertEquals(listOf("stale"), jar.valuesFor(HOST_A))
// A live re-auth replaces the credential in memory (its durable write is async and may lag).
jar.saveFromResponse(
"$HOST_A/auth".toHttpUrl(),
listOf(
okhttp3.Cookie.Builder()
.name(AUTH_COOKIE_NAME)
.value("fresh")
.expiresAt(FAR_FUTURE)
.path("/")
.hostOnlyDomain("10.0.0.5")
.build(),
),
)
hydrator.hydrateOnce()
assertEquals(1, store.loads, "a second warm-up must not re-read the store")
assertEquals(listOf("fresh"), jar.valuesFor(HOST_A))
}
@Test
fun `a failing store never breaks warm-up and is retried on the next one`() = runTest {
val store = FailingThenWorkingStore(listOf(record(HOST_A, "10.0.0.5")))
val jar = AuthCookieJar()
val hydrator = hydrator(store, jar)
hydrator.hydrateOnce() // throws inside → swallowed
assertTrue(jar.valuesFor(HOST_A).isEmpty())
hydrator.hydrateOnce() // the failure did not consume the one-shot
assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A))
}
@Test
fun `the restored credential is not exposed by the jar or record toString`() = runTest {
val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5")))
val jar = AuthCookieJar()
hydrator(store, jar).hydrateOnce()
assertFalse(jar.toString().contains(TOKEN))
assertFalse(store.loadAll().toString().contains(TOKEN))
}
// ── Doubles ─────────────────────────────────────────────────────────────────────────────────────
private class CountingStore(initial: List<AuthCookieRecord>) : AuthCookieStore {
private val delegate = InMemoryAuthCookieStore(initial)
var loads: Int = 0
override suspend fun loadAll(): List<AuthCookieRecord> {
loads++
return delegate.loadAll()
}
override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey)
override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record)
override suspend fun remove(id: AuthCookieId) = delegate.remove(id)
override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey)
override suspend fun replaceHost(hostKey: String, records: List<AuthCookieRecord>) =
delegate.replaceHost(hostKey, records)
}
private class FailingThenWorkingStore(initial: List<AuthCookieRecord>) : AuthCookieStore {
private val delegate = InMemoryAuthCookieStore(initial)
private var failed = false
override suspend fun loadAll(): List<AuthCookieRecord> {
if (!failed) {
failed = true
error("keystore unavailable")
}
return delegate.loadAll()
}
override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey)
override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record)
override suspend fun remove(id: AuthCookieId) = delegate.remove(id)
override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey)
override suspend fun replaceHost(hostKey: String, records: List<AuthCookieRecord>) =
delegate.replaceHost(hostKey, records)
}
}

View File

@@ -0,0 +1,62 @@
package wang.yaojia.webterm.wiring
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME
import wang.yaojia.webterm.transport.AuthCookieSnapshot
/**
* The `:app`-owned bridge between `:transport-okhttp`'s [AuthCookieSnapshot] (what the live cookie jar
* publishes) and `:host-registry`'s `AuthCookieRecord` (what is encrypted at rest). Neither module may
* depend on the other (`android/README.md`: dependencies only flow down), so this 2-function mapping is
* the seam — and a field dropped here is a silently broken credential, hence the field-for-field test.
*/
@DisplayName("AuthCookie snapshot ⇄ record mapping")
class AuthCookieMappingTest {
private companion object {
const val HOST_KEY = "https://star.terminal.yaojia.wang:443"
const val TOKEN = "shell-credential"
}
private fun snapshot() = AuthCookieSnapshot(
name = AUTH_COOKIE_NAME,
value = TOKEN,
domain = "star.terminal.yaojia.wang",
path = "/",
expiresAtEpochMillis = 4_000_000_000_000L,
secure = true,
httpOnly = true,
hostOnly = true,
)
@Test
fun `every field survives the round trip, with the host key stamped on the record`() {
val original = snapshot()
val record = original.toRecord(HOST_KEY)
assertEquals(HOST_KEY, record.hostKey)
assertEquals(original.name, record.name)
assertEquals(original.value, record.value)
assertEquals(original.domain, record.domain)
assertEquals(original.path, record.path)
// ABSOLUTE wall-clock expiry, never a Max-Age duration (which would restart on every restore).
assertEquals(original.expiresAtEpochMillis, record.expiresAtEpochMillis)
assertEquals(original.secure, record.secure)
assertEquals(original.httpOnly, record.httpOnly)
assertEquals(original.hostOnly, record.hostOnly)
assertEquals(original, record.toSnapshot())
}
@Test
fun `neither side renders the credential in toString`() {
val record = snapshot().toRecord(HOST_KEY)
assertFalse(record.toString().contains(TOKEN))
assertFalse(record.toSnapshot().toString().contains(TOKEN))
}
}

View File

@@ -0,0 +1,331 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
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.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.api.enroll.DeviceRenewalState
import wang.yaojia.webterm.api.enroll.RotationPolicy
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.tlsandroid.CertStore
import wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot
import wang.yaojia.webterm.tlsandroid.EnrollmentRecord
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
import wang.yaojia.webterm.tlsandroid.StoredIdentityMetadata
import java.io.IOException
import java.time.Duration
import java.time.Instant
/**
* The silent-rotation DRIVER: it reads the installed identity's timing, asks the pure `RotationPolicy`
* what to do, and drives the matching call. iOS ships `CertificateRotationScheduler`; Android had no
* counterpart at all, so a device certificate simply died.
*
* The behaviours under test here are the ones that make it safe to fire on every app foreground:
* - it is IDEMPOTENT and single-flight — a second trigger while one pass is in flight coalesces,
* - a failure is SURFACED (observable outcome) and leaves the prior identity fully live,
* - a failure THROTTLES the next attempt instead of hammering a rate-limited control plane, and
* - nothing it logs or surfaces can carry a credential.
*/
class CertificateRotationSchedulerTest {
private companion object {
val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z")
val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z")
const val CP_URL = "https://cp.terminal.yaojia.wang"
/** A base64-looking string standing in for credential material in an error message. */
const val LEAKY_SECRET = "MIIBfzCCASWgAwIBAgIUSECRETCSRBYTES"
}
private val logLines = mutableListOf<String>()
private var clock: Instant = RENEW_AFTER
private var snapshot: DeviceRotationSnapshot? = snapshot()
private var readFault: Exception? = null
private val renewUrls = mutableListOf<String>()
private val recoverUrls = mutableListOf<String>()
private var renewFault: Exception? = null
private var recoverFault: Exception? = null
/** Optional gate a test can arm to hold `renew` suspended (single-flight coalescing). */
private var renewGate: CompletableDeferred<Unit>? = null
private fun snapshot(
notAfter: Instant? = NOT_AFTER,
renewAfter: Instant? = RENEW_AFTER,
controlPlaneUrl: String = CP_URL,
): DeviceRotationSnapshot = DeviceRotationSnapshot(
renewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter),
controlPlaneUrl = controlPlaneUrl,
)
private fun scheduler(): CertificateRotationScheduler = CertificateRotationScheduler(
readState = {
readFault?.let { throw it }
snapshot
},
renew = { url ->
renewUrls += url
renewGate?.await()
renewFault?.let { throw it }
},
recover = { url ->
recoverUrls += url
recoverFault?.let { throw it }
},
now = { clock },
log = { logLines += it },
)
// ── the wait branches ──────────────────────────────────────────────────────────────────────
@Test
fun nothingEnrolledDoesNoWorkAtAll() = runTest {
snapshot = null
assertEquals(RotationOutcome.NotEnrolled, scheduler().runIfDue())
assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty())
}
@Test
fun aCertificateInsideItsRenewWindowIsLeftAlone() = runTest {
clock = RENEW_AFTER.minusSeconds(1)
assertEquals(RotationOutcome.NotDue(RENEW_AFTER), scheduler().runIfDue())
assertTrue(renewUrls.isEmpty(), "the common case must be free")
}
// ── renew ──────────────────────────────────────────────────────────────────────────────────
@Test
fun aDueCertificateRenewsAgainstThePersistedControlPlane() = runTest {
val subject = scheduler()
assertEquals(RotationOutcome.Renewed, subject.runIfDue())
assertEquals(listOf(CP_URL), renewUrls, "renew targets the control plane the device enrolled with")
assertTrue(recoverUrls.isEmpty())
assertEquals(RotationOutcome.Renewed, subject.lastOutcome.value)
}
@Test
fun aBlankControlPlaneUrlRefusesToGuessAHost() = runTest {
// A record written before the URL was persisted has no rotation target. Renewing against a
// compiled-in default would talk to somebody else's control plane.
snapshot = snapshot(controlPlaneUrl = " ")
assertEquals(RotationOutcome.NotConfigured("dev-1"), scheduler().runIfDue())
assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty(), "no request without a known host")
}
// ── recover ────────────────────────────────────────────────────────────────────────────────
@Test
fun anExpiredCertificateRecoversAndNeverAttemptsAnMtlsRenew() = runTest {
clock = NOT_AFTER.plus(Duration.ofDays(8))
assertEquals(RotationOutcome.Recovered, scheduler().runIfDue())
// An expired leaf cannot authenticate its own renewal, so a renew attempt here is not merely
// useless — it is the production deadlock this whole path exists to break.
assertTrue(renewUrls.isEmpty(), "an expired leaf must never be sent to the mTLS renew route")
assertEquals(listOf(CP_URL), recoverUrls)
}
@Test
fun aCertificateExpiredBeyondTheGraceWindowIsTerminalAndSilent() = runTest {
clock = NOT_AFTER.plus(Duration.ofDays(31))
val outcome = scheduler().runIfDue()
assertEquals(RotationOutcome.ReEnrollRequired(Duration.ofDays(31)), outcome)
assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty(), "no request can succeed — make none")
}
// ── failure posture ────────────────────────────────────────────────────────────────────────
@Test
fun aFailedRenewIsSurfacedAndTheStageIsIdentified() = runTest {
renewFault = DeviceEnrollmentError.Http(429, "rate_limited")
val subject = scheduler()
val outcome = subject.runIfDue()
assertEquals(RotationOutcome.Failed(RotationStage.RENEW, "Http(429,rate_limited)"), outcome)
// Observable, not only a log line — iOS surfaces the same as `isCertificateRenewalFailing`.
assertEquals(outcome, subject.lastOutcome.value)
}
@Test
fun aFailedRecoveryIsSurfacedSeparatelyFromAFailedRenew() = runTest {
clock = NOT_AFTER.plus(Duration.ofDays(8))
recoverFault = IOException("connection reset")
assertEquals(
RotationOutcome.Failed(RotationStage.RECOVER, "IOException"),
scheduler().runIfDue(),
)
}
@Test
fun aStateReadFaultIsSurfacedRatherThanReportedAsNotEnrolled() = runTest {
// Degrading a Tink/keystore fault to "nothing enrolled" would disable rotation forever.
readFault = IllegalStateException("keystore unavailable")
assertEquals(
RotationOutcome.Failed(RotationStage.READ_STATE, "IllegalStateException"),
scheduler().runIfDue(),
)
}
@Test
fun aFailureThrottlesTheNextPassInsteadOfHammering() = runTest {
renewFault = IOException("connection reset")
val subject = scheduler()
subject.runIfDue()
clock = RENEW_AFTER.plus(Duration.ofMinutes(1))
val second = subject.runIfDue()
assertEquals(
RotationOutcome.BackingOff(RENEW_AFTER.plus(RotationPolicy.DEFAULT_FAILURE_BACKOFF)),
second,
)
assertEquals(1, renewUrls.size, "the control plane rate-limits renewals — one failure, one call")
}
@Test
fun aSuccessClearsTheThrottle() = runTest {
renewFault = IOException("connection reset")
val subject = scheduler()
subject.runIfDue()
renewFault = null
clock = RENEW_AFTER.plus(RotationPolicy.DEFAULT_FAILURE_BACKOFF)
assertEquals(RotationOutcome.Renewed, subject.runIfDue())
// Still due (the fake state does not change), so a following pass attempts again immediately —
// proving the recorded failure was cleared rather than throttling forever.
assertEquals(RotationOutcome.Renewed, subject.runIfDue())
assertEquals(3, renewUrls.size)
}
// ── single-flight / idempotence ────────────────────────────────────────────────────────────
@Test
fun aSecondTriggerWhileOneIsInFlightCoalescesIntoTheSamePass() = runTest {
val gate = CompletableDeferred<Unit>()
renewGate = gate
val subject = scheduler()
val first = async { subject.runIfDue() }
val second = async { subject.runIfDue() }
// Let both actually start: the first parks inside `renew`, the second must then find the pass
// already in flight and park on ITS result. Without this the first pass would finish before
// the second even began, and the test would prove nothing.
testScheduler.runCurrent()
gate.complete(Unit)
assertEquals(RotationOutcome.Renewed, first.await())
assertEquals(RotationOutcome.Renewed, second.await())
assertEquals(1, renewUrls.size, "two foreground triggers must not stampede two renewals")
}
@Test
fun aCancelledPassDoesNotWedgeTheScheduler() = runTest {
val gate = CompletableDeferred<Unit>()
renewGate = gate
val subject = scheduler()
val cancelled = launch { subject.runIfDue() }
cancelled.cancel()
cancelled.join()
// The in-flight slot must have been released, or every later pass would await a deferred that
// can never complete — rotation dead until the process restarts.
renewGate = null
assertEquals(RotationOutcome.Renewed, subject.runIfDue())
}
// ── credential hygiene ─────────────────────────────────────────────────────────────────────
@Test
fun neitherTheLogNorTheOutcomeCanCarryACredential() = runTest {
// A real exception message can embed a URL, a body or, as here, cert/CSR material. The driver
// must report the error SHAPE only — never the message.
renewFault = IllegalStateException("csr=$LEAKY_SECRET token=hunter2")
val subject = scheduler()
val outcome = subject.runIfDue()
val rendered = "$outcome ${logLines.joinToString(" ")}"
assertFalse(rendered.contains(LEAKY_SECRET), "no certificate/CSR material may be logged or surfaced")
assertFalse(rendered.contains("hunter2"), "no token/passphrase may be logged or surfaced")
assertFalse(rendered.contains("csr="), "not even the message fragment")
assertEquals(RotationOutcome.Failed(RotationStage.RENEW, "IllegalStateException"), outcome)
}
@Test
fun theSuccessLogNamesTheDeviceAndNothingElse() = runTest {
scheduler().runIfDue()
assertTrue(logLines.isNotEmpty(), "a silent rotation must still leave a trace")
val rendered = logLines.joinToString(" ")
assertTrue(rendered.contains("dev-1"), "the non-secret device id is what makes the line useful")
assertFalse(rendered.contains("MII"), "no DER/PEM material")
}
@Test
fun theLastOutcomeStartsEmpty() = runTest {
assertNull(scheduler().lastOutcome.value, "nothing is claimed before a pass has run")
}
// ── the composition root ───────────────────────────────────────────────────────────────────
@Test
fun createBuildsAWorkingSchedulerOverTheRealCollaborators() = runTest {
// `create` is the ONE place that knows both halves (:api-client + :client-tls-android). This
// smoke test runs it over the real DeviceRotationStore / DeviceEnroller types with empty
// stores, so a broken composition root fails here rather than silently at runtime.
val scheduler = CertificateRotationScheduler.create(
certStore = EmptyCertStore,
recordStore = EmptyRecordStore,
cacheRefresher = IdentityCacheRefresher {},
sharedClient = { OkHttpClient() },
mtlsTransport = { FakeHttpTransport() },
plainTransport = { FakeHttpTransport() },
ioDispatcher = StandardTestDispatcher(testScheduler),
now = { clock },
log = { logLines += it },
)
assertEquals(RotationOutcome.NotEnrolled, scheduler.runIfDue())
}
private object EmptyCertStore : CertStore {
override fun save(metadata: StoredIdentityMetadata): Unit = error("not used")
override fun load(): StoredIdentityMetadata? = null
override fun clear(): Unit = error("not used")
}
private object EmptyRecordStore : EnrollmentRecordStore {
override fun save(record: EnrollmentRecord): Unit = error("not used")
override fun load(): EnrollmentRecord? = null
override fun clear(): Unit = error("not used")
}
}

View File

@@ -0,0 +1,89 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
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
import java.time.Instant
/**
* [CertificateRotationTrigger] — the app-lifecycle edge that finally CALLS
* [CertificateRotationScheduler]. Without it the scheduler is dead code and a device certificate simply
* expires, stranding the app (the iOS `AppCoordinator.runCertificateRotationIfDue()` counterpart).
*
* The trigger is deliberately fire-and-forget: `AppEnvironment.warmUp()` is awaited by the terminal
* screen's readiness gate, so a rotation's NETWORK call must never be able to hold the UI. The
* properties asserted here are the ones that make that safe — it launches rather than suspends, it never
* lets a failure escape, and a scheduler that cannot even be constructed does not crash the app.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CertificateRotationTriggerTest {
/** A scheduler whose store read is scripted; `null` state ⇒ NotEnrolled (no network at all). */
private fun scheduler(
readState: suspend () -> wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot? = { null },
) = CertificateRotationScheduler(
readState = readState,
renew = { },
recover = { },
now = { Instant.parse("2026-07-30T00:00:00Z") },
log = { },
)
@Test
fun `firing runs one rotation pass and publishes its outcome`() = runTest(UnconfinedTestDispatcher()) {
val live = scheduler()
val trigger = CertificateRotationTrigger(scope = this, scheduler = { live })
trigger.fire()
assertEquals(RotationOutcome.NotEnrolled, live.lastOutcome.value)
}
@Test
fun `a store fault becomes a Failed outcome, never a thrown exception`() =
runTest(UnconfinedTestDispatcher()) {
val live = scheduler(readState = { throw IllegalStateException("keystore unavailable") })
val trigger = CertificateRotationTrigger(scope = this, scheduler = { live })
trigger.fire() // must not throw
val outcome = live.lastOutcome.value
assertTrue(outcome is RotationOutcome.Failed, "expected Failed, got $outcome")
assertEquals(RotationStage.READ_STATE, (outcome as RotationOutcome.Failed).stage)
// Only the SHAPE is kept — never a message that could embed a URL or credential material.
assertEquals("IllegalStateException", outcome.errorShape)
}
@Test
fun `a scheduler that cannot be resolved is reported, not crashed`() = runTest(UnconfinedTestDispatcher()) {
val errors = mutableListOf<Throwable>()
val trigger = CertificateRotationTrigger(
scope = this,
scheduler = { throw IllegalStateException("graph not ready") },
onError = { errors += it },
)
trigger.fire()
assertEquals(1, errors.size)
}
@Test
fun `fire never suspends the caller on the rotation itself`() = runTest {
// A scheduler whose read blocks forever: `fire()` must still return immediately. If it awaited the
// pass, this test would hang instead of completing (runTest would report the coroutine as stuck).
val live = scheduler(readState = { kotlinx.coroutines.awaitCancellation() })
val scope = TestScope(UnconfinedTestDispatcher(testScheduler))
val trigger = CertificateRotationTrigger(scope = scope, scheduler = { live })
trigger.fire()
assertNull(live.lastOutcome.value) // still in flight, and we got control back
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
}
}

View File

@@ -0,0 +1,192 @@
package wang.yaojia.webterm.wiring
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.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore
import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore
import java.util.UUID
/**
* [HostRemover] — un-pairing a host must erase EVERY trace keyed off it, in the one order that works.
*
* The bug this closes: `PushRegistrar.unregisterHost` had zero call sites, so a dropped host kept the
* device's FCM token forever and kept pushing notifications for a host the user had removed.
*
* Two rules are asserted because getting either wrong is worse than not removing at all:
* - **the remote DELETE happens FIRST**, while the host record and its `webterm_auth` cookie still
* exist — erase them first and the DELETE can neither be addressed nor authenticated;
* - **local erasure is all-or-nothing and `HostStore.remove` is LAST**, so a failure part-way leaves the
* host paired and the operation retryable, never a ghost with orphaned cookies.
* The remote DELETE itself is BEST-EFFORT: a host that is switched off must still be removable.
*/
class HostRemoverTest {
private companion object {
const val BASE = "http://laptop.local:3000"
val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
val SESSION_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")
fun host(id: String = "h1"): Host = Host.create(id = id, name = "laptop", baseUrl = BASE)!!
}
private class RecordingHostStore(initial: List<Host>) : HostStore {
var hosts: List<Host> = initial
var removeError: Throwable? = null
override suspend fun loadAll(): List<Host> = hosts
override suspend fun upsert(host: Host): List<Host> = hosts
override suspend fun remove(id: String): List<Host> {
removeError?.let { throw it }
hosts = hosts.filterNot { it.id == id }
return hosts
}
}
/** Records the (host, token) pairs handed to the push DELETE — and whether it was called at all. */
private class RecordingPush(private val error: Throwable? = null) : HostPushUnregister {
val calls = mutableListOf<Pair<String, String>>()
var hostStillPairedAtCallTime: Boolean? = null
var probe: (() -> Boolean)? = null
override suspend fun unregister(host: Host, token: String) {
hostStillPairedAtCallTime = probe?.invoke()
calls += host.id to token
error?.let { throw it }
}
}
private suspend fun fixture(
hosts: RecordingHostStore = RecordingHostStore(listOf(host())),
push: RecordingPush = RecordingPush(),
token: String? = "fcm-token",
cookieJarClears: MutableList<String> = mutableListOf(),
): Triple<HostRemover, RecordingPush, Quad> {
val lastSession = InMemoryLastSessionStore()
val cookies = InMemoryAuthCookieStore()
val watermarks = InMemorySessionWatermarkStore()
lastSession.setLastSessionId(SESSION_1.toString(), "h1")
cookies.upsert(
AuthCookieRecord(
hostKey = "http://laptop.local:3000",
name = "webterm_auth",
value = "secret",
domain = "laptop.local",
path = "/",
expiresAtEpochMillis = Long.MAX_VALUE,
secure = false,
httpOnly = true,
hostOnly = true,
),
)
watermarks.replaceAll(mapOf(SESSION_1.toString() to 10L, SESSION_2.toString() to 20L))
val remover = HostRemover(
hostStore = hosts,
lastSessionStore = lastSession,
authCookieStore = cookies,
watermarkStore = watermarks,
pushUnregister = push,
currentPushToken = { token },
clearLiveCookies = { key -> cookieJarClears += key },
log = { }, // android.util.Log is not mocked under JVM unit tests
)
return Triple(remover, push, Quad(hosts, lastSession, cookies, watermarks))
}
private data class Quad(
val hosts: RecordingHostStore,
val lastSession: InMemoryLastSessionStore,
val cookies: InMemoryAuthCookieStore,
val watermarks: InMemorySessionWatermarkStore,
)
@Test
fun `removing a host erases every trace keyed off it`() = runTest {
val jarClears = mutableListOf<String>()
val (remover, push, stores) = fixture(cookieJarClears = jarClears)
remover.removeHost("h1", listOf(SESSION_1.toString(), SESSION_2.toString()))
assertEquals(listOf("h1" to "fcm-token"), push.calls.toList()) // the DELETE finally happens
assertTrue(stores.hosts.hosts.isEmpty())
assertNull(stores.lastSession.lastSessionId("h1"))
assertTrue(stores.cookies.loadAll().isEmpty())
assertEquals(listOf("http://laptop.local:3000"), jarClears) // live jar too, not just at rest
assertTrue(stores.watermarks.loadAll().isEmpty())
}
@Test
fun `the remote DELETE runs while the host is still paired`() = runTest {
val hosts = RecordingHostStore(listOf(host()))
val push = RecordingPush()
push.probe = { hosts.hosts.isNotEmpty() }
val (remover, _, _) = fixture(hosts = hosts, push = push)
remover.removeHost("h1", emptyList())
assertTrue(push.hostStillPairedAtCallTime == true)
}
@Test
fun `an unreachable host is still removable`() = runTest {
val push = RecordingPush(error = java.io.IOException("host is off"))
val (remover, _, stores) = fixture(push = push)
remover.removeHost("h1", listOf(SESSION_1.toString()))
assertTrue(stores.hosts.hosts.isEmpty()) // best-effort DELETE must not block the removal
}
@Test
fun `with no push token the local state is still fully erased`() = runTest {
val push = RecordingPush()
val (remover, _, stores) = fixture(push = push, token = null)
remover.removeHost("h1", listOf(SESSION_1.toString()))
assertTrue(push.calls.isEmpty()) // nothing to DELETE
assertTrue(stores.hosts.hosts.isEmpty())
assertNull(stores.lastSession.lastSessionId("h1"))
}
@Test
fun `a failing local erase leaves the host paired so the user can retry`() = runTest {
val hosts = RecordingHostStore(listOf(host()))
hosts.removeError = IllegalStateException("datastore write failed")
val (remover, _, stores) = fixture(hosts = hosts)
assertThrows(IllegalStateException::class.java) {
kotlinx.coroutines.runBlocking { remover.removeHost("h1", listOf(SESSION_1.toString())) }
}
assertEquals(1, stores.hosts.hosts.size) // still paired → retryable, never a ghost
}
@Test
fun `an unknown host id is a no-op that touches nothing`() = runTest {
val push = RecordingPush()
val (remover, _, stores) = fixture(push = push)
remover.removeHost("nope", listOf(SESSION_1.toString()))
assertTrue(push.calls.isEmpty())
assertEquals(1, stores.hosts.hosts.size)
assertFalse(stores.watermarks.loadAll().isEmpty()) // watermarks untouched
}
@Test
fun `only the named sessions lose their watermarks`() = runTest {
val (remover, _, stores) = fixture()
remover.removeHost("h1", listOf(SESSION_1.toString()))
assertEquals(mapOf(SESSION_2.toString() to 20L), stores.watermarks.loadAll())
}
}

View File

@@ -0,0 +1,303 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
import wang.yaojia.webterm.viewmodels.PairingProber
import wang.yaojia.webterm.viewmodels.PairingUiState
import wang.yaojia.webterm.viewmodels.PairingViewModel
import wang.yaojia.webterm.viewmodels.TokenError
import wang.yaojia.webterm.viewmodels.TokenSubmission
import wang.yaojia.webterm.wire.HostEndpoint
/**
* The `WEBTERM_TOKEN` acquisition flow in [PairingViewModel]: prompt → submit → success / wrong / 429.
*
* Lives in the `wiring` test package rather than next to `PairingViewModelTest` because the
* `app/src/test` `viewmodels` package belongs to another agent's file-ownership lane in this run; the
* subject under test is the (public) pairing state machine either way.
*
* Why the flow exists: when a host runs with `WEBTERM_TOKEN` set, `GET /live-sessions` answers **401**
* before anything else can happen (`src/server.ts` `authGate`). The frozen probe taxonomy can only
* report that as `HttpOkButNotWebTerminal` ("wrong port?"), which is actively misleading and leaves a
* token-gated host impossible to pair. So a failed probe asks the prober whether the host is gated, and
* the answer routes to a token prompt instead of the dead-end failure card.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@DisplayName("Pairing — WEBTERM_TOKEN acquisition")
class PairingTokenFlowTest {
private companion object {
const val TOKEN = "s3cret-shell-token"
const val LAN = "http://10.0.0.5:3000"
const val TUNNEL = "https://star.terminal.yaojia.wang"
}
/** Records every probe AND every token submission so "no retry / no re-probe" is directly assertable. */
private class FakeProber(
var probeResult: (HostEndpoint) -> PairingProbeResult = { PairingProbeResult.Success(it) },
var gated: Boolean = false,
var submission: (String) -> TokenSubmission = { TokenSubmission.Accepted },
) : PairingProber {
val probes = mutableListOf<HostEndpoint>()
val submitted = mutableListOf<String>()
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult {
probes += endpoint
return probeResult(endpoint)
}
override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = gated
override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission {
submitted += token
return submission(token)
}
}
private fun endpoint(url: String): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
private fun gatedThenOpen(prober: FakeProber): (HostEndpoint) -> PairingProbeResult = { ep ->
// Before the token: the gate 401s the reachability read, which the probe can only classify as
// "not a web-terminal". After the token is accepted the same probe succeeds.
if (prober.gated) PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
else PairingProbeResult.Success(ep)
}
private fun newVm(
prober: PairingProber,
hasCert: () -> Boolean = { false },
store: InMemoryHostStore = InMemoryHostStore(),
) = PairingViewModel(
hostStore = store,
prober = prober,
hasDeviceCert = { hasCert() },
newId = { "fixed-id" },
)
// ── Prompt ──────────────────────────────────────────────────────────────────────────────────────
@Test
fun `a token-gated host asks for the token instead of showing the wrong-port failure`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
val state = vm.uiState.value
assertInstanceOf(PairingUiState.TokenRequired::class.java, state)
assertEquals(endpoint(LAN), (state as PairingUiState.TokenRequired).endpoint)
assertEquals(null, state.error, "the first prompt is not an error")
}
@Test
fun `an ungated host still shows the wrong-port failure`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(
probeResult = { PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) },
gated = false,
)
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
}
@Test
fun `a non-gate failure is never re-interpreted as a token prompt`() = runTest(UnconfinedTestDispatcher()) {
// `gated` is true here, but the failure is a timeout — only the ambiguous
// HttpOkButNotWebTerminal case is allowed to become a token prompt.
val prober = FakeProber(probeResult = { PairingProbeResult.Failure(PairingError.Timeout) }, gated = true)
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
val state = vm.uiState.value
assertInstanceOf(PairingUiState.Failed::class.java, state)
assertEquals(PairingError.Timeout, (state as PairingUiState.Failed).error)
}
// ── Submit ──────────────────────────────────────────────────────────────────────────────────────
@Test
fun `submitting the right token continues pairing and persists the host`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
// Accepting the token means the cookie jar now holds the credential → the gate opens.
prober.submission = { prober.gated = false; TokenSubmission.Accepted }
val store = InMemoryHostStore()
val vm = newVm(prober, store = store)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
vm.submitAccessToken(TOKEN)
assertEquals(listOf(TOKEN), prober.submitted)
assertEquals(2, prober.probes.size, "an accepted token re-runs the probe")
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
assertEquals(LAN, store.loadAll().single().endpoint.baseUrl)
}
@Test
fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
prober.submission = { TokenSubmission.Rejected }
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
vm.submitAccessToken("wrong")
val state = vm.uiState.value
assertInstanceOf(PairingUiState.TokenRequired::class.java, state)
assertEquals(TokenError.INVALID, (state as PairingUiState.TokenRequired).error)
assertEquals(1, prober.probes.size, "a rejected token must not re-probe")
}
@Test
fun `a 429 is surfaced honestly and never auto-retried`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
prober.submission = { TokenSubmission.RateLimited }
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
vm.submitAccessToken(TOKEN)
assertEquals(TokenError.RATE_LIMITED, (vm.uiState.value as PairingUiState.TokenRequired).error)
assertEquals(1, prober.submitted.size, "rate-limited must not resubmit by itself")
assertEquals(1, prober.probes.size)
}
@Test
fun `an unreachable host during submit is distinguishable from a wrong token`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
prober.submission = { TokenSubmission.Unreachable("IOException") }
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
vm.submitAccessToken(TOKEN)
assertEquals(TokenError.UNREACHABLE, (vm.uiState.value as PairingUiState.TokenRequired).error)
}
@Test
fun `a prober with no token support reports the capability honestly`() =
runTest(UnconfinedTestDispatcher()) {
// The interface defaults exist so a probe-only seam (a test double) still compiles; they must
// surface as an explicit "cannot submit", never as a silent success.
val probeOnly = object : PairingProber {
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult =
PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
}
val vm = newVm(probeOnly)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
// Not gated by default → the plain failure card, exactly as before this feature existed.
assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
}
@Test
fun `a blank token is never submitted`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
vm.submitAccessToken(" ")
assertTrue(prober.submitted.isEmpty())
assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value)
}
@Test
fun `a token submitted outside the prompt is a no-op`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber()
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN) // still on the confirm gate — nothing has asked for a token
vm.submitAccessToken(TOKEN)
assertTrue(prober.submitted.isEmpty())
assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
}
// ── The token must not outlive the submit ────────────────────────────────────────────────────────
@Test
fun `the token is never held in the UI state or any of its toStrings`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
prober.submission = { TokenSubmission.Rejected }
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
val promptState = vm.uiState.value
vm.submitAccessToken(TOKEN)
val afterState = vm.uiState.value
assertFalse(promptState.toString().contains(TOKEN))
assertFalse(afterState.toString().contains(TOKEN))
}
// ── The token path cannot bypass the tunnel cert-gate (RULE 4) ──────────────────────────────────
@Test
fun `an accepted token still funnels through the device-cert gate`() =
runTest(UnconfinedTestDispatcher()) {
var certInstalled = true
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
prober.submission = { prober.gated = false; TokenSubmission.Accepted }
val vm = newVm(prober, hasCert = { certInstalled })
vm.bind(backgroundScope)
vm.onManualEntry(TUNNEL)
vm.confirm()
assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value)
// The cert is removed while the token prompt is open; the post-token probe must re-refuse.
certInstalled = false
vm.submitAccessToken(TOKEN)
assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value)
assertEquals(1, prober.probes.size, "no network probe without a device cert")
}
}

View File

@@ -1,34 +1,56 @@
package wang.yaojia.webterm.wiring
import android.graphics.Bitmap
import com.termux.terminal.TerminalEmulator
import com.termux.terminal.TerminalOutput
import com.termux.terminal.TerminalSessionClient
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
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.assertNotSame
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* The [ThumbnailPipeline] policy (plan §6.7), driven at JVM speed with fakes so the cache/concurrency/
* dedup/failure logic is verified without android.graphics (the pixel raster lives behind the
* [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd).
* dedup/supersede/timeout/failure logic is verified without android.graphics (the pixel raster lives behind
* the [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd). The rasterizer's two
* SIZE decisions are pure ([ThumbnailGrid] / [ThumbnailBudget]) and are tested here too, one of them against
* a real off-screen [TerminalEmulator] (pure Java — the emulator needs no android.graphics).
*
* Proves:
* - an unchanged `(sessionId, lastOutputAt)` ⇒ cache hit, NO re-render; a changed `lastOutputAt` ⇒ render;
* - the `Semaphore(2)` caps concurrent fetch+render at 2;
* - two concurrent same-key requests SHARE one render (one fetch, one raster, one bitmap);
* - a NEWER key for a session supersedes the older in-flight render and frees its permit;
* - a stalled fetch times out, releases its permit and falls back to the placeholder (liveness);
* - a request after `close()` returns null and leaks no in-flight entry;
* - a render landing after its session was pruned does not re-enter the cache;
* - a fetch failure caches the placeholder and is NOT retried on the next request (no hot-loop storm);
* - [PreviewCap] caps the body at 256 KiB, keeping the tail.
* - the off-screen grid never aliases rows, and no server geometry can blow the raster budget;
* - [PreviewCap] caps the emulator's input at 256 KiB, keeping the tail.
*
* **Virtual-time note:** a render is now bounded by [ThumbnailPipeline.DEFAULT_RENDER_TIMEOUT_MS], so
* `advanceUntilIdle()` would advance past that timeout and cancel a deliberately-parked fetch. Tests that
* observe IN-FLIGHT state therefore use `runCurrent()` (run what is ready, advance no virtual time).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ThumbnailPipelineTest {
@@ -59,7 +81,7 @@ class ThumbnailPipelineTest {
}
/** Hands back a fresh mock bitmap per raster (so identity distinguishes renders) + a fixed placeholder. */
private class FakeRasterizer : ThumbnailRasterizer {
private class FakeRasterizer(private val placeholderFailure: Throwable? = null) : ThumbnailRasterizer {
var rasterizeCount = 0
val placeholderBitmap: Bitmap = mockk()
@@ -68,7 +90,10 @@ class ThumbnailPipelineTest {
return mockk()
}
override fun placeholder(): Bitmap = placeholderBitmap
override fun placeholder(): Bitmap {
placeholderFailure?.let { throw it }
return placeholderBitmap
}
}
/** Map-backed cache seam — keeps LRU KEYING testable without touching the stubbed android.util.LruCache. */
@@ -78,6 +103,10 @@ class ThumbnailPipelineTest {
override fun put(key: ThumbnailKey, bitmap: Bitmap) {
map[key] = bitmap
}
override fun retainOnly(keys: Set<ThumbnailKey>) {
map.keys.retainAll(keys)
}
}
private fun session(id: UUID, lastOutputAt: Long?): LiveSessionInfo = LiveSessionInfo(
@@ -90,6 +119,32 @@ class ThumbnailPipelineTest {
lastOutputAt = lastOutputAt,
)
/**
* A bare off-screen [TerminalEmulator] — the same construction [CanvasThumbnailRasterizer] uses, minus
* the `Canvas`. Termux's emulator/buffer are pure Java (no android.graphics), so the row-mapping
* geometry is verifiable at JVM speed; only the pixel draw is device-QA.
*/
private fun offScreenEmulator(cols: Int, rows: Int, transcriptRows: Int): TerminalEmulator =
TerminalEmulator(
mockk<TerminalOutput>(relaxed = true),
cols,
rows,
transcriptRows,
mockk<TerminalSessionClient>(relaxed = true),
)
private companion object {
/** Short virtual-time render budget so the timeout test doesn't encode the production value. */
const val TEST_RENDER_TIMEOUT_MS = 1_000L
/** Bytes per pixel of `Bitmap.Config.ARGB_8888` (the config the rasterizer allocates). */
const val ARGB_8888_BYTES = 4
/** A representative monospace cell at the rasterizer's 12px default (measured on-device). */
const val TEMPLATE_CELL_WIDTH_PX = 7
const val TEMPLATE_CELL_HEIGHT_PX = 14
}
// ── Tests ─────────────────────────────────────────────────────────────────────────────────
@Test
@@ -135,7 +190,7 @@ class ThumbnailPipelineTest {
)
val jobs = (0 until 5).map { launch { pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L)) } }
advanceUntilIdle()
runCurrent() // no virtual time passes — the parked fetches must not hit the render timeout
// Only 2 permits → only 2 fetches in flight; the other 3 are parked on the semaphore.
assertEquals(2, source.active)
@@ -165,7 +220,7 @@ class ThumbnailPipelineTest {
val a = async { pipeline.thumbnail(same) }
val b = async { pipeline.thumbnail(same) }
advanceUntilIdle()
runCurrent()
assertEquals(1, source.fetchStarts) // deduped: a single in-flight fetch
gate.complete(Unit)
@@ -201,6 +256,401 @@ class ThumbnailPipelineTest {
assertEquals(1, source.fetchStarts)
}
/**
* A rasterizer failure that ALSO breaks the placeholder (a `Bitmap` OOM is the realistic case) must
* surface as `null`, NOT as an exception — the row's `LaunchedEffect` collects this directly, so a
* throw would take down the composition. It must also leave no poisoned in-flight entry: the next
* request retries instead of re-awaiting a permanently-failed `Deferred`.
*/
@Test
fun `an unrenderable thumbnail returns null and is retried rather than poisoning the key`() = runTest {
val source = FakePreviewSource(failure = RuntimeException("boom"))
val raster = FakeRasterizer(placeholderFailure = OutOfMemoryError("bitmap"))
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
)
val s = session(UUID.randomUUID(), lastOutputAt = 9L)
val first = async { pipeline.thumbnail(s) }
advanceUntilIdle()
assertNull(first.await())
assertEquals(1, source.fetchStarts)
// Nothing cacheable ⇒ the key is free again (no stale in-flight Deferred to re-await forever).
val second = async { pipeline.thumbnail(s) }
advanceUntilIdle()
assertNull(second.await())
assertEquals(2, source.fetchStarts)
}
/**
* The render lives in the PIPELINE's scope, not the caller's: a card scrolled off-screen mid-render
* (its collector cancelled) must not cancel the shared render another card is awaiting.
*/
@Test
fun `a cancelled caller does not cancel the shared render`() = runTest {
val gate = CompletableDeferred<Unit>()
val source = FakePreviewSource(block = gate)
val raster = FakeRasterizer()
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
)
val s = session(UUID.randomUUID(), lastOutputAt = 3L)
val scrolledAway = launch { pipeline.thumbnail(s) }
runCurrent()
scrolledAway.cancel()
runCurrent()
val stillVisible = async { pipeline.thumbnail(s) }
runCurrent()
gate.complete(Unit)
advanceUntilIdle()
assertNotNull(stillVisible.await())
assertEquals(1, source.fetchStarts) // the first render survived the cancelled caller
assertEquals(1, raster.rasterizeCount)
}
/**
* Bitmaps must not be retained for sessions that are gone (killed/exited) or for superseded
* `lastOutputAt` keys: [ThumbnailPipeline.retainOnly] prunes everything outside the current live list,
* so a still-live session keeps its cache hit while a dead one's bitmap is released.
*/
@Test
fun `retainOnly releases bitmaps for sessions no longer live`() = runTest {
val raster = FakeRasterizer()
val pipeline = ThumbnailPipeline(
previewSource = FakePreviewSource(),
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
)
val live = session(UUID.randomUUID(), lastOutputAt = 1L)
val dead = session(UUID.randomUUID(), lastOutputAt = 1L)
val warm = launch {
pipeline.thumbnail(live)
pipeline.thumbnail(dead)
}
advanceUntilIdle()
assertTrue(warm.isCompleted)
assertEquals(2, raster.rasterizeCount)
pipeline.retainOnly(listOf(live))
// The live session is still a cache hit …
val hit = async { pipeline.thumbnail(live) }
advanceUntilIdle()
hit.await()
assertEquals(2, raster.rasterizeCount)
// … the dead one's bitmap was released, so asking again re-renders (it is no longer retained).
val miss = async { pipeline.thumbnail(dead) }
advanceUntilIdle()
miss.await()
assertEquals(3, raster.rasterizeCount)
}
/**
* **Supersede (head-of-line blocking).** `lastOutputAt` advances on every PTY output and the list polls
* every 5 s, so an ACTIVE session mints a new key on every tick. The obsolete render must be abandoned:
* left alone it holds a permit while the row waits for the screen it is actually showing, and its bitmap
* would land in the cache under a key nothing will ever ask for again.
*/
@Test
fun `a newer key supersedes the in-flight render for the same session and frees its permit`() = runTest {
val stall = CompletableDeferred<Unit>()
val source = FakePreviewSource(block = stall)
val raster = FakeRasterizer()
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
maxConcurrent = 1, // ONE permit: whether the obsolete render blocks the fresh one is the point
)
val id = UUID.randomUUID()
val stale = async { pipeline.thumbnail(session(id, lastOutputAt = 100L)) }
runCurrent()
assertEquals(1, source.fetchStarts)
assertEquals(1, source.active) // holds the only permit
val fresh = async { pipeline.thumbnail(session(id, lastOutputAt = 200L)) }
runCurrent()
assertEquals(2, source.fetchStarts) // the freed permit let the NEWEST key start
assertEquals(1, source.active) // and only one render is alive — the obsolete one was abandoned
stall.complete(Unit)
advanceUntilIdle()
assertNull(stale.await()) // the superseded caller gets no bitmap; its screen no longer exists
assertNotNull(fresh.await())
assertEquals(1, raster.rasterizeCount) // only the newest key was ever rasterised
}
/**
* **Liveness.** The shared REST client sets no `callTimeout` and its per-socket read timeout is reset by
* every trickled byte, and a render is deliberately not cancellable by any caller — so without a bound
* inside the render, two slow-trickle previews hold both permits forever and NO thumbnail renders again
* until `close()`. The timeout raises `TimeoutCancellationException`, which IS a `CancellationException`:
* it must land in the PLACEHOLDER branch, never the rethrow branch.
*/
@Test
fun `a stalled fetch times out, frees its permit and falls back to the placeholder`() = runTest {
val stall = CompletableDeferred<Unit>() // never completed: a host that trickles and never finishes
val source = FakePreviewSource(block = stall)
val raster = FakeRasterizer()
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
renderTimeoutMs = TEST_RENDER_TIMEOUT_MS,
)
val stalled = (0 until 2).map { async { pipeline.thumbnail(session(UUID.randomUUID(), 1L)) } }
runCurrent()
assertEquals(2, source.active) // both permits held by fetches that will never finish
val queued = async { pipeline.thumbnail(session(UUID.randomUUID(), 1L)) }
runCurrent()
assertEquals(2, source.fetchStarts) // parked on the semaphore behind the stalled pair
advanceTimeBy(TEST_RENDER_TIMEOUT_MS + 1)
runCurrent()
// Both stalled renders gave up with the placeholder (cached ⇒ no retry storm) instead of dying or
// wedging, and — the whole point — the queued key got a permit and ran.
stalled.forEach { assertSame(raster.placeholderBitmap, it.await()) }
assertEquals(3, source.fetchStarts)
advanceTimeBy(TEST_RENDER_TIMEOUT_MS + 1)
runCurrent()
assertSame(raster.placeholderBitmap, queued.await())
assertEquals(0, source.active)
pipeline.close()
}
/**
* `close()` is public and unguarded, and `scope.async` on a cancelled scope never runs the body — so the
* body's `finally` (which frees the in-flight slot) never runs either. Inserting after close would leave
* a dead `Deferred` under that key forever: every later request for it awaits a corpse and gets null,
* and `inFlight` grows by every distinct post-close key.
*/
@Test
fun `a request after close returns null and leaks no in-flight entry`() = runTest {
val source = FakePreviewSource()
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = FakeRasterizer(),
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
)
pipeline.close()
val afterClose = pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L))
advanceUntilIdle()
assertNull(afterClose)
assertEquals(0, source.fetchStarts) // nothing was fetched on a dead pipeline …
assertEquals(0, pipeline.inFlightCount()) // … and no key was left holding a dead Deferred
}
/**
* The prune is defeatable by a render that finishes AFTER it: re-inserting a killed session's bitmap
* leaves it cached until the row set changes again. The caller that asked still gets its bitmap (it
* asked), but nothing outlives the session in the cache.
*/
@Test
fun `a render landing after its session was pruned is not put back in the cache`() = runTest {
val stall = CompletableDeferred<Unit>()
val source = FakePreviewSource(block = stall)
val raster = FakeRasterizer()
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
)
val killed = session(UUID.randomUUID(), lastOutputAt = 4L)
val landing = async { pipeline.thumbnail(killed) }
runCurrent()
assertEquals(1, source.fetchStarts)
pipeline.retainOnly(emptyList()) // the session was killed while its render was in flight
stall.complete(Unit)
advanceUntilIdle()
assertNotNull(landing.await())
// Nothing was cached for the dead session, so asking again re-fetches and re-renders.
val again = async { pipeline.thumbnail(killed) }
advanceUntilIdle()
again.await()
assertEquals(2, source.fetchStarts)
assertEquals(2, raster.rasterizeCount)
}
// ── Off-screen grid + raster budget (pure; the real emulator is pure Java) ─────────────────
/**
* **The thumbnail must not draw duplicated rows.** Termux's `TerminalBuffer` keeps
* `mTotalRows = transcriptRows` and maps `externalToInternalRow(row) = (mScreenFirstRow + row) %
* mTotalRows`, so a transcript SHORTER than the screen aliases external row *n* onto *n mTotalRows*.
* The off-screen emulator used a fixed `TERMINAL_TRANSCRIPT_ROWS_MIN` (100) against rows up to 200,
* so rows 100..199 aliased onto 0..99 — asserted below both ways: the fixed grid is injective, and the
* old geometry provably is not (so this regression cannot quietly come back as "no longer reproducible").
*/
@Test
fun `the off-screen grid never aliases screen rows onto each other`() {
val grid = ThumbnailGrid.of(cols = 120, rows = 200)
assertEquals(120, grid.cols)
assertEquals(200, grid.rows)
assertTrue(
grid.transcriptRows >= grid.rows,
"transcriptRows=${grid.transcriptRows} must be >= rows=${grid.rows}",
)
val fixed = offScreenEmulator(grid.cols, grid.rows, grid.transcriptRows)
val fixedRows = (0 until grid.rows).map { fixed.screen.externalToInternalRow(it) }
assertEquals(grid.rows, fixedRows.toSet().size, "external rows must map 1:1 onto internal rows")
val aliased = offScreenEmulator(grid.cols, grid.rows, TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN)
val aliasedRows = (0 until grid.rows).map { aliased.screen.externalToInternalRow(it) }
assertTrue(
aliasedRows.toSet().size < grid.rows,
"the aliasing this guards against is no longer reproducible — re-derive the guard",
)
}
@Test
fun `the off-screen grid clamps a rogue server geometry and never degenerates`() {
// cols/rows are SERVER-supplied: src/protocol.ts validates a resize only to 1..1000.
val rogue = ThumbnailGrid.of(cols = 1000, rows = 1000)
assertEquals(ThumbnailGrid.MAX_COLS, rogue.cols)
assertEquals(ThumbnailGrid.MAX_ROWS, rogue.rows)
assertTrue(rogue.transcriptRows >= rogue.rows)
val degenerate = ThumbnailGrid.of(cols = 0, rows = -5)
assertEquals(1, degenerate.cols)
assertEquals(1, degenerate.rows)
assertTrue(degenerate.transcriptRows >= TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN)
}
/**
* **No server-reported geometry may drive a huge allocation.** A 1000×1000 session (which the protocol
* permits) at a 12px monospace cell was a 2800×2800 ARGB_8888 = 31.4 MB bitmap — ×2 concurrent permits,
* re-run every 5 s poll tick. It degraded safely (an OOM is caught) but invited an LMK kill of the app.
*/
@Test
fun `no server geometry can make the off-screen raster exceed the budget`() {
val budgetBytes = ThumbnailBudget.MAX_WIDTH_PX.toLong() *
ThumbnailBudget.MAX_HEIGHT_PX * ARGB_8888_BYTES
for (cols in intArrayOf(1, 20, 80, 161, 400, 1000)) {
for (rows in intArrayOf(1, 24, 50, 200, 1000)) {
val grid = ThumbnailGrid.of(cols, rows)
val cell = ThumbnailBudget.cellMetrics(
cols = grid.cols,
rows = grid.rows,
templateWidth = TEMPLATE_CELL_WIDTH_PX,
templateHeight = TEMPLATE_CELL_HEIGHT_PX,
)
val width = grid.cols * cell.width
val height = grid.rows * cell.height
val where = "$cols×$rows${width}×$height px (cell ${cell.width}×${cell.height})"
assertTrue(cell.width >= 1 && cell.height >= 1, "zero-sized cell: $where")
assertTrue(width <= ThumbnailBudget.MAX_WIDTH_PX, "width over budget: $where")
assertTrue(height <= ThumbnailBudget.MAX_HEIGHT_PX, "height over budget: $where")
assertTrue(width.toLong() * height * ARGB_8888_BYTES <= budgetBytes, "bytes over budget: $where")
}
}
}
@Test
fun `the cell shrink preserves the cell aspect and never upscales a small screen`() {
// 80×24 at a 7×14 cell is 560×336 — just over the 480px budget, so both axes take the SAME factor
// (480/560) and the glyph cell keeps its shape instead of being squeezed on one axis.
val shrunk = ThumbnailBudget.cellMetrics(cols = 80, rows = 24, templateWidth = 7, templateHeight = 14)
assertEquals(6, shrunk.width)
assertEquals(12, shrunk.height)
// Already inside the budget → draw at the font's own cell size, no shrink at all.
val asRendered = ThumbnailBudget.cellMetrics(cols = 40, rows = 10, templateWidth = 7, templateHeight = 14)
assertEquals(7, asRendered.width)
assertEquals(14, asRendered.height)
assertEquals(1f, asRendered.textScale)
}
@Test
fun `raster budget downscales an oversized terminal raster and leaves small ones alone`() {
// A 161x50 session at 12px monospace rasterises to ~1127x700 = 3.1 MiB ARGB_8888 — three of those
// blow an 8 MiB cache. The budget scales it to the card's resolution, preserving aspect ratio.
val scaled = ThumbnailBudget.scaledSize(width = 1127, height = 700, maxWidth = 480)
assertNotNull(scaled)
assertEquals(480, scaled!!.width)
assertEquals(298, scaled.height) // 700 * 480 / 1127, floored
// Already inside the budget → no scale step at all (null = draw as rendered).
assertNull(ThumbnailBudget.scaledSize(width = 320, height = 200, maxWidth = 480))
// A degenerate ratio never collapses to a zero-height bitmap.
val sliver = ThumbnailBudget.scaledSize(width = 4000, height = 3, maxWidth = 480)
assertNotNull(sliver)
assertEquals(1, sliver!!.height)
// HEIGHT is bounded too: a tall/narrow raster is already inside the width bound, so a width-only
// budget left it at full size (140×2800 = 1.5 MiB cached for a 96×60dp tile).
val tall = ThumbnailBudget.scaledSize(width = 140, height = 2800, maxWidth = 480, maxHeight = 480)
assertNotNull(tall)
assertEquals(480, tall!!.height)
assertEquals(24, tall.width) // aspect preserved on the tighter axis
}
/**
* **A thumbnail fetch must NEVER attach.** Looking at the home chooser reads the ring-buffer tail over
* the read-only `GET /live-sessions/:id/preview` route ONLY — it must not open a WS, must not register
* a client, and must not touch the session's idle clock, or merely displaying the grid would keep every
* session alive forever and defeat the idle-reclaim design (plan §5.2 / §6.7).
*
* Asserted at the transport: exactly ONE request, `GET`, on the preview path, carrying **no `Origin`
* header** (which is the marker of a guarded/state-changing route — plan §4.2/§4.3).
*/
@Test
fun `the production preview source issues only the read-only preview GET and never attaches`() = runTest {
val transport = FakeHttpTransport()
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.7:3000"))
val id = UUID.fromString("11111111-2222-4333-8444-555555555555")
val previewUrl = "http://10.0.0.7:3000/live-sessions/$id/preview"
transport.queueSuccess(
method = HttpMethod.GET,
url = previewUrl,
body = """{"id":"$id","cols":80,"rows":24,"data":"hello"}""".toByteArray(Charsets.UTF_8),
)
val fetched = ApiPreviewSource { ApiClient(endpoint = endpoint, http = transport) }.fetch(id)
assertEquals("hello", fetched.data)
assertEquals(1, transport.recordedRequests.size)
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.GET, request.method)
assertEquals(previewUrl, request.url)
assertNull(request.body)
assertNull(request.headers["Origin"], "the RO preview route must not carry Origin: ${request.headers}")
}
/**
* [PreviewCap] bounds what reaches the EMULATOR, not the response body (which the transport has already
* buffered and parsed by then — see its KDoc). 256 KiB is the client's own ceiling: the server's
* `PREVIEW_BYTES` default is 24 KiB and an operator may raise it with no upper bound.
*/
@Test
fun `preview cap keeps the tail when over 256 KiB and is a no-op under the cap`() {
val big = "A".repeat(PreviewCap.MAX_PREVIEW_BYTES + 100) + "TAIL"

View File

@@ -0,0 +1,85 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.CancellationException
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.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.wire.HostEndpoint
/**
* [UiConfigGate] — the client half of `GET /config/ui`'s `allowAutoMode`.
*
* `ApiClient.uiConfig()` was implemented and never called, so the plan-gate sheet offered
* "approve + auto-accept edits" even on a host whose operator had switched `ALLOW_AUTO_MODE` off. The
* gate closes that, and the ONE rule that matters is that **failure fails CLOSED**: an unreachable host,
* a pre-`/config/ui` server, or a garbled body must all mean "auto is not allowed", because the
* permissive default is the dangerous one.
*/
class UiConfigGateTest {
private companion object {
val HOST_A: HostEndpoint = HostEndpoint.fromBaseUrl("http://a:3000")!!
val HOST_B: HostEndpoint = HostEndpoint.fromBaseUrl("http://b:3000")!!
}
@Test
fun `auto mode is allowed when the host says so`() = runTest {
var calls = 0
val gate = UiConfigGate { calls += 1; UiConfig(allowAutoMode = true) }
assertTrue(gate.allowsAutoMode(HOST_A))
assertEquals(1, calls)
}
@Test
fun `auto mode is refused when the host disabled it`() = runTest {
val gate = UiConfigGate { UiConfig(allowAutoMode = false) }
assertFalse(gate.allowsAutoMode(HOST_A))
}
@Test
fun `a failed fetch fails CLOSED`() = runTest {
val gate = UiConfigGate { throw java.io.IOException("host unreachable") }
assertFalse(gate.allowsAutoMode(HOST_A))
}
@Test
fun `a successful answer is cached per host`() = runTest {
val seen = mutableListOf<String>()
val gate = UiConfigGate { endpoint ->
seen += endpoint.baseUrl
UiConfig(allowAutoMode = endpoint == HOST_A)
}
assertTrue(gate.allowsAutoMode(HOST_A))
assertTrue(gate.allowsAutoMode(HOST_A)) // served from cache
assertFalse(gate.allowsAutoMode(HOST_B)) // a DIFFERENT host is fetched, not inherited
assertEquals(listOf(HOST_A.baseUrl, HOST_B.baseUrl), seen.toList())
}
@Test
fun `a failure is NOT cached so a host that comes back is re-asked`() = runTest {
var attempt = 0
val gate = UiConfigGate {
attempt += 1
if (attempt == 1) throw java.io.IOException("down") else UiConfig(allowAutoMode = true)
}
assertFalse(gate.allowsAutoMode(HOST_A)) // fail closed
assertTrue(gate.allowsAutoMode(HOST_A)) // recovered → now allowed
assertEquals(2, attempt)
}
@Test
fun `cancellation propagates instead of being read as a policy answer`() = runTest {
val gate = UiConfigGate { throw CancellationException("scope cancelled") }
assertThrows(CancellationException::class.java) {
kotlinx.coroutines.runBlocking { gate.allowsAutoMode(HOST_A) }
}
}
}

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