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:
3
android/.gitignore
vendored
3
android/.gitignore
vendored
@@ -40,3 +40,6 @@ service-account*.json
|
|||||||
|
|
||||||
# Kover / test reports
|
# Kover / test reports
|
||||||
**/kover/
|
**/kover/
|
||||||
|
|
||||||
|
# Kotlin compiler session/error scratch (KGP 2.x writes this at the project root)
|
||||||
|
.kotlin/
|
||||||
|
|||||||
@@ -1,19 +1,133 @@
|
|||||||
# Android client — device-QA checklist (A36 / plan §7)
|
# Android client — device-QA checklist (A36 / plan §7)
|
||||||
|
|
||||||
Everything below is **device/emulator-only** — it could NOT run in the build environment (no emulator,
|
Everything below is **device/emulator-only**: it cannot be proven by the JVM suite. The pure logic
|
||||||
no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests,
|
underneath each item IS unit-tested (757 JVM tests across 10 modules, Kover ≥80% on the pure modules),
|
||||||
Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping.
|
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)
|
## 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
|
- [ ] `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).
|
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
|
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
|
||||||
omitted it — applying it without the json fails the build).
|
omitted it — applying it without the json fails the build).
|
||||||
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
|
- [ ] `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
|
SHA-256 (for the verified App Link `autoVerify`). Read it with
|
||||||
`FCM_*` env group (service-account key path + project id).
|
`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.
|
- [ ] `attach → attached → output` round-trip timing.
|
||||||
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
|
||||||
|
|
||||||
## A35 — one macrobenchmark / Espresso happy path
|
## 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.
|
- [ ] 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)
|
## Terminal (A16/A17/A21)
|
||||||
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
|
- [ ] 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).
|
WebView fallback ONLY if fidelity diverges — not expected).
|
||||||
- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap.
|
- [ ] IME/CJK composition; http/https link tap. (Selection→clipboard is the gap above, not a test.)
|
||||||
- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are
|
- [ ] **real font-metric → grid resize** and resize parity vs web/iOS on the same device sizes (R5).
|
||||||
package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); 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
|
- [ ] **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 →
|
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
|
||||||
generation bump → fresh emulator replays.
|
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
|
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
|
||||||
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
|
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
|
||||||
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
|
- [ ] 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
|
- [ ] token registers to every paired host + self-heals on rotation / new host / removal.
|
||||||
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)
|
## 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
|
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
|
||||||
bypass); public host explicit-ack.
|
bypass); public host explicit-ack.
|
||||||
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
|
- [ ] 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
|
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
|
||||||
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
|
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.
|
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
||||||
|
|
||||||
## Nav / deep links / adaptive (A32/A26/A29)
|
## Nav / deep links / adaptive (A32/A26/A29)
|
||||||
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
||||||
right destination; invalid UUID ignored.
|
right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and
|
||||||
- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens.
|
`assetlinks.json`.)
|
||||||
|
- [ ] continue-last-session banner re-opens. (The no-host → pairing half is observed above.)
|
||||||
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
||||||
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
`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) ;
|
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).
|
**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
|
- [ ] 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.
|
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 "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
|
||||||
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
|
- [ ] 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`
|
- [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a
|
||||||
is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to
|
`dataExtractionRules` `<device-transfer>` xml resource. Until then a D2D migration could copy the
|
||||||
`:app` and delete the shim.
|
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.
|
||||||
|
|||||||
@@ -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 (A1–A36 + S1) landed
|
## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1–A36 + S1) landed
|
||||||
|
|
||||||
Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure
|
Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure
|
||||||
|
|||||||
@@ -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
|
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
||||||
upward* (ARCHITECTURE §1).
|
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)
|
## Build environment (SDK installed — all modules build)
|
||||||
|
|
||||||
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
|
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`.
|
`:client-tls`, `:test-support`, `:transport-okhttp`.
|
||||||
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
|
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
|
||||||
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
`: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`;
|
Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`;
|
||||||
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
|
`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)
|
## 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 |
|
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
||||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
||||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ 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`
|
`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a
|
||||||
> impls, JVM) is owned by task **A7** and will be added then. The iOS
|
`com.android.test` module — a **separate APK** that drives `:app` out of process via
|
||||||
> `URLSession*Transport`s consolidate into it (plan §3 framing note).
|
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")
|
### Dependency graph (arrows = "depends on")
|
||||||
|
|
||||||
@@ -83,14 +97,123 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
|
|||||||
```bash
|
```bash
|
||||||
# Use the committed wrapper for everything.
|
# Use the committed wrapper for everything.
|
||||||
./gradlew help # sanity: the build configures
|
./gradlew help # sanity: the build configures
|
||||||
./gradlew projects # lists the 5 pure modules
|
./gradlew projects # lists every module
|
||||||
./gradlew build # compile all pure modules
|
./gradlew test # JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
|
||||||
./gradlew test # run 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`,
|
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
||||||
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
|
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
|
||||||
> small focused files — same discipline as the rest of the repo.
|
> 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)
|
## 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
|
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
|
||||||
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
|
`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"`.
|
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import wang.yaojia.webterm.wire.HttpTransport
|
|||||||
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
|
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
|
||||||
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
|
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
|
||||||
* notBefore, notAfter, renewAfter }`
|
* notBefore, notAfter, renewAfter }`
|
||||||
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The
|
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 `{ cert, caChain,
|
||||||
* server schema is `.strict()`; NO keyAlg/subdomain/deviceName.
|
* 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
|
* 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
|
* sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
|
||||||
@@ -29,8 +32,12 @@ public class DeviceEnrollmentClient(
|
|||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
private val http: HttpTransport,
|
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
|
* 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 path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
|
||||||
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
|
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 ────────────────────────────────────────────────────────────
|
// ── 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
|
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
|
||||||
// string must never emit a bare "Bearer " header.
|
// string must never emit a bare "Bearer " header.
|
||||||
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
|
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`
|
/** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
|
||||||
@@ -127,6 +165,24 @@ public class DeviceEnrollmentClient(
|
|||||||
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
|
.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 {
|
private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
|
||||||
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
|
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
|
||||||
val chain = dto.caChain.map { entry ->
|
val chain = dto.caChain.map { entry ->
|
||||||
|
|||||||
@@ -97,6 +97,19 @@ internal data class EnrollRequestBody(
|
|||||||
@Serializable
|
@Serializable
|
||||||
internal data class RenewRequestBody(val csr: String)
|
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
|
@Serializable
|
||||||
internal data class LoginResponseDto(
|
internal data class LoginResponseDto(
|
||||||
val enrollToken: String,
|
val enrollToken: String,
|
||||||
@@ -114,6 +127,25 @@ internal data class EnrollResponseDto(
|
|||||||
val renewAfter: String? = null,
|
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
|
@Serializable
|
||||||
internal data class ErrorDto(val error: String? = null)
|
internal data class ErrorDto(val error: String? = null)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,15 @@ public data class CommitLogEntry(
|
|||||||
val hash: String,
|
val hash: String,
|
||||||
val at: Long,
|
val at: Long,
|
||||||
val subject: String = "",
|
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)
|
@Serializable(with = CommitLogEntryListSerializer::class)
|
||||||
val commits: List<CommitLogEntry> = emptyList(),
|
val commits: List<CommitLogEntry> = emptyList(),
|
||||||
val truncated: Boolean = false,
|
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). */
|
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
|
||||||
internal object CommitLogEntryListSerializer :
|
internal object CommitLogEntryListSerializer :
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ public data class CommitResult(val commit: String = "")
|
|||||||
@Serializable
|
@Serializable
|
||||||
public data class PushResult(val branch: String? = null, val remote: String? = null)
|
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 }`. */
|
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
|
||||||
@Serializable
|
@Serializable
|
||||||
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
|
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
|
||||||
|
|||||||
@@ -42,4 +42,9 @@ public data class LiveSessionInfo(
|
|||||||
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
|
/** 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". */
|
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
|
||||||
val lastOutputAt: Long? = null,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ public data class ProjectSessionRef(
|
|||||||
val clientCount: Int,
|
val clientCount: Int,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val exited: Boolean,
|
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,
|
val behind: Int? = null,
|
||||||
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
|
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
|
||||||
val lastCommitMs: Long? = null,
|
val lastCommitMs: Long? = null,
|
||||||
|
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
|
||||||
|
val dirtyCount: Int? = null,
|
||||||
@Serializable(with = ProjectSessionRefListSerializer::class)
|
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||||
val sessions: List<ProjectSessionRef> = emptyList(),
|
val sessions: List<ProjectSessionRef> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -65,6 +73,10 @@ public data class ProjectDetail(
|
|||||||
val isGit: Boolean,
|
val isGit: Boolean,
|
||||||
val branch: String? = null,
|
val branch: String? = null,
|
||||||
val dirty: Boolean? = 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)
|
@Serializable(with = WorktreeInfoListSerializer::class)
|
||||||
val worktrees: List<WorktreeInfo> = emptyList(),
|
val worktrees: List<WorktreeInfo> = emptyList(),
|
||||||
@Serializable(with = ProjectSessionRefListSerializer::class)
|
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,6 +2,8 @@ package wang.yaojia.webterm.api.models
|
|||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.builtins.nullable
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
@@ -9,7 +11,9 @@ import kotlinx.serialization.encoding.Decoder
|
|||||||
import kotlinx.serialization.encoding.Encoder
|
import kotlinx.serialization.encoding.Encoder
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonDecoder
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -36,6 +40,23 @@ internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
|
|||||||
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
|
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
|
* 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`,
|
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -11,12 +11,17 @@ import wang.yaojia.webterm.api.models.PrStatus
|
|||||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||||
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
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.PushResult
|
||||||
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
import wang.yaojia.webterm.api.models.SessionPreview
|
import wang.yaojia.webterm.api.models.SessionPreview
|
||||||
import wang.yaojia.webterm.api.models.StageResult
|
import wang.yaojia.webterm.api.models.StageResult
|
||||||
import wang.yaojia.webterm.api.models.UiConfig
|
import wang.yaojia.webterm.api.models.UiConfig
|
||||||
import wang.yaojia.webterm.api.models.UiPrefs
|
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.decodeGitError
|
||||||
import wang.yaojia.webterm.api.models.decodeGitPayload
|
import wang.yaojia.webterm.api.models.decodeGitPayload
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
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
|
/** `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). */
|
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
|
||||||
public suspend fun prefs(): UiPrefs {
|
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 ──────────────
|
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
|
||||||
|
|
||||||
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
|
/** `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> =
|
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
|
||||||
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
|
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
|
* 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]
|
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
|
||||||
|
|||||||
@@ -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. */
|
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
|
||||||
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
|
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. */
|
/** 500 from `GET /projects/log` — the server failed reading the git log. */
|
||||||
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
|
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ internal object HttpStatus {
|
|||||||
const val BAD_REQUEST = 400
|
const val BAD_REQUEST = 400
|
||||||
const val FORBIDDEN = 403
|
const val FORBIDDEN = 403
|
||||||
const val NOT_FOUND = 404
|
const val NOT_FOUND = 404
|
||||||
|
const val CONFLICT = 409
|
||||||
|
const val PAYLOAD_TOO_LARGE = 413
|
||||||
const val TOO_MANY_REQUESTS = 429
|
const val TOO_MANY_REQUESTS = 429
|
||||||
const val INTERNAL_SERVER_ERROR = 500
|
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. */
|
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
|
||||||
const val CLIENT_ERROR_MIN = 400
|
const val CLIENT_ERROR_MIN = 400
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import java.util.UUID
|
|||||||
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
|
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
|
||||||
* pair (this client uses FCM, not APNs/VAPID).
|
* pair (this client uses FCM, not APNs/VAPID).
|
||||||
*
|
*
|
||||||
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
|
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue`
|
||||||
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
|
* · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
|
||||||
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
|
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue`
|
||||||
* · `POST|DELETE /push/fcm-token`
|
* · `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
|
* 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` +
|
* 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)}",
|
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 =
|
fun getPrefs(): ApiRoute =
|
||||||
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
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). */
|
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
|
||||||
fun projectPr(path: String): ApiRoute =
|
fun projectPr(path: String): ApiRoute =
|
||||||
ApiRoute(
|
ApiRoute(
|
||||||
@@ -148,10 +168,50 @@ internal object Endpoints {
|
|||||||
fun gitCommit(path: String, message: String): ApiRoute =
|
fun gitCommit(path: String, message: String): ApiRoute =
|
||||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
|
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 }`. */
|
/** `POST /projects/git/push` — `{ path }`. */
|
||||||
fun gitPush(path: String): ApiRoute =
|
fun gitPush(path: String): ApiRoute =
|
||||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
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]). */
|
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
|
||||||
private fun <T> jsonBodyRoute(
|
private fun <T> jsonBodyRoute(
|
||||||
method: HttpMethod,
|
method: HttpMethod,
|
||||||
@@ -216,4 +276,10 @@ internal object Endpoints {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class PushBody(val path: String)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,6 +253,137 @@ class DeviceEnrollmentClientTest {
|
|||||||
assertTrue(transport.recordedRequests.isEmpty())
|
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 ──────────────────────────────────────────────────────────────────────
|
// ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`() {
|
||||||
|
// [A + TAB + CR + CJK, exactly as the server stored the keystroke bytes.
|
||||||
|
val s = snapshot("""{"length":1,"items":["[A\t你好\r"]}""")!!
|
||||||
|
assertEquals("[A\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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
|
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
@@ -17,6 +19,83 @@ plugins {
|
|||||||
alias(libs.plugins.hilt)
|
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 {
|
android {
|
||||||
namespace = "wang.yaojia.webterm"
|
namespace = "wang.yaojia.webterm"
|
||||||
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
|
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
|
||||||
@@ -28,24 +107,87 @@ android {
|
|||||||
applicationId = "wang.yaojia.webterm"
|
applicationId = "wang.yaojia.webterm"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 35
|
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
|
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 {
|
buildFeatures {
|
||||||
compose = true
|
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 {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
// No applicationId suffix — keep it stable for deep-link testing (A32).
|
// No applicationId suffix — keep it stable for deep-link testing (A32).
|
||||||
}
|
}
|
||||||
release {
|
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(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"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(libs.bundles.unit.test)
|
||||||
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
|
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
|
||||||
testRuntimeOnly(libs.junit.platform.launcher)
|
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.
|
// 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 {
|
tasks.withType<Test>().configureEach {
|
||||||
useJUnitPlatform()
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
121
android/app/proguard-rules.pro
vendored
121
android/app/proguard-rules.pro
vendored
@@ -1,4 +1,117 @@
|
|||||||
# WebTerm :app — R8/ProGuard rules.
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Release is not minified yet (isMinifyEnabled = false); this file exists so the
|
# WebTerm :app — R8 keep rules (release + benchmark; both isMinifyEnabled = true).
|
||||||
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for
|
#
|
||||||
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later.
|
# 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 56–79 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 15–19 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 26–53) 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 7–20) 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 82–92 → `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 149–160),
|
||||||
|
* 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 18–25), 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,13 +12,32 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
<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
|
<application
|
||||||
android:name=".WebTermApp"
|
android:name=".WebTermApp"
|
||||||
|
android:allowBackup="false"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.WebTerm"
|
android:theme="@style/Theme.WebTerm"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:usesCleartextTraffic="false">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ import wang.yaojia.webterm.designsystem.DisplayStatus
|
|||||||
import wang.yaojia.webterm.designsystem.Spacing
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
import wang.yaojia.webterm.designsystem.StatusBadge
|
import wang.yaojia.webterm.designsystem.StatusBadge
|
||||||
import wang.yaojia.webterm.designsystem.WebTermCard
|
import wang.yaojia.webterm.designsystem.WebTermCard
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermType
|
||||||
import wang.yaojia.webterm.session.GateState
|
import wang.yaojia.webterm.session.GateState
|
||||||
import wang.yaojia.webterm.viewmodels.GateDecision
|
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.GateKind
|
||||||
|
import wang.yaojia.webterm.wire.PreviewDiffFile
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
|
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
|
||||||
@@ -44,6 +49,7 @@ public fun GateBanner(
|
|||||||
gate: GateState,
|
gate: GateState,
|
||||||
onDecide: (GateDecision, Int) -> Unit,
|
onDecide: (GateDecision, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
preview: ApprovalPreview? = null,
|
||||||
) {
|
) {
|
||||||
WebTermCard(modifier = modifier.fillMaxWidth()) {
|
WebTermCard(modifier = modifier.fillMaxWidth()) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
|
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
|
||||||
Text(text = "拒绝")
|
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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Preview(name = "GateBanner")
|
@Preview(name = "GateBanner")
|
||||||
@@ -84,6 +211,7 @@ private fun GateBannerPreview() {
|
|||||||
GateBanner(
|
GateBanner(
|
||||||
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
|
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
|
||||||
onDecide = { _, _ -> },
|
onDecide = { _, _ -> },
|
||||||
|
preview = ApprovalPreview.Command("rm -rf build/\nnpm run build", truncated = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import wang.yaojia.webterm.designsystem.Spacing
|
|||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
import wang.yaojia.webterm.session.GateState
|
import wang.yaojia.webterm.session.GateState
|
||||||
import wang.yaojia.webterm.viewmodels.GateDecision
|
import wang.yaojia.webterm.viewmodels.GateDecision
|
||||||
|
import wang.yaojia.webterm.wire.ApprovalPreview
|
||||||
import wang.yaojia.webterm.wire.GateKind
|
import wang.yaojia.webterm.wire.GateKind
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +50,7 @@ public fun PlanGateSheet(
|
|||||||
onDecide: (GateDecision, Int) -> Unit,
|
onDecide: (GateDecision, Int) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
preview: ApprovalPreview? = null,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||||
@@ -69,6 +71,9 @@ public fun PlanGateSheet(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
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(
|
Button(
|
||||||
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
|
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import wang.yaojia.webterm.nav.LayoutMode
|
import wang.yaojia.webterm.nav.LayoutMode
|
||||||
import wang.yaojia.webterm.nav.PointerMenuPolicy
|
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.
|
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
|
||||||
@@ -59,7 +61,12 @@ public fun PointerContextMenu(
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
Box(
|
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 {
|
awaitPointerEventScope {
|
||||||
while (true) {
|
while (true) {
|
||||||
val event = awaitPointerEvent()
|
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. */
|
/** 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)
|
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) })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,35 @@ package wang.yaojia.webterm.components
|
|||||||
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.SuggestionChip
|
import androidx.compose.material3.SuggestionChip
|
||||||
import androidx.compose.material3.SuggestionChipDefaults
|
import androidx.compose.material3.SuggestionChipDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
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.Radius
|
||||||
import wang.yaojia.webterm.designsystem.Spacing
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
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 =
|
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
|
||||||
gateHeld && hasChips
|
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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Preview(name = "QuickReply (gate held)")
|
@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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ private fun TitleLine(row: SessionRow) {
|
|||||||
) {
|
) {
|
||||||
StatusBadge(status = row.displayStatus)
|
StatusBadge(status = row.displayStatus)
|
||||||
if (row.isUnread) UnreadDot()
|
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(
|
||||||
text = row.title.ifBlank { fallbackLabel(row.info) },
|
text = row.title.ifBlank { fallbackLabel(row.info) },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -202,7 +206,12 @@ private fun SessionListRowPreview() {
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
|
||||||
SessionListRow(
|
SessionListRow(
|
||||||
row = SessionRow(
|
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,
|
displayStatus = DisplayStatus.Working,
|
||||||
title = "web-terminal",
|
title = "web-terminal",
|
||||||
isUnread = true,
|
isUnread = true,
|
||||||
@@ -228,6 +237,7 @@ private fun previewInfo(
|
|||||||
cols: Int = 161,
|
cols: Int = 161,
|
||||||
rows: Int = 50,
|
rows: Int = 50,
|
||||||
lastOutputAt: Long? = null,
|
lastOutputAt: Long? = null,
|
||||||
|
queueLength: Int? = null,
|
||||||
): LiveSessionInfo = LiveSessionInfo(
|
): LiveSessionInfo = LiveSessionInfo(
|
||||||
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
|
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
|
||||||
createdAt = 1L,
|
createdAt = 1L,
|
||||||
@@ -240,4 +250,5 @@ private fun previewInfo(
|
|||||||
rows = rows,
|
rows = rows,
|
||||||
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
|
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
|
||||||
lastOutputAt = lastOutputAt,
|
lastOutputAt = lastOutputAt,
|
||||||
|
queueLength = queueLength,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import okhttp3.ConnectionPool
|
import okhttp3.ConnectionPool
|
||||||
|
import okhttp3.CookieJar
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import wang.yaojia.webterm.transport.ClientIdentity
|
import wang.yaojia.webterm.transport.ClientIdentity
|
||||||
import wang.yaojia.webterm.transport.ClientIdentityProvider
|
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
|
* 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.
|
* 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)
|
* ### Breaking the construction cycle (A11's frozen constructor)
|
||||||
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
|
* `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
|
* 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)
|
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
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
public fun provideOkHttpClient(
|
public fun provideOkHttpClient(
|
||||||
identityProvider: ClientIdentityProvider,
|
identityProvider: ClientIdentityProvider,
|
||||||
connectionPool: ConnectionPool,
|
connectionPool: ConnectionPool,
|
||||||
|
cookieJar: CookieJar,
|
||||||
): OkHttpClient =
|
): OkHttpClient =
|
||||||
OkHttpClientFactory.create(identityProvider)
|
OkHttpClientFactory.create(identityProvider, cookieJar)
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.connectionPool(connectionPool)
|
.connectionPool(connectionPool)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package wang.yaojia.webterm.di
|
package wang.yaojia.webterm.di
|
||||||
|
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
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.PushRegistrarTokenSink
|
||||||
import wang.yaojia.webterm.push.PushTokenSink
|
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
|
* 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(...)`
|
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
|
||||||
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
|
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
|
||||||
* (self-heal). This is the intended optional-collaborator handoff (no mutable global).
|
* (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
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
public interface PushModule {
|
public interface PushModule {
|
||||||
@Binds
|
@Binds
|
||||||
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,22 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import wang.yaojia.webterm.hostregistry.DataStoreHostStore
|
import wang.yaojia.webterm.hostregistry.DataStoreHostStore
|
||||||
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
|
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
|
||||||
|
import wang.yaojia.webterm.hostregistry.DataStoreSessionWatermarkStore
|
||||||
import wang.yaojia.webterm.hostregistry.HostStore
|
import wang.yaojia.webterm.hostregistry.HostStore
|
||||||
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||||
|
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore.
|
* 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
|
* The host list ([HostStore], key `"hosts"`), the per-host last-session id ([LastSessionStore], key
|
||||||
* `"lastSessionId.<hostId>"`) use disjoint keys, so they safely share one DataStore file. Secret
|
* `"lastSessionId.<hostId>"`) and the unread watermarks ([SessionWatermarkStore], key
|
||||||
* material (the device cert) never lives here — that is the Tink store in [TlsModule].
|
* `"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
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
@@ -43,5 +50,16 @@ public object StorageModule {
|
|||||||
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
|
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
|
||||||
DataStoreLastSessionStore(dataStore)
|
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"
|
private const val DATASTORE_NAME: String = "webterm"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
|
|||||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||||
import wang.yaojia.webterm.tlsandroid.TinkCertStore
|
import wang.yaojia.webterm.tlsandroid.TinkCertStore
|
||||||
import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore
|
import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore
|
||||||
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
|
import wang.yaojia.webterm.wiring.CertificateRotationScheduler
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,4 +80,35 @@ public object TlsModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher =
|
public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher =
|
||||||
repository
|
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() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
@@ -18,12 +20,16 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import wang.yaojia.webterm.components.ContinueLastBanner
|
import wang.yaojia.webterm.components.ContinueLastBanner
|
||||||
|
import wang.yaojia.webterm.components.SessionThumbnails
|
||||||
import wang.yaojia.webterm.components.continueLastModel
|
import wang.yaojia.webterm.components.continueLastModel
|
||||||
|
import wang.yaojia.webterm.hostregistry.Host
|
||||||
import wang.yaojia.webterm.screens.AdaptiveHome
|
import wang.yaojia.webterm.screens.AdaptiveHome
|
||||||
import wang.yaojia.webterm.screens.SessionListScreen
|
import wang.yaojia.webterm.screens.SessionListScreen
|
||||||
import wang.yaojia.webterm.viewmodels.ApiClientSessionGateway
|
import wang.yaojia.webterm.viewmodels.ApiClientSessionGateway
|
||||||
import wang.yaojia.webterm.viewmodels.SessionListViewModel
|
import wang.yaojia.webterm.viewmodels.SessionListViewModel
|
||||||
import wang.yaojia.webterm.wiring.AppEnvironment
|
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||||
|
import wang.yaojia.webterm.wiring.ThumbnailPipeline
|
||||||
|
import wang.yaojia.webterm.wiring.sessionThumbnailPipeline
|
||||||
import java.util.UUID
|
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
|
* 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.
|
* 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
|
@Composable
|
||||||
public fun SessionsHome(
|
public fun SessionsHome(
|
||||||
@@ -70,6 +91,40 @@ public fun SessionsHome(
|
|||||||
val openNewSession: () -> Unit = {
|
val openNewSession: () -> Unit = {
|
||||||
activeHostId?.let { navController.navigate(NavRoutes.terminalNew(it)) }
|
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(
|
AdaptiveHome(
|
||||||
listPane = {
|
listPane = {
|
||||||
@@ -89,6 +144,9 @@ public fun SessionsHome(
|
|||||||
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
|
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
|
||||||
onEnroll = { navController.navigate(NavRoutes.ENROLL) },
|
onEnroll = { navController.navigate(NavRoutes.ENROLL) },
|
||||||
onImportCert = { navController.navigate(NavRoutes.CERT) },
|
onImportCert = { navController.navigate(NavRoutes.CERT) },
|
||||||
|
thumbnails = thumbnails,
|
||||||
|
layoutMode = mode,
|
||||||
|
onNewSessionInCwd = openNewSessionInCwd,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.Manifest
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresPermission
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
@@ -56,11 +55,23 @@ public class FcmService : FirebaseMessagingService() {
|
|||||||
if (pushTokenSink.isPresent) pushTokenSink.get().onTokenRefreshed(token)
|
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) {
|
private fun postNotification(payload: PushPayload, plan: NotificationBuilder.NotificationPlan) {
|
||||||
val notification = NotificationBuilder.build(this, payload, plan)
|
val notification = NotificationBuilder.build(this, payload, plan)
|
||||||
val id = NotificationBuilder.notificationIdFor(payload.sessionId.toString())
|
val id = NotificationBuilder.notificationIdFor(payload.sessionId.toString())
|
||||||
|
try {
|
||||||
NotificationManagerCompat.from(this).notify(id, notification)
|
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. */
|
/** POST_NOTIFICATIONS is a runtime permission on API 33+; below that it is implicitly granted. */
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import wang.yaojia.webterm.viewmodels.CertPhase
|
|||||||
import wang.yaojia.webterm.viewmodels.CertSummaryView
|
import wang.yaojia.webterm.viewmodels.CertSummaryView
|
||||||
import wang.yaojia.webterm.viewmodels.ClientCertUiState
|
import wang.yaojia.webterm.viewmodels.ClientCertUiState
|
||||||
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
||||||
|
import wang.yaojia.webterm.wiring.RotationOutcome
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # ClientCertScreen (A27) — device-certificate (mTLS) management.
|
* # ClientCertScreen (A27) — device-certificate (mTLS) management.
|
||||||
@@ -78,6 +79,7 @@ public fun ClientCertScreen(
|
|||||||
viewModel: ClientCertViewModel,
|
viewModel: ClientCertViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onBack: (() -> Unit)? = null,
|
onBack: (() -> Unit)? = null,
|
||||||
|
rotationOutcome: RotationOutcome? = rememberRotationOutcome(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
// Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load.
|
// Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load.
|
||||||
@@ -117,9 +119,21 @@ public fun ClientCertScreen(
|
|||||||
onDismissError = viewModel::clearError,
|
onDismissError = viewModel::clearError,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBack = onBack,
|
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. */
|
/** Stateless body — pure inputs so a [Preview] and any host can drive it without the SAF/VM machinery. */
|
||||||
@Composable
|
@Composable
|
||||||
public fun ClientCertScreen(
|
public fun ClientCertScreen(
|
||||||
@@ -132,6 +146,7 @@ public fun ClientCertScreen(
|
|||||||
onDismissError: () -> Unit,
|
onDismissError: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onBack: (() -> Unit)? = null,
|
onBack: (() -> Unit)? = null,
|
||||||
|
rotationOutcome: RotationOutcome? = null,
|
||||||
) {
|
) {
|
||||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
Column(
|
Column(
|
||||||
@@ -159,6 +174,10 @@ public fun ClientCertScreen(
|
|||||||
|
|
||||||
state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) }
|
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(
|
PassphraseField(
|
||||||
passphrase = passphrase,
|
passphrase = passphrase,
|
||||||
onPassphraseChange = onPassphraseChange,
|
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
|
@Composable
|
||||||
private fun SummaryRow(label: String, value: String) {
|
private fun SummaryRow(label: String, value: String) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -38,6 +39,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
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.designsystem.Spacing
|
||||||
import wang.yaojia.webterm.hostregistry.Host
|
import wang.yaojia.webterm.hostregistry.Host
|
||||||
import wang.yaojia.webterm.viewmodels.EntryError
|
import wang.yaojia.webterm.viewmodels.EntryError
|
||||||
|
import wang.yaojia.webterm.viewmodels.PairingCopy
|
||||||
import wang.yaojia.webterm.viewmodels.PairingUiState
|
import wang.yaojia.webterm.viewmodels.PairingUiState
|
||||||
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
||||||
|
import wang.yaojia.webterm.viewmodels.TokenError
|
||||||
import wang.yaojia.webterm.viewmodels.WarningTier
|
import wang.yaojia.webterm.viewmodels.WarningTier
|
||||||
import wang.yaojia.webterm.viewmodels.needsExplicitAck
|
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
|
* - **POST_NOTIFICATIONS** is NOT requested here — push registration owns that prompt (A31); pairing
|
||||||
* only establishes the host.
|
* only establishes the host.
|
||||||
*
|
*
|
||||||
* All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is
|
* A host behind the optional `WEBTERM_TOKEN` gate cannot answer the probe at all until it is
|
||||||
* JVM-tested in `PairingViewModelTest`.
|
* 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 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.
|
* @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() },
|
onRetry = { viewModel.retry() },
|
||||||
onCancel = viewModel::reset,
|
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(
|
is PairingUiState.Failed -> FailedStep(
|
||||||
message = s.message,
|
message = s.message,
|
||||||
needsAck = s.tier.needsExplicitAck,
|
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
|
@Composable
|
||||||
private fun CameraPreview(onScanned: (String) -> Unit) {
|
private fun CameraPreview(onScanned: (String) -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -292,14 +311,82 @@ private fun tierCopy(tier: WarningTier): Pair<String, String> = when (tier) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProbingStep(host: String) {
|
private fun ProbingStep(host: String) {
|
||||||
|
BusyStep(message = "正在验证 $host …")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BusyStep(message: String) {
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
CircularProgressIndicator()
|
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
|
@Composable
|
||||||
private fun CertRequiredStep(
|
private fun CertRequiredStep(
|
||||||
host: String,
|
host: String,
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import wang.yaojia.webterm.api.models.CommitLogEntry
|
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.PrAvailability
|
||||||
import wang.yaojia.webterm.api.models.PrStatus
|
import wang.yaojia.webterm.api.models.PrStatus
|
||||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.models.ProjectSessionRef
|
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.api.models.WorktreeInfo
|
||||||
import wang.yaojia.webterm.designsystem.Spacing
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
import wang.yaojia.webterm.designsystem.Stroke
|
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.WebTermColors
|
||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
import wang.yaojia.webterm.designsystem.WebTermType
|
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.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.WorktreeViewModel
|
||||||
|
import wang.yaojia.webterm.viewmodels.headerDirtyChip
|
||||||
|
import wang.yaojia.webterm.viewmodels.syncBandOf
|
||||||
|
import wang.yaojia.webterm.viewmodels.worktreeChips
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,8 +86,15 @@ public fun ProjectDetailScreen(
|
|||||||
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
||||||
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
|
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
|
||||||
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
|
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
|
||||||
|
val fetch by viewModel.fetchPhase.collectAsStateWithLifecycle()
|
||||||
|
val worktreeStates by viewModel.worktreeStates.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
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) {
|
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -91,7 +110,12 @@ public fun ProjectDetailScreen(
|
|||||||
detail = current.detail,
|
detail = current.detail,
|
||||||
prChip = prChip,
|
prChip = prChip,
|
||||||
recent = recent,
|
recent = recent,
|
||||||
|
fetch = fetch,
|
||||||
|
canFetch = viewModel.canFetch,
|
||||||
|
onFetch = { scope.launch { viewModel.fetchUpstream() } },
|
||||||
|
onDismissFetchBanner = viewModel::clearFetchBanner,
|
||||||
worktree = viewModel.worktree,
|
worktree = viewModel.worktree,
|
||||||
|
worktreeStates = worktreeStates,
|
||||||
onOpenClaude = onOpenClaude,
|
onOpenClaude = onOpenClaude,
|
||||||
onViewDiff = onViewDiff,
|
onViewDiff = onViewDiff,
|
||||||
)
|
)
|
||||||
@@ -120,6 +144,11 @@ private fun DetailBody(
|
|||||||
worktree: WorktreeViewModel?,
|
worktree: WorktreeViewModel?,
|
||||||
onOpenClaude: (String) -> Unit,
|
onOpenClaude: (String) -> Unit,
|
||||||
onViewDiff: (String) -> Unit = {},
|
onViewDiff: (String) -> Unit = {},
|
||||||
|
fetch: ProjectDetailViewModel.FetchPhase = ProjectDetailViewModel.FetchPhase.Idle,
|
||||||
|
canFetch: Boolean = false,
|
||||||
|
onFetch: () -> Unit = {},
|
||||||
|
onDismissFetchBanner: () -> Unit = {},
|
||||||
|
worktreeStates: Map<String, WorktreeRowState> = emptyMap(),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -139,19 +168,34 @@ private fun DetailBody(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
detail.branch?.let { Text(text = it, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
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)
|
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)
|
PrChipRow(prChip)
|
||||||
|
|
||||||
if (detail.isGit && worktree != null) {
|
if (detail.isGit && worktree != null) {
|
||||||
WorktreeSection(detail = detail, worktree = worktree)
|
WorktreeSection(detail = detail, worktree = worktree, worktreeStates = worktreeStates)
|
||||||
} else {
|
} else {
|
||||||
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size))
|
||||||
if (!detail.isGit) EmptyLine("不是 git 仓库。")
|
if (!detail.isGit) EmptyLine("不是 git 仓库。")
|
||||||
else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
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 }
|
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) ──────────────────────────────────────────────────────────
|
// ── PR + CI chip (link only when https) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -230,18 +402,30 @@ private fun isHttpsUrl(url: String): Boolean =
|
|||||||
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
|
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
|
private fun WorktreeSection(
|
||||||
|
detail: ProjectDetail,
|
||||||
|
worktree: WorktreeViewModel,
|
||||||
|
worktreeStates: Map<String, WorktreeRowState>,
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val phase by worktree.phase.collectAsStateWithLifecycle()
|
val phase by worktree.phase.collectAsStateWithLifecycle()
|
||||||
var branch by remember { mutableStateOf("") }
|
var branch by remember { mutableStateOf("") }
|
||||||
var base by remember { mutableStateOf("") }
|
var base by remember { mutableStateOf("") }
|
||||||
var removeTarget by remember { mutableStateOf<WorktreeInfo?>(null) }
|
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()) {
|
if (detail.worktrees.isEmpty()) {
|
||||||
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
||||||
} else {
|
} 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.
|
// New-worktree inline form.
|
||||||
@@ -337,31 +521,77 @@ private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
|
|||||||
when (recent) {
|
when (recent) {
|
||||||
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
|
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
|
||||||
ProjectDetailViewModel.RecentCommits.Loading -> {
|
ProjectDetailViewModel.RecentCommits.Loading -> {
|
||||||
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
|
SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("正在读取提交记录…")
|
||||||
}
|
}
|
||||||
ProjectDetailViewModel.RecentCommits.Unavailable -> {
|
ProjectDetailViewModel.RecentCommits.Unavailable -> {
|
||||||
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
|
SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("提交记录不可用。")
|
||||||
}
|
}
|
||||||
is ProjectDetailViewModel.RecentCommits.Loaded -> {
|
is ProjectDetailViewModel.RecentCommits.Loaded -> CommitList(recent.result)
|
||||||
SectionTitle("最近提交")
|
|
||||||
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
|
|
||||||
else for (commit in recent.result.commits) CommitRow(commit)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
@Composable
|
||||||
private fun CommitRow(commit: CommitLogEntry) {
|
private fun CommitRow(commit: CommitLogEntry) {
|
||||||
|
val unpushed = commit.isUnpushed()
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(
|
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,
|
style = WebTermType.metaMono,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = commit.subject,
|
text = commit.subject,
|
||||||
style = WebTermType.metaMono,
|
style = WebTermType.metaMono,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = if (unpushed) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@@ -369,23 +599,57 @@ 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) ────────────────────────────────────────────────────────────────
|
// ── 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
|
@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"
|
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
|
||||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), verticalAlignment = Alignment.CenterVertically) {
|
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))
|
Text(text = label, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||||
if (worktree.isMain) Tag("main")
|
for (chip in worktreeChips(worktree, state)) Tag(chip.label, chip.tone)
|
||||||
if (worktree.isCurrent) Tag("current")
|
|
||||||
if (worktree.locked == true) Tag("locked")
|
|
||||||
if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") }
|
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)
|
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
|
@Composable
|
||||||
@@ -413,8 +677,15 @@ private fun ClaudeMdBlock(text: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Tag(text: String) {
|
private fun Tag(text: String, tone: GitChipTone = GitChipTone.ACCENT) {
|
||||||
Text(text = text, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
|
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
|
@Composable
|
||||||
@@ -452,16 +723,27 @@ private fun Centered(content: @Composable () -> Unit) {
|
|||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() }
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(name = "ProjectDetailScreen — loaded")
|
@Preview(name = "ProjectDetailScreen — git panel (stale ↓, 9 to push)")
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProjectDetailScreenPreview() {
|
private fun ProjectDetailScreenPreview() {
|
||||||
val detail = ProjectDetail(
|
val detail = ProjectDetail(
|
||||||
name = "Acme.Web.frontend",
|
name = "Acme.Web.frontend",
|
||||||
path = "/home/dev/acme-web",
|
path = "/home/dev/acme-web",
|
||||||
isGit = true,
|
isGit = true,
|
||||||
branch = "main",
|
branch = "develop",
|
||||||
dirty = true,
|
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(),
|
sessions = emptyList(),
|
||||||
hasClaudeMd = true,
|
hasClaudeMd = true,
|
||||||
claudeMd = "# CLAUDE.md\n\nProject instructions…",
|
claudeMd = "# CLAUDE.md\n\nProject instructions…",
|
||||||
@@ -471,13 +753,21 @@ private fun ProjectDetailScreenPreview() {
|
|||||||
detail = detail,
|
detail = detail,
|
||||||
prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)),
|
prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)),
|
||||||
recent = ProjectDetailViewModel.RecentCommits.Loaded(
|
recent = ProjectDetailViewModel.RecentCommits.Loaded(
|
||||||
wang.yaojia.webterm.api.models.GitLogResult(
|
GitLogResult(
|
||||||
commits = listOf(CommitLogEntry("abc1234", 1, "Initial commit")),
|
commits = listOf(
|
||||||
|
CommitLogEntry("553a00c1", 1, "docs(progress): log the leftover fixes", unpushed = true),
|
||||||
|
CommitLogEntry("1dbed541", 1, "feat(control-panel): web admin UI"),
|
||||||
|
),
|
||||||
truncated = false,
|
truncated = false,
|
||||||
|
upstream = "origin/develop",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
worktree = null,
|
worktree = null,
|
||||||
onOpenClaude = {},
|
onOpenClaude = {},
|
||||||
|
canFetch = true,
|
||||||
|
worktreeStates = mapOf(
|
||||||
|
"/home/dev/acme-web/.claude/worktrees/x" to WorktreeRowState(sync = SyncState(), sessionCount = 1),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
@@ -37,20 +39,31 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
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 kotlinx.coroutines.launch
|
||||||
|
import wang.yaojia.webterm.components.PointerContextMenu
|
||||||
import wang.yaojia.webterm.components.SessionListRow
|
import wang.yaojia.webterm.components.SessionListRow
|
||||||
import wang.yaojia.webterm.components.SessionThumbnails
|
import wang.yaojia.webterm.components.SessionThumbnails
|
||||||
|
import wang.yaojia.webterm.components.sessionRowMenuActions
|
||||||
import wang.yaojia.webterm.designsystem.Radius
|
import wang.yaojia.webterm.designsystem.Radius
|
||||||
import wang.yaojia.webterm.designsystem.Spacing
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.nav.LayoutMode
|
||||||
import wang.yaojia.webterm.viewmodels.HostOption
|
import wang.yaojia.webterm.viewmodels.HostOption
|
||||||
|
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
|
||||||
import wang.yaojia.webterm.viewmodels.SessionListUiState
|
import wang.yaojia.webterm.viewmodels.SessionListUiState
|
||||||
import wang.yaojia.webterm.viewmodels.SessionListViewModel
|
import wang.yaojia.webterm.viewmodels.SessionListViewModel
|
||||||
import wang.yaojia.webterm.viewmodels.SessionRow
|
import wang.yaojia.webterm.viewmodels.SessionRow
|
||||||
|
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
|
||||||
|
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +94,20 @@ import java.util.UUID
|
|||||||
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
|
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
|
||||||
* @param thumbnails off-screen preview seam; production wires it to the active host's
|
* @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.
|
* [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
|
@Composable
|
||||||
public fun SessionListScreen(
|
public fun SessionListScreen(
|
||||||
@@ -92,14 +119,28 @@ public fun SessionListScreen(
|
|||||||
onImportCert: () -> Unit,
|
onImportCert: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
thumbnails: SessionThumbnails? = null,
|
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 state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
var confirmingRemove by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// STARTED-scoped poll: fetch→wait→repeat while foregrounded, cancelled on background (plan §6.6).
|
// 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) {
|
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()
|
viewModel.poll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +155,7 @@ public fun SessionListScreen(
|
|||||||
onPairHost = onPairHost,
|
onPairHost = onPairHost,
|
||||||
onEnroll = onEnroll,
|
onEnroll = onEnroll,
|
||||||
onImportCert = onImportCert,
|
onImportCert = onImportCert,
|
||||||
|
onRemoveHost = if (hostRemoval != null) ({ confirmingRemove = true }) else null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
@@ -129,8 +171,45 @@ public fun SessionListScreen(
|
|||||||
onNewSession = onNewSession,
|
onNewSession = onNewSession,
|
||||||
onPairHost = onPairHost,
|
onPairHost = onPairHost,
|
||||||
thumbnails = thumbnails,
|
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 ──────────────────────────────────────────────────────────────────────────────
|
// ── Top bar + host menu ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -143,6 +222,7 @@ private fun SessionListTopBar(
|
|||||||
onPairHost: () -> Unit,
|
onPairHost: () -> Unit,
|
||||||
onEnroll: () -> Unit,
|
onEnroll: () -> Unit,
|
||||||
onImportCert: () -> Unit,
|
onImportCert: () -> Unit,
|
||||||
|
onRemoveHost: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
var menuOpen by remember { mutableStateOf(false) }
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话"
|
val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话"
|
||||||
@@ -162,13 +242,20 @@ private fun SessionListTopBar(
|
|||||||
onPairHost = { menuOpen = false; onPairHost() },
|
onPairHost = { menuOpen = false; onPairHost() },
|
||||||
onEnroll = { menuOpen = false; onEnroll() },
|
onEnroll = { menuOpen = false; onEnroll() },
|
||||||
onImportCert = { menuOpen = false; onImportCert() },
|
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
|
@Composable
|
||||||
private fun HostMenu(
|
private fun HostMenu(
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
@@ -178,6 +265,7 @@ private fun HostMenu(
|
|||||||
onPairHost: () -> Unit,
|
onPairHost: () -> Unit,
|
||||||
onEnroll: () -> Unit,
|
onEnroll: () -> Unit,
|
||||||
onImportCert: () -> Unit,
|
onImportCert: () -> Unit,
|
||||||
|
onRemoveHost: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||||
for (host in hosts) {
|
for (host in hosts) {
|
||||||
@@ -193,6 +281,13 @@ private fun HostMenu(
|
|||||||
// 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS.
|
// 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS.
|
||||||
DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll)
|
DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll)
|
||||||
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
|
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
|
||||||
|
if (onRemoveHost != null && hosts.any { it.isActive }) {
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(text = "移除此主机", color = MaterialTheme.colorScheme.error) },
|
||||||
|
onClick = onRemoveHost,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,24 +303,106 @@ private fun SessionListBody(
|
|||||||
onNewSession: () -> Unit,
|
onNewSession: () -> Unit,
|
||||||
onPairHost: () -> Unit,
|
onPairHost: () -> Unit,
|
||||||
thumbnails: SessionThumbnails?,
|
thumbnails: SessionThumbnails?,
|
||||||
|
layoutMode: LayoutMode,
|
||||||
|
onNewSessionInCwd: ((String) -> Unit)?,
|
||||||
|
onDismissRemoveError: () -> Unit,
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.pulltorefresh.PullToRefreshBox(
|
androidx.compose.material3.pulltorefresh.PullToRefreshBox(
|
||||||
isRefreshing = state.isRefreshing,
|
isRefreshing = state.isRefreshing,
|
||||||
onRefresh = onRefresh,
|
onRefresh = onRefresh,
|
||||||
modifier = Modifier.fillMaxSize().padding(contentPadding),
|
modifier = Modifier.fillMaxSize().padding(contentPadding),
|
||||||
) {
|
) {
|
||||||
|
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 {
|
when {
|
||||||
state.activeHostId == null ->
|
state.activeHostId == null ->
|
||||||
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
|
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
|
||||||
state.rows.isEmpty() ->
|
state.rows.isEmpty() ->
|
||||||
EmptyState(
|
EmptyState(
|
||||||
message = if (state.hasLoadError) "无法加载会话列表。下拉重试。" else "该主机没有运行中的会话。",
|
message = if (state.hasLoadError) {
|
||||||
|
"无法加载会话列表。下拉重试。"
|
||||||
|
} else {
|
||||||
|
"该主机没有运行中的会话。"
|
||||||
|
},
|
||||||
actionLabel = "开新会话",
|
actionLabel = "开新会话",
|
||||||
onAction = onNewSession,
|
onAction = onNewSession,
|
||||||
)
|
)
|
||||||
else -> SessionList(state = state, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails)
|
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
|
@Composable
|
||||||
@@ -234,6 +411,8 @@ private fun SessionList(
|
|||||||
onOpen: (UUID) -> Unit,
|
onOpen: (UUID) -> Unit,
|
||||||
onKill: (UUID) -> Unit,
|
onKill: (UUID) -> Unit,
|
||||||
thumbnails: SessionThumbnails?,
|
thumbnails: SessionThumbnails?,
|
||||||
|
layoutMode: LayoutMode,
|
||||||
|
onNewSessionInCwd: ((String) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -244,7 +423,14 @@ private fun SessionList(
|
|||||||
item(key = "load-error") { LoadErrorBanner() }
|
item(key = "load-error") { LoadErrorBanner() }
|
||||||
}
|
}
|
||||||
items(items = state.rows, key = { it.id.toString() }) { row ->
|
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
|
* 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
|
* 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.
|
* 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
|
@Composable
|
||||||
private fun SwipeToKillRow(
|
private fun SwipeToKillRow(
|
||||||
@@ -260,6 +451,8 @@ private fun SwipeToKillRow(
|
|||||||
onOpen: (UUID) -> Unit,
|
onOpen: (UUID) -> Unit,
|
||||||
onKill: (UUID) -> Unit,
|
onKill: (UUID) -> Unit,
|
||||||
thumbnails: SessionThumbnails?,
|
thumbnails: SessionThumbnails?,
|
||||||
|
layoutMode: LayoutMode,
|
||||||
|
onNewSessionInCwd: ((String) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val dismissState = rememberSwipeToDismissBoxState(
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
confirmValueChange = { value ->
|
confirmValueChange = { value ->
|
||||||
@@ -275,9 +468,19 @@ private fun SwipeToKillRow(
|
|||||||
state = dismissState,
|
state = dismissState,
|
||||||
enableDismissFromStartToEnd = false,
|
enableDismissFromStartToEnd = false,
|
||||||
backgroundContent = { KillBackground() },
|
backgroundContent = { KillBackground() },
|
||||||
|
) {
|
||||||
|
PointerContextMenu(
|
||||||
|
mode = layoutMode,
|
||||||
|
actions = sessionRowMenuActions(
|
||||||
|
row = row,
|
||||||
|
onOpen = onOpen,
|
||||||
|
onNewSessionInCwd = onNewSessionInCwd,
|
||||||
|
onKill = onKill,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
|
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The red "删除" reveal behind a swiped row (end-aligned — the swipe is right→left). */
|
/** The red "删除" reveal behind a swiped row (end-aligned — the swipe is right→left). */
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package wang.yaojia.webterm.screens
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.view.View
|
|
||||||
import android.view.WindowManager
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -25,6 +26,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -39,23 +41,45 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
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.AwayDigestView
|
||||||
|
import wang.yaojia.webterm.components.BannerModel
|
||||||
|
import wang.yaojia.webterm.components.DataStoreQuickReplyStore
|
||||||
import wang.yaojia.webterm.components.GateBanner
|
import wang.yaojia.webterm.components.GateBanner
|
||||||
import wang.yaojia.webterm.components.HardwareKeyRouter
|
import wang.yaojia.webterm.components.HardwareKeyRouter
|
||||||
import wang.yaojia.webterm.components.KeyBar
|
import wang.yaojia.webterm.components.KeyBar
|
||||||
import wang.yaojia.webterm.components.PlanGateSheet
|
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.ReconnectBanner
|
||||||
import wang.yaojia.webterm.components.TelemetryChips
|
import wang.yaojia.webterm.components.TelemetryChips
|
||||||
import wang.yaojia.webterm.designsystem.LocalReduceMotion
|
import wang.yaojia.webterm.designsystem.LocalReduceMotion
|
||||||
import wang.yaojia.webterm.designsystem.Spacing
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||||
import wang.yaojia.webterm.terminalview.RemoteTerminalView
|
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.GateKind
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wiring.RetainedSessionHolder
|
import wang.yaojia.webterm.wiring.RetainedSessionHolder
|
||||||
import wang.yaojia.webterm.wiring.TerminalSessionControllerImpl
|
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.
|
* 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
|
* Hosts the forked Termux emulator ([RemoteTerminalView]) via an [AndroidView] and wires it to the
|
||||||
* holder-owned [TerminalSessionControllerImpl]. Structure top→bottom: a toolbar (sanitized title +
|
* holder-owned [TerminalSessionControllerImpl]. Structure top→bottom: a toolbar (sanitized title +
|
||||||
* "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) + telemetry chips
|
* 「快捷回复」editor + "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) +
|
||||||
* ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with the privacy
|
* telemetry chips ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with
|
||||||
* cover), the [KeyBar] pinned above the IME, and — for plan gates — the [PlanGateSheet]
|
* the privacy cover), the [QuickReply] chip row (A25 — floats only while a gate is held), the [KeyBar]
|
||||||
* `ModalBottomSheet` overlay. Every server-derived string is rendered INERT by its component (§8).
|
* 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 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.
|
* 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.
|
* cycle rebuilds a fresh emulator (ring replay), while a rotation re-binds the survivor.
|
||||||
* `holder.onStop(isChangingConfigurations)` drives that split.
|
* `holder.onStop(isChangingConfigurations)` drives that split.
|
||||||
* - `FLAG_SECURE` + a cover Composable on `ON_STOP` keep terminal bytes out of the recents snapshot (§8).
|
* - `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 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].
|
* @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
|
* 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
|
* [AwayDigestView.onExpand][wang.yaojia.webterm.components.AwayDigestView] callback outward; no internal
|
||||||
* logic changes.
|
* 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
|
@Composable
|
||||||
public fun TerminalScreen(
|
public fun TerminalScreen(
|
||||||
@@ -127,6 +168,9 @@ public fun TerminalScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onWarmUp: suspend () -> Unit = {},
|
onWarmUp: suspend () -> Unit = {},
|
||||||
onDigestExpand: () -> Unit = {},
|
onDigestExpand: () -> Unit = {},
|
||||||
|
quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(),
|
||||||
|
queueGateway: QueueGateway? = rememberAppQueueGateway(endpoint),
|
||||||
|
uiConfigGate: UiConfigGate? = rememberAppEnvironment()?.uiConfigGate,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = remember(context) { context.findActivity() }
|
val activity = remember(context) { context.findActivity() }
|
||||||
@@ -192,13 +236,15 @@ public fun TerminalScreen(
|
|||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner, activity, controller) {
|
DisposableEffect(lifecycleOwner, activity, controller) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
when (event) {
|
when {
|
||||||
Lifecycle.Event.ON_STOP -> {
|
event == Lifecycle.Event.ON_STOP -> {
|
||||||
covered = true
|
covered = true
|
||||||
holder.onStop(activity?.isChangingConfigurations == true)
|
holder.onStop(activity?.isChangingConfigurations == true)
|
||||||
}
|
}
|
||||||
Lifecycle.Event.ON_START -> covered = false
|
event == Lifecycle.Event.ON_START -> covered = false
|
||||||
Lifecycle.Event.ON_RESUME -> controller.notifyForegrounded(null, null)
|
// §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
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,10 +256,47 @@ public fun TerminalScreen(
|
|||||||
val windowInfo = LocalWindowInfo.current
|
val windowInfo = LocalWindowInfo.current
|
||||||
LaunchedEffect(windowInfo, controller) {
|
LaunchedEffect(windowInfo, controller) {
|
||||||
snapshotFlow { windowInfo.isWindowFocused }.collect { focused ->
|
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)) }
|
val requestNewSession: () -> Unit = { onNewSessionInCwd(NewSessionInCwd.requestFor(cwd)) }
|
||||||
|
|
||||||
// A slow wall-clock tick so the telemetry chips grey out as the last frame ages (§ staleness).
|
// 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()) {
|
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)
|
ReconnectBanner(model = banner, onNewSession = requestNewSession)
|
||||||
// Cockpit surfaces (A22): away-digest (once per reconnect) + statusLine telemetry chips. Both hide
|
// 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).
|
// 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.digest?.let { AwayDigestView(digest = it, onExpand = onDigestExpand) } // onExpand → A28 timeline (wired)
|
||||||
gateUi.telemetry?.let { TelemetryChips(telemetry = it, nowMs = nowMs) }
|
gateUi.telemetry?.let { TelemetryChips(telemetry = it, nowMs = nowMs) }
|
||||||
// Tool gate → an inline approve/reject card; plan gates use the ModalBottomSheet below.
|
// 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 ->
|
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()) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
key(generation) {
|
key(generation) {
|
||||||
@@ -246,38 +375,150 @@ public fun TerminalScreen(
|
|||||||
remoteView.onKeyCommand = { keyCode, keyEvent ->
|
remoteView.onKeyCommand = { keyCode, keyEvent ->
|
||||||
HardwareKeyRouter.handle(keyCode, keyEvent, controller::sendInput)
|
HardwareKeyRouter.handle(keyCode, keyEvent, controller::sendInput)
|
||||||
}
|
}
|
||||||
// A layout/size change feeds the latest-writer-wins reclaim path (§6.4). The pixel→
|
// The grid for this size has ALREADY been measured and pushed by the view layer
|
||||||
// grid metric read (Termux's package-private mFontWidth) + emulator resize is
|
// (TerminalResizeDriver → RemoteTerminalSession.updateSize → `Resize`) by the time
|
||||||
// completed in the view layer on-device; here we reclaim full-screen.
|
// this fires, so re-asserting it here would only double the frame. Forward it to
|
||||||
remoteView.onViewSizeChanged = { _, _ -> controller.notifyForegrounded(null, null) }
|
// 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)
|
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
|
// 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).
|
// 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()
|
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).
|
// The IME-bypass touch key-bar, pinned above the keyboard (hidden on wide screens / hardware kbd).
|
||||||
KeyBar(onSend = controller::sendInput)
|
KeyBar(onSend = controller::sendInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan gate → a three-way ModalBottomSheet overlay. Dismissing (scrim/back) resolves NOTHING — the
|
// The editable palette (A25). Every mutation goes through the store (the CRUD authority) and the
|
||||||
// gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in
|
// presenter republishes what came back, so the chips above can never drift from what was persisted.
|
||||||
// GateViewModel.decide.
|
if (showPaletteEditor) {
|
||||||
gateUi.gate?.takeIf { it.kind == GateKind.PLAN }?.let { planGate ->
|
QuickReplyPaletteEditor(
|
||||||
PlanGateSheet(gate = planGate, onDecide = gateViewModel::decide, onDismiss = {})
|
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. */
|
/** 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
|
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
|
@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()) {
|
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -292,6 +533,15 @@ private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) {
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
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("在当前目录开新会话") }
|
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
|
* WHICH app-side events must call `SessionEngine.notifyForegrounded` for the §6.4 latest-writer-wins
|
||||||
* [RemoteTerminalView].
|
* reclaim — the pure, JVM-tested half of the device-switch resize path.
|
||||||
*
|
*
|
||||||
* BOUNDARY WORKAROUND (recorded): A16's `RemoteTerminalView.terminalView` is typed
|
* A shared PTY has exactly ONE size, so the device you are actively using has to re-assert its dims to
|
||||||
* `com.termux.view.TerminalView`, but `:terminal-view` scopes the Termux `terminal-view` artifact as
|
* take that size back from whichever device wrote last. Two things make this subtler than "resend on every
|
||||||
* `implementation`, so that concrete type is NOT on `:app`'s compile classpath (and neither
|
* event":
|
||||||
* `app/build.gradle.kts` nor `:terminal-view`/`RemoteTerminalView` is in A21's editable lane). We
|
* - **The view layer already owns the grid.** `RemoteTerminalHostView.onSizeChanged` →
|
||||||
* therefore read the already-attached stock view through its public getter as a plain [View] — a
|
* `TerminalResizeDriver` → `RemoteTerminalSession.updateSize` measures and pushes the real cols×rows,
|
||||||
* `TerminalView` IS a `View`, so hosting it in an `AndroidView` is correct; only the compile-time type
|
* deduping sub-cell layout churn so a rotation costs exactly one `Resize` frame. Calling the engine on
|
||||||
* name is avoided. The clean fix (out of A21's lane) is to `api`-expose the Termux view from
|
* the SAME size-changed event would double every one of those frames (and re-SIGWINCH the shell).
|
||||||
* `:terminal-view` or add `libs.termux.terminal.view` to `:app`, then `AndroidView { remote.terminalView }`.
|
* - **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 =
|
internal object TerminalReclaimPolicy {
|
||||||
RemoteTerminalView::class.java.getMethod("getTerminalView").invoke(this) as View
|
|
||||||
|
/** `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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import wang.yaojia.webterm.session.Adopted
|
||||||
import wang.yaojia.webterm.session.AwayDigest
|
import wang.yaojia.webterm.session.AwayDigest
|
||||||
import wang.yaojia.webterm.session.Digest
|
import wang.yaojia.webterm.session.Digest
|
||||||
import wang.yaojia.webterm.session.Exited
|
import wang.yaojia.webterm.session.Exited
|
||||||
import wang.yaojia.webterm.session.Gate
|
import wang.yaojia.webterm.session.Gate
|
||||||
import wang.yaojia.webterm.session.GateState
|
import wang.yaojia.webterm.session.GateState
|
||||||
|
import wang.yaojia.webterm.session.Queued
|
||||||
import wang.yaojia.webterm.session.SessionEvent
|
import wang.yaojia.webterm.session.SessionEvent
|
||||||
import wang.yaojia.webterm.session.Telemetry
|
import wang.yaojia.webterm.session.Telemetry
|
||||||
|
import wang.yaojia.webterm.wire.ApprovalPreview
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
import wang.yaojia.webterm.wire.ClientMessage
|
||||||
import wang.yaojia.webterm.wire.GateKind
|
import wang.yaojia.webterm.wire.GateKind
|
||||||
import wang.yaojia.webterm.wire.StatusTelemetry
|
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
|
* 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,
|
* 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.
|
* 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(
|
public class GateViewModel(
|
||||||
private val controller: TerminalSessionController,
|
private val controller: TerminalSessionController,
|
||||||
@@ -105,14 +121,29 @@ public class GateViewModel(
|
|||||||
is Gate -> onGate(event.gate)
|
is Gate -> onGate(event.gate)
|
||||||
is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry)
|
is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry)
|
||||||
is Digest -> onDigest(event.digest)
|
is Digest -> onDigest(event.digest)
|
||||||
// The session ended: a held gate is no longer decidable — clear it so no stale card lingers.
|
// W2: the server's authoritative pending-prompt depth (never a local count).
|
||||||
is Exited -> _uiState.value = _uiState.value.copy(gate = null)
|
is Queued -> _uiState.value = _uiState.value.copy(queueDepth = event.length.coerceAtLeast(0))
|
||||||
else -> Unit // Output / Connection / Adopted are not cockpit concerns.
|
// 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?) {
|
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.
|
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).
|
// Rising edge = a NEW epoch (epochs are monotonic; a refresh keeps the same epoch).
|
||||||
if (gate.epoch != lastArrivalEpoch) {
|
if (gate.epoch != lastArrivalEpoch) {
|
||||||
@@ -133,8 +164,12 @@ public class GateViewModel(
|
|||||||
* an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message.
|
* an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message.
|
||||||
*/
|
*/
|
||||||
public fun decide(decision: GateDecision, epoch: Int) {
|
public fun decide(decision: GateDecision, epoch: Int) {
|
||||||
val held = _uiState.value.gate ?: return // canDecide line 1: no gate held → 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.
|
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.
|
val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop.
|
||||||
controller.decideGate(epoch, message)
|
controller.decideGate(epoch, message)
|
||||||
}
|
}
|
||||||
@@ -152,14 +187,49 @@ public enum class GateDecision {
|
|||||||
REJECT,
|
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. */
|
/** The gate/cockpit snapshot rendered by the surfaces. All fields null = nothing to show. */
|
||||||
public data class GateUiState(
|
public data class GateUiState(
|
||||||
/** The held permission gate, or `null` when none (falling edge / never risen). */
|
/** The held permission gate, or `null` when none (falling edge / never risen). */
|
||||||
val gate: GateState? = null,
|
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. */
|
/** Latest statusLine telemetry, or `null` before the first `telemetry` frame. */
|
||||||
val telemetry: StatusTelemetry? = null,
|
val telemetry: StatusTelemetry? = null,
|
||||||
/** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */
|
/** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */
|
||||||
val digest: AwayDigest? = null,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,14 +8,11 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import wang.yaojia.webterm.api.pairing.PairingError
|
import wang.yaojia.webterm.api.pairing.PairingError
|
||||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
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.Host
|
||||||
import wang.yaojia.webterm.hostregistry.HostStore
|
import wang.yaojia.webterm.hostregistry.HostStore
|
||||||
import wang.yaojia.webterm.wire.HostClassifier
|
import wang.yaojia.webterm.wire.HostClassifier
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HostNetworkTier
|
import wang.yaojia.webterm.wire.HostNetworkTier
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
|
||||||
import wang.yaojia.webterm.wire.TermTransport
|
|
||||||
import java.util.UUID
|
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
|
* 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
|
* the user ticks the risk acknowledge ([confirm]`(acknowledgedPublicRisk = true)`); otherwise the
|
||||||
* confirm is a no-op that re-arms the reminder.
|
* 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]
|
* 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** [confirm], [retry] and the
|
||||||
* funnel through the ONE private [runProbe]; for a [WarningTier.TUNNEL] host with no installed
|
* post-token resume all funnel through the ONE private `performProbe`; for a [WarningTier.TUNNEL]
|
||||||
* device cert, [runProbe] refuses BEFORE any network I/O and re-refuses on every retry (plan §8
|
* host with no installed device cert it refuses BEFORE any network I/O and re-refuses on every
|
||||||
* "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed").
|
* 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].
|
* 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 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 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).
|
* @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO).
|
||||||
@@ -126,38 +133,102 @@ public class PairingViewModel(
|
|||||||
runProbe(candidate, acknowledgedPublicRisk)
|
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) {
|
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
|
||||||
val tier = candidate.tier
|
// RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the
|
||||||
// RULE 3 — public host: no probe until the risk is explicitly acknowledged.
|
// pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe.
|
||||||
if (tier.needsExplicitAck && !acknowledgedPublicRisk) {
|
if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) {
|
||||||
_uiState.value = PairingUiState.Confirming(
|
_uiState.value = PairingUiState.Confirming(
|
||||||
endpoint = candidate.endpoint,
|
endpoint = candidate.endpoint,
|
||||||
name = candidate.name,
|
name = candidate.name,
|
||||||
tier = tier,
|
tier = candidate.tier,
|
||||||
awaitingAck = true,
|
awaitingAck = true,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val scope = scope ?: return
|
val scope = scope ?: return
|
||||||
probeJob?.cancel()
|
probeJob?.cancel()
|
||||||
probeJob = scope.launch {
|
probeJob = scope.launch { performProbe(candidate) }
|
||||||
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry re-hits this same guard.
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()) {
|
if (tier.isCertGated && !hasDeviceCert()) {
|
||||||
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
|
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
|
||||||
return@launch
|
return
|
||||||
}
|
}
|
||||||
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
||||||
when (val result = prober.probe(candidate.endpoint)) {
|
when (val result = prober.probe(candidate.endpoint)) {
|
||||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||||
is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed(
|
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,
|
endpoint = candidate.endpoint,
|
||||||
name = candidate.name,
|
name = candidate.name,
|
||||||
tier = tier,
|
tier = candidate.tier,
|
||||||
error = result.error,
|
error = error,
|
||||||
message = PairingCopy.describe(result.error, tier),
|
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) {
|
private suspend fun onProbeSuccess(candidate: Candidate) {
|
||||||
@@ -181,20 +252,80 @@ public class PairingViewModel(
|
|||||||
HostClassifier.hostOf(endpoint).ifEmpty { endpoint.baseUrl }
|
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
|
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
|
* Outcome of `POST /auth` (server `src/http/auth.ts` + the route in `src/server.ts`). A wrong token
|
||||||
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
|
* ([Rejected]) and a host that did not answer ([Unreachable]) are DISTINCT cases on purpose — collapsing
|
||||||
* layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel —
|
* them would tell the user to re-type a token that was fine.
|
||||||
* `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink
|
|
||||||
* I/O on a UI thread.
|
|
||||||
*/
|
*/
|
||||||
public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber =
|
public sealed interface TokenSubmission {
|
||||||
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) }
|
/** 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) ───────────────────────────────────────────────────────────────────────
|
// ── 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
|
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. */
|
/** The probe failed; [message] is inert, app-authored copy for [error]. Retryable. */
|
||||||
public data class Failed(
|
public data class Failed(
|
||||||
val endpoint: HostEndpoint,
|
val endpoint: HostEndpoint,
|
||||||
@@ -302,6 +454,8 @@ private fun PairingUiState.candidate(): Candidate? = when (this) {
|
|||||||
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
|
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
|
||||||
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
|
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
|
||||||
is PairingUiState.CertRequired -> 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.Failed -> Candidate(endpoint, name, tier)
|
||||||
is PairingUiState.Entry, is PairingUiState.Paired -> null
|
is PairingUiState.Entry, is PairingUiState.Paired -> null
|
||||||
}
|
}
|
||||||
@@ -315,7 +469,21 @@ internal object PairingCopy {
|
|||||||
"这个端口在运行别的服务,不是 web-terminal。端口对吗?"
|
"这个端口在运行别的服务,不是 web-terminal。端口对吗?"
|
||||||
is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived
|
is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived
|
||||||
is PairingError.CleartextBlocked ->
|
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 ->
|
PairingError.TlsFailure ->
|
||||||
// Tunnel hosts present a real LE server cert, so a TLS failure there means OUR client cert
|
// 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").
|
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
|
||||||
@@ -324,4 +492,19 @@ internal object PairingCopy {
|
|||||||
PairingError.Timeout ->
|
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 ->
|
||||||
|
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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.GitLogResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
import wang.yaojia.webterm.api.models.PrStatus
|
import wang.yaojia.webterm.api.models.PrStatus
|
||||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
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
|
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +36,16 @@ public class ProjectDetailViewModel(
|
|||||||
private val fetchLog: (suspend () -> GitLogResult)? = null,
|
private val fetchLog: (suspend () -> GitLogResult)? = null,
|
||||||
/** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */
|
/** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */
|
||||||
public val worktree: WorktreeViewModel? = null,
|
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`). */
|
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
|
||||||
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
|
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
|
||||||
@@ -57,9 +73,23 @@ public class ProjectDetailViewModel(
|
|||||||
public data object Unavailable : RecentCommits
|
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 _phase = MutableStateFlow<Phase>(Phase.Loading)
|
||||||
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
|
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
|
||||||
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.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. */
|
/** The main detail snapshot `ProjectDetailScreen` renders from. */
|
||||||
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||||
@@ -70,6 +100,15 @@ public class ProjectDetailViewModel(
|
|||||||
/** The recent-commits snapshot. */
|
/** The recent-commits snapshot. */
|
||||||
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
|
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
|
* 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
|
* 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() {
|
private suspend fun loadPr() {
|
||||||
val fetcher = fetchPr ?: return
|
val fetcher = fetchPr ?: return
|
||||||
_prChip.value = PrChip.Loading
|
_prChip.value = PrChip.Loading
|
||||||
@@ -137,9 +242,218 @@ public class ProjectDetailViewModel(
|
|||||||
fetchPr = { gateway.projectPr(path) },
|
fetchPr = { gateway.projectPr(path) },
|
||||||
fetchLog = { gateway.projectLog(path, null) },
|
fetchLog = { gateway.projectLog(path, null) },
|
||||||
worktree = worktree,
|
worktree = worktree,
|
||||||
|
fetchRemote = { gateway.gitFetch(path) },
|
||||||
|
fetchWorktreeState = { gateway.worktreeState(it) },
|
||||||
)
|
)
|
||||||
self = vm
|
self = vm
|
||||||
return 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 条提交。"
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
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.GitLogResult
|
||||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
import wang.yaojia.webterm.api.models.PrStatus
|
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.PruneWorktreesResult
|
||||||
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
import wang.yaojia.webterm.api.models.UiPrefs
|
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.ApiClient
|
||||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||||
import wang.yaojia.webterm.wire.Validation
|
import wang.yaojia.webterm.wire.Validation
|
||||||
@@ -418,6 +420,18 @@ public interface ProjectsGateway {
|
|||||||
public suspend fun projectPr(path: String): PrStatus
|
public suspend fun projectPr(path: String): PrStatus
|
||||||
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
|
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 ─────────────────────────────────────────────────────────
|
// ── W5: guarded worktree write ─────────────────────────────────────────────────────────
|
||||||
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
|
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
|
||||||
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
|
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 projectDetail(path: String): ProjectDetail = api.projectDetail(path)
|
||||||
override suspend fun projectPr(path: String): PrStatus = api.projectPr(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 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> =
|
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
|
||||||
api.createWorktree(path, branch, base)
|
api.createWorktree(path, branch, base)
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import wang.yaojia.webterm.api.routes.ApiClientError
|
|||||||
import wang.yaojia.webterm.designsystem.DisplayStatus
|
import wang.yaojia.webterm.designsystem.DisplayStatus
|
||||||
import wang.yaojia.webterm.hostregistry.Host
|
import wang.yaojia.webterm.hostregistry.Host
|
||||||
import wang.yaojia.webterm.hostregistry.HostStore
|
import wang.yaojia.webterm.hostregistry.HostStore
|
||||||
|
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
|
||||||
import wang.yaojia.webterm.session.TitleSanitizer
|
import wang.yaojia.webterm.session.TitleSanitizer
|
||||||
import wang.yaojia.webterm.session.UnreadLedger
|
import wang.yaojia.webterm.session.UnreadLedger
|
||||||
import wang.yaojia.webterm.wire.Tunables
|
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]
|
* 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
|
* [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]
|
* 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) and [killSession]
|
* (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open), [killSession]
|
||||||
* (optimistic swipe-to-kill). Ports the iOS session-list surface.
|
* (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)
|
* ### Untrusted server boundary (plan §4 / §8)
|
||||||
* The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` →
|
* The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` →
|
||||||
@@ -45,14 +53,16 @@ import kotlin.time.Duration
|
|||||||
* ### Testability
|
* ### Testability
|
||||||
* A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time with
|
* 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],
|
* no `Dispatchers.Main`/Robolectric. Its collaborators are seams ([SessionListGatewayFactory],
|
||||||
* [UnreadWatermarkStore]) so the fetch→row mapping, unread-dot logic and optimistic-kill path are all
|
* [UnreadWatermarkStore], [HostRemovalGateway]) so the fetch→row mapping, unread-dot logic,
|
||||||
* JVM-tested; swipe/thumbnail/pull gestures are device-QA (plan §7).
|
* 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(
|
public class SessionListViewModel(
|
||||||
private val hostStore: HostStore,
|
private val hostStore: HostStore,
|
||||||
private val gatewayFactory: SessionListGatewayFactory,
|
private val gatewayFactory: SessionListGatewayFactory,
|
||||||
private val watermarkStore: UnreadWatermarkStore = InMemoryUnreadWatermarkStore(),
|
watermarkStore: UnreadWatermarkStore? = null,
|
||||||
private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL,
|
private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL,
|
||||||
|
removalGateway: HostRemovalGateway? = null,
|
||||||
) {
|
) {
|
||||||
private val _uiState = MutableStateFlow(SessionListUiState())
|
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. */
|
/** The active host id; sticky across polls, defaults to the first host until the user switches. */
|
||||||
private var activeHostId: String? = null
|
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 ledger: UnreadLedger = UnreadLedger()
|
||||||
private var ledgerLoaded = false
|
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.
|
* 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)`.
|
* 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 row = _uiState.value.rows.firstOrNull { it.id == sessionId } ?: return
|
||||||
val at = row.info.lastOutputAt ?: return
|
val at = row.info.lastOutputAt ?: return
|
||||||
ledger = ledger.record(sessionId.toString(), at)
|
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) }) }
|
_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 ────────────────────────────────────────────────────────────────────────────────
|
// ── Internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private suspend fun fetch(markRefreshing: Boolean) {
|
private suspend fun fetch(markRefreshing: Boolean) {
|
||||||
@@ -202,7 +301,7 @@ public class SessionListViewModel(
|
|||||||
|
|
||||||
private suspend fun ensureLedgerLoaded() {
|
private suspend fun ensureLedgerLoaded() {
|
||||||
if (ledgerLoaded) return
|
if (ledgerLoaded) return
|
||||||
ledger = runCatching { watermarkStore.load() }.getOrDefault(UnreadLedger())
|
ledger = runCatching { watermarks.load() }.getOrDefault(UnreadLedger())
|
||||||
ledgerLoaded = true
|
ledgerLoaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,6 +333,11 @@ public data class SessionListUiState(
|
|||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
/** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */
|
/** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */
|
||||||
val hasLoadError: Boolean = false,
|
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)
|
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 {
|
public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()) : UnreadWatermarkStore {
|
||||||
@Volatile private var current: UnreadLedger = initial
|
@Volatile private var current: UnreadLedger = initial
|
||||||
override suspend fun load(): UnreadLedger = current
|
override suspend fun load(): UnreadLedger = current
|
||||||
@@ -307,3 +411,46 @@ public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()
|
|||||||
current = ledger
|
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>)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
package wang.yaojia.webterm.wiring
|
package wang.yaojia.webterm.wiring
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
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 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.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.HostStore
|
||||||
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||||
|
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
|
||||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
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.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.HttpTransport
|
||||||
import wang.yaojia.webterm.wire.TermTransport
|
import wang.yaojia.webterm.wire.TermTransport
|
||||||
|
import java.net.URI
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
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`.
|
* Engine state is never touched off-confinement and emulator/scrollback state never off-`Main`.
|
||||||
*
|
*
|
||||||
* ### Off-`Main` warm-up (FIX 3)
|
* ### Off-`Main` warm-up (FIX 3)
|
||||||
* The mTLS transports and [identityRepository] are behind `dagger.Lazy` so constructing this root does
|
* The mTLS transports, [identityRepository], the auth-cookie store and the thumbnail rasterizer are all
|
||||||
* NO AndroidKeyStore/Tink/`OkHttpClient` I/O on the injection (often `Main`) thread. [warmUp] resolves
|
* behind `dagger.Lazy` so constructing this root does NO AndroidKeyStore/Tink/`OkHttpClient`/DataStore/
|
||||||
* them on `Dispatchers.IO`, so the shared client is built and the device identity first-touched off the
|
* `android.graphics` work on the injection (often `Main`) thread — `MainActivity` field-injects it in
|
||||||
* UI thread. A21/A27 call [warmUp] before the first `bind()` / cert-screen open.
|
* `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):
|
* What lives here (all app singletons):
|
||||||
* - [hostStore] / [lastSessionStore] — persisted, non-secret UI state (DataStore).
|
* - [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
|
* - [apiClientFactory] / [sessionEngineFactory] — per-host / per-session builders over the shared
|
||||||
* transports (lazy).
|
* transports (lazy).
|
||||||
* - [coldStartPolicy] — the cold-launch route seam (A29).
|
* - [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
|
* Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its
|
||||||
* [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder].
|
* [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 identityRepositoryLazy: Lazy<IdentityRepository>,
|
||||||
private val httpTransportLazy: Lazy<HttpTransport>,
|
private val httpTransportLazy: Lazy<HttpTransport>,
|
||||||
private val termTransportLazy: Lazy<TermTransport>,
|
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
|
* 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
|
* (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()
|
public val httpTransport: HttpTransport get() = httpTransportLazy.get()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the two-step pairing prober (A19) over the ONE shared transports. The transports are
|
* Build the pairing network seam (A19) over the ONE shared transports: the two-step probe plus the
|
||||||
* resolved LAZILY inside `probe()` (not at build time) so constructing the prober on the UI thread
|
* `WEBTERM_TOKEN` gate's detect + submit. The transports are resolved LAZILY inside each call (not at
|
||||||
* never triggers the mTLS/OkHttp build — the actual resolve happens in the probe's own coroutine,
|
* build time) so constructing the prober on the UI thread never triggers the mTLS/OkHttp build — the
|
||||||
* after [warmUp].
|
* 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 =
|
public fun buildPairingProber(): PairingProber = object : PairingProber {
|
||||||
PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) }
|
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
|
* Build the session-list / manage-page thumbnail pipeline for one host (§6.7). Per-host because its
|
||||||
* either transport builds the one shared client (which reads the mTLS material); touching the
|
* preview fetch is bound to that host's [ApiClient][wang.yaojia.webterm.api.routes.ApiClient]
|
||||||
* identity forces its first AndroidKeyStore/Tink read. Idempotent at the singleton level — the
|
* (`Origin` is stamped per endpoint), so it cannot be an app singleton.
|
||||||
* client/identity are built once and cached. Safe to call from any coroutine; hops to
|
*
|
||||||
* `Dispatchers.IO` internally.
|
* 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() {
|
public suspend fun warmUp() {
|
||||||
withContext(Dispatchers.IO) {
|
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
|
sessionEngineFactory.warm() // builds TermTransport → the shared OkHttpClient → reads SSL material
|
||||||
apiClientFactory.warm() // HttpTransport reuses the SAME already-built client
|
apiClientFactory.warm() // HttpTransport reuses the SAME already-built client
|
||||||
runCatching { identityRepositoryLazy.get().hasInstalledIdentity() } // first AndroidKeyStore/Tink touch
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,8 +62,10 @@ public class TerminalSessionControllerImpl(
|
|||||||
) : TerminalSessionController by base {
|
) : TerminalSessionController by base {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The forked Termux emulator for this session (no local process). The screen puts it on-screen via
|
* The forked Termux emulator for this session (no local process). The screen only puts it on-screen
|
||||||
* `attachView` and drives `updateSize`; A21 feeds it the remote output and routes its replies out.
|
* (`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 =
|
public val remoteSession: RemoteTerminalSession =
|
||||||
remoteSessionFactory(::routeEmulatorMessage) { raw -> onSanitizedTitle(TitleSanitizer.sanitize(raw)) }
|
remoteSessionFactory(::routeEmulatorMessage) { raw -> onSanitizedTitle(TitleSanitizer.sanitize(raw)) }
|
||||||
|
|||||||
@@ -24,56 +24,129 @@ import kotlin.math.ceil
|
|||||||
*
|
*
|
||||||
* `TerminalRenderer`'s own `mFontWidth`/`mFontLineSpacing` are package-private, so this computes its own
|
* `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
|
* 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,
|
* 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
|
* `: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(
|
public class CanvasThumbnailRasterizer(
|
||||||
private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX,
|
private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX,
|
||||||
typeface: Typeface = Typeface.MONOSPACE,
|
typeface: Typeface = Typeface.MONOSPACE,
|
||||||
|
private val maxWidthPx: Int = ThumbnailBudget.MAX_WIDTH_PX,
|
||||||
|
private val maxHeightPx: Int = ThumbnailBudget.MAX_HEIGHT_PX,
|
||||||
) : ThumbnailRasterizer {
|
) : 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
|
this.typeface = typeface
|
||||||
textSize = fontSizePx
|
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)
|
/** The UNSCALED cell size at [fontSizePx]; [ThumbnailBudget.cellMetrics] shrinks it per render. */
|
||||||
private val baselineOffset: Int = -paint.fontMetricsInt.ascent
|
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 {
|
override fun rasterize(preview: SessionPreview): Bitmap {
|
||||||
val cols = preview.cols.coerceIn(1, MAX_COLS)
|
val grid = ThumbnailGrid.of(preview.cols, preview.rows)
|
||||||
val rows = preview.rows.coerceIn(1, MAX_ROWS)
|
val cell = ThumbnailBudget.cellMetrics(
|
||||||
val emulator = TerminalEmulator(NoOpOutput, cols, rows, TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN, NoOpClient)
|
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)
|
val bytes = preview.data.toByteArray(Charsets.UTF_8)
|
||||||
emulator.append(bytes, bytes.size)
|
emulator.append(bytes, bytes.size)
|
||||||
|
|
||||||
val bitmap = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, Bitmap.Config.ARGB_8888)
|
// Per-render Paint (see [metricsPaint]); its text size follows the shrunken cell, so a glyph never
|
||||||
val canvas = Canvas(bitmap)
|
// 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 palette = emulator.mColors.mCurrentColors
|
||||||
val defaultBg = palette[TextStyle.COLOR_INDEX_BACKGROUND]
|
val defaultBg = palette[TextStyle.COLOR_INDEX_BACKGROUND]
|
||||||
canvas.drawColor(defaultBg)
|
canvas.drawColor(defaultBg)
|
||||||
for (row in 0 until rows) {
|
for (row in 0 until grid.rows) {
|
||||||
drawRow(canvas, emulator, row, cols, palette, defaultBg)
|
drawRow(canvas, paint, emulator, row, geometry, palette, defaultBg)
|
||||||
}
|
}
|
||||||
return bitmap
|
return downscaleToBudget(full)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun placeholder(): Bitmap {
|
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)
|
bitmap.eraseColor(PLACEHOLDER_COLOR)
|
||||||
return bitmap
|
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 screen = emulator.screen
|
||||||
val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row))
|
val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row))
|
||||||
val text = line.mText
|
val text = line.mText
|
||||||
val top = (row * cellHeight).toFloat()
|
val top = (row * geometry.cellHeight).toFloat()
|
||||||
val baseline = (row * cellHeight + baselineOffset).toFloat()
|
val baseline = (row * geometry.cellHeight + geometry.baseline).toFloat()
|
||||||
for (col in 0 until cols) {
|
for (col in 0 until geometry.cols) {
|
||||||
val style = line.getStyle(col)
|
val style = line.getStyle(col)
|
||||||
val effect = TextStyle.decodeEffect(style)
|
val effect = TextStyle.decodeEffect(style)
|
||||||
val bold = (effect and (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0
|
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)
|
var bg = resolveColor(TextStyle.decodeBackColor(style), palette, boldBright = false)
|
||||||
if (inverse) { val swap = fg; fg = bg; bg = swap }
|
if (inverse) { val swap = fg; fg = bg; bg = swap }
|
||||||
|
|
||||||
val left = (col * cellWidth).toFloat()
|
val left = (col * geometry.cellWidth).toFloat()
|
||||||
if (bg != defaultBg) {
|
if (bg != defaultBg) {
|
||||||
paint.color = bg
|
paint.color = bg
|
||||||
paint.style = Paint.Style.FILL
|
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 start = line.findStartOfColumn(col)
|
||||||
val end = line.findStartOfColumn(col + 1)
|
val end = line.findStartOfColumn(col + 1)
|
||||||
@@ -111,13 +184,9 @@ public class CanvasThumbnailRasterizer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public companion object {
|
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
|
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. */
|
/** Opaque dark-grey placeholder for the fetch/render-failure fallback. */
|
||||||
public const val PLACEHOLDER_COLOR: Int = -0xdfdfe0 // 0xFF202020
|
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. */
|
/** Off-screen emulator sink: the preview emulator produces no wire output and needs no delegates. */
|
||||||
private object NoOpOutput : TerminalOutput() {
|
private object NoOpOutput : TerminalOutput() {
|
||||||
override fun write(data: ByteArray?, offset: Int, count: Int) = Unit
|
override fun write(data: ByteArray?, offset: Int, count: Int) = Unit
|
||||||
|
|||||||
@@ -6,17 +6,24 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.sync.withPermit
|
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.LiveSessionInfo
|
||||||
import wang.yaojia.webterm.api.models.SessionPreview
|
import wang.yaojia.webterm.api.models.SessionPreview
|
||||||
import wang.yaojia.webterm.api.routes.ApiClient
|
import wang.yaojia.webterm.api.routes.ApiClient
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Off-screen terminal-preview rasterisation for the session-list / manage-page thumbnails (plan §6.7).
|
* 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
|
* [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).
|
* 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
|
* - **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
|
* 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]
|
* 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.
|
* 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
|
* - **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.
|
* 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
|
* - **Supersede (coalesce per session)** — `lastOutputAt` advances on every PTY output and the list polls
|
||||||
* same key, so a broken/oversized session is not retried in a hot scroll loop.
|
* 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
|
* 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.
|
* 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),
|
private val cache: BitmapCache = LruBitmapCache(DEFAULT_CACHE_BYTES),
|
||||||
dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Default,
|
dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Default,
|
||||||
maxConcurrent: Int = MAX_CONCURRENT_RENDERS,
|
maxConcurrent: Int = MAX_CONCURRENT_RENDERS,
|
||||||
|
private val renderTimeoutMs: Long = DEFAULT_RENDER_TIMEOUT_MS,
|
||||||
) {
|
) {
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
|
||||||
@@ -56,16 +72,36 @@ public class ThumbnailPipeline(
|
|||||||
private val renderGate = Semaphore(maxConcurrent)
|
private val renderGate = Semaphore(maxConcurrent)
|
||||||
|
|
||||||
/** In-flight renders by key; guarded by [inFlightMutex]. A second same-key request shares the entry. */
|
/** 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()
|
private val inFlightMutex = Mutex()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The cached (or freshly rendered) thumbnail for [session]. Returns the cached bitmap immediately when
|
* The newest key requested per session id — the supersede rule's state, and the cache-publish gate.
|
||||||
* `(id, lastOutputAt)` is unchanged; otherwise fetches the preview over mTLS, rasterises off-screen,
|
* Concurrent (not [inFlightMutex]-guarded) because [retainOnly] is a plain, non-suspending call from a
|
||||||
* caches, and returns it. Never throws for a fetch/render failure — a placeholder is cached and
|
* `LaunchedEffect` and must be able to drop a killed session's entry without a coroutine.
|
||||||
* returned instead (only cooperative cancellation of [scope] propagates).
|
|
||||||
*/
|
*/
|
||||||
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)
|
val key = ThumbnailKey(session.id, session.lastOutputAt)
|
||||||
// Fast path: an unchanged lastOutputAt ⇒ identical screen ⇒ never re-render (§6.7).
|
// Fast path: an unchanged lastOutputAt ⇒ identical screen ⇒ never re-render (§6.7).
|
||||||
cache.get(key)?.let { return it }
|
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
|
// 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.
|
// populated the cache and removed its in-flight entry.
|
||||||
cache.get(key)?.let { return it }
|
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 }
|
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). */
|
/** 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()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startRender(key: ThumbnailKey): Deferred<Bitmap> = scope.async {
|
/** Test-only: live in-flight entries. A non-zero count after [close] would be a leaked key. */
|
||||||
val bitmap = renderGuarded(key.sessionId)
|
internal suspend fun inFlightCount(): Int = inFlightMutex.withLock { inFlight.size }
|
||||||
// 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 {
|
* Make [key] the session's newest key and abandon any older in-flight render for the SAME session — the
|
||||||
cache.put(key, bitmap)
|
* supersede rule. Caller MUST hold [inFlightMutex]; `Deferred.cancel` does not suspend, so cancelling
|
||||||
inFlight.remove(key)
|
* from inside the critical section is safe (the render's own `finally` takes the mutex afterwards).
|
||||||
}
|
*
|
||||||
bitmap
|
* 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 {
|
try {
|
||||||
renderGate.withPermit {
|
renderGate.withPermit {
|
||||||
|
// 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)
|
val preview = previewSource.fetch(sessionId)
|
||||||
rasterizer.rasterize(preview)
|
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) {
|
} catch (e: CancellationException) {
|
||||||
throw e // never swallow cooperative cancellation
|
throw e // never swallow cooperative cancellation (closed pipeline / superseded key)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
// Any fetch/render failure (network, oversized body, malformed escape) → cache a placeholder
|
// 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).
|
// so the broken session isn't retried on every scroll frame (§6.7). If even the placeholder
|
||||||
rasterizer.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 {
|
public companion object {
|
||||||
/** Plan §6.7: the render/fetch fan-out is capped at 2 concurrent renders. */
|
/** Plan §6.7: the render/fetch fan-out is capped at 2 concurrent renders. */
|
||||||
public const val MAX_CONCURRENT_RENDERS: Int = 2
|
public const val MAX_CONCURRENT_RENDERS: Int = 2
|
||||||
|
|
||||||
/** Default LRU budget for cached thumbnails (bytes). Small — thumbnails are transient UI cache. */
|
/** Default LRU budget for cached thumbnails (bytes). Small — thumbnails are transient UI cache. */
|
||||||
public const val DEFAULT_CACHE_BYTES: Int = 8 * 1024 * 1024
|
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
|
* 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`
|
* 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
|
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 interface BitmapCache {
|
||||||
public fun get(key: ThumbnailKey): Bitmap?
|
public fun get(key: ThumbnailKey): Bitmap?
|
||||||
public fun put(key: ThumbnailKey, bitmap: 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) {
|
override fun put(key: ThumbnailKey, bitmap: Bitmap) {
|
||||||
lru.put(key, 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
|
* 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
|
* tunnel-host previews traverse nginx). `GET /live-sessions/:id/preview` is a READ-ONLY route: it does NOT
|
||||||
* the server's own cap (plan §6.7 / §4.2).
|
* 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 =
|
override suspend fun fetch(sessionId: UUID): SessionPreview =
|
||||||
PreviewCap.cap(apiClient.preview(sessionId))
|
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 {
|
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
|
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)
|
* 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
|
* so a huge body can't drive an unbounded emulator append. A tail cut on a UTF-8 boundary at worst
|
||||||
* worst mangles one leading glyph — harmless for a thumbnail.
|
* mangles one leading glyph — harmless for a thumbnail.
|
||||||
*/
|
*/
|
||||||
public fun cap(preview: SessionPreview): SessionPreview {
|
public fun cap(preview: SessionPreview): SessionPreview {
|
||||||
val bytes = preview.data.toByteArray(Charsets.UTF_8)
|
val bytes = preview.data.toByteArray(Charsets.UTF_8)
|
||||||
|
|||||||
@@ -1,10 +1,84 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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
|
Cleartext posture (plan §6.9 + §8 checklist "Cleartext posture"). READ BEFORE EDITING.
|
||||||
and Tailscale CIDRs that legitimately need ws:// (10/8, 172.16/12,
|
|
||||||
192.168/16, 100.64/10). Tunnel (*.terminal.yaojia.wang) and Tailscale
|
── PLATFORM CONSTRAINT (verified against the schema, NOT assumed) ───────────────────────────
|
||||||
MagicDNS hosts present real LE certs and use default system trust (§6.9),
|
The network-security-config format has NO address-range syntax. Its entire vocabulary is
|
||||||
so they stay under this base-config. -->
|
<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>
|
<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>
|
</network-security-config>
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,109 @@ class QuickReplyTest {
|
|||||||
assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = false))
|
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 ─────────────────────────────────
|
// ── Pure transforms never mutate the receiver ─────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package wang.yaojia.webterm.viewmodels
|
package wang.yaojia.webterm.viewmodels
|
||||||
|
|
||||||
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
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.GitLogResult
|
||||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
import wang.yaojia.webterm.api.models.PrStatus
|
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.PruneWorktreesResult
|
||||||
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
import wang.yaojia.webterm.api.models.UiPrefs
|
import wang.yaojia.webterm.api.models.UiPrefs
|
||||||
|
import wang.yaojia.webterm.api.models.WorktreeState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
|
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
|
||||||
@@ -18,6 +20,8 @@ import wang.yaojia.webterm.api.models.UiPrefs
|
|||||||
*/
|
*/
|
||||||
class FakeWorktreeGateway(
|
class FakeWorktreeGateway(
|
||||||
private val detail: ProjectDetail? = null,
|
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 prResult: PrStatus? = null,
|
||||||
private val prThrows: Boolean = false,
|
private val prThrows: Boolean = false,
|
||||||
private val logResult: GitLogResult? = null,
|
private val logResult: GitLogResult? = null,
|
||||||
@@ -29,6 +33,8 @@ class FakeWorktreeGateway(
|
|||||||
val createCalls = mutableListOf<Triple<String, String, String?>>()
|
val createCalls = mutableListOf<Triple<String, String, String?>>()
|
||||||
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
|
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
|
||||||
val pruneCalls = mutableListOf<String>()
|
val pruneCalls = mutableListOf<String>()
|
||||||
|
val worktreeStateCalls = mutableListOf<String>()
|
||||||
|
val fetchCalls = mutableListOf<String>()
|
||||||
var detailCalls = 0
|
var detailCalls = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -51,6 +57,16 @@ class FakeWorktreeGateway(
|
|||||||
return logResult ?: throw NotImplementedError("no log configured")
|
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> {
|
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
|
||||||
createCalls += Triple(path, branch, base)
|
createCalls += Triple(path, branch, base)
|
||||||
return createOutcome
|
return createOutcome
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,9 @@ class GateViewModelTest {
|
|||||||
val fake = FakeController()
|
val fake = FakeController()
|
||||||
val vm = GateViewModel(fake)
|
val vm = GateViewModel(fake)
|
||||||
vm.bind(backgroundScope)
|
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))
|
fake.emit(planGate(3))
|
||||||
|
|
||||||
vm.decide(GateDecision.APPROVE, epoch = 3) // default review
|
vm.decide(GateDecision.APPROVE, epoch = 3) // default review
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,8 @@ class ProjectsViewModelTest {
|
|||||||
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
|
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
|
||||||
override suspend fun projectPr(path: String) = throw NotImplementedError()
|
override suspend fun projectPr(path: String) = throw NotImplementedError()
|
||||||
override suspend fun projectLog(path: String, n: Int?) = 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 createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError()
|
||||||
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError()
|
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError()
|
||||||
override suspend fun pruneWorktrees(path: String) = throw NotImplementedError()
|
override suspend fun pruneWorktrees(path: String) = throw NotImplementedError()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("已复制"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,56 @@
|
|||||||
package wang.yaojia.webterm.wiring
|
package wang.yaojia.webterm.wiring
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import com.termux.terminal.TerminalEmulator
|
||||||
|
import com.termux.terminal.TerminalOutput
|
||||||
|
import com.termux.terminal.TerminalSessionClient
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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.assertNotSame
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.Assertions.assertSame
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||||
import wang.yaojia.webterm.api.models.SessionPreview
|
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
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The [ThumbnailPipeline] policy (plan §6.7), driven at JVM speed with fakes so the cache/concurrency/
|
* 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
|
* dedup/supersede/timeout/failure logic is verified without android.graphics (the pixel raster lives behind
|
||||||
* [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd).
|
* 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:
|
* Proves:
|
||||||
* - an unchanged `(sessionId, lastOutputAt)` ⇒ cache hit, NO re-render; a changed `lastOutputAt` ⇒ render;
|
* - an unchanged `(sessionId, lastOutputAt)` ⇒ cache hit, NO re-render; a changed `lastOutputAt` ⇒ render;
|
||||||
* - the `Semaphore(2)` caps concurrent fetch+render at 2;
|
* - the `Semaphore(2)` caps concurrent fetch+render at 2;
|
||||||
* - two concurrent same-key requests SHARE one render (one fetch, one raster, one bitmap);
|
* - 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);
|
* - 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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class ThumbnailPipelineTest {
|
class ThumbnailPipelineTest {
|
||||||
@@ -59,7 +81,7 @@ class ThumbnailPipelineTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Hands back a fresh mock bitmap per raster (so identity distinguishes renders) + a fixed placeholder. */
|
/** 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
|
var rasterizeCount = 0
|
||||||
val placeholderBitmap: Bitmap = mockk()
|
val placeholderBitmap: Bitmap = mockk()
|
||||||
|
|
||||||
@@ -68,7 +90,10 @@ class ThumbnailPipelineTest {
|
|||||||
return mockk()
|
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. */
|
/** 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) {
|
override fun put(key: ThumbnailKey, bitmap: Bitmap) {
|
||||||
map[key] = bitmap
|
map[key] = bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun retainOnly(keys: Set<ThumbnailKey>) {
|
||||||
|
map.keys.retainAll(keys)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun session(id: UUID, lastOutputAt: Long?): LiveSessionInfo = LiveSessionInfo(
|
private fun session(id: UUID, lastOutputAt: Long?): LiveSessionInfo = LiveSessionInfo(
|
||||||
@@ -90,6 +119,32 @@ class ThumbnailPipelineTest {
|
|||||||
lastOutputAt = lastOutputAt,
|
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 ─────────────────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -135,7 +190,7 @@ class ThumbnailPipelineTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val jobs = (0 until 5).map { launch { pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L)) } }
|
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.
|
// Only 2 permits → only 2 fetches in flight; the other 3 are parked on the semaphore.
|
||||||
assertEquals(2, source.active)
|
assertEquals(2, source.active)
|
||||||
@@ -165,7 +220,7 @@ class ThumbnailPipelineTest {
|
|||||||
|
|
||||||
val a = async { pipeline.thumbnail(same) }
|
val a = async { pipeline.thumbnail(same) }
|
||||||
val b = async { pipeline.thumbnail(same) }
|
val b = async { pipeline.thumbnail(same) }
|
||||||
advanceUntilIdle()
|
runCurrent()
|
||||||
assertEquals(1, source.fetchStarts) // deduped: a single in-flight fetch
|
assertEquals(1, source.fetchStarts) // deduped: a single in-flight fetch
|
||||||
|
|
||||||
gate.complete(Unit)
|
gate.complete(Unit)
|
||||||
@@ -201,6 +256,401 @@ class ThumbnailPipelineTest {
|
|||||||
assertEquals(1, source.fetchStarts)
|
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
|
@Test
|
||||||
fun `preview cap keeps the tail when over 256 KiB and is a no-op under the cap`() {
|
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"
|
val big = "A".repeat(PreviewCap.MAX_PREVIEW_BYTES + 100) + "TAIL"
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user