Compare commits
36 Commits
e81c426329
...
822364d12b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
822364d12b | ||
|
|
217206e9a9 | ||
|
|
14f28e9d66 | ||
|
|
390dd11202 | ||
|
|
8075d2c671 | ||
|
|
538c8ebf34 | ||
|
|
53bee034ca | ||
|
|
2a24935b8d | ||
|
|
5833529c0c | ||
|
|
c1612d0145 | ||
|
|
b09b90e5bb | ||
|
|
3ad8d8beba | ||
|
|
a6cf547f04 | ||
|
|
d39a0ab8d1 | ||
|
|
2bfc76b397 | ||
|
|
22a7929a7b | ||
|
|
f6ef19ebf6 | ||
|
|
980dbaa928 | ||
|
|
4892fa7b49 | ||
|
|
35d32f4670 | ||
|
|
3fe719f874 | ||
|
|
3e5cfdc1cf | ||
|
|
ffc185aa2d | ||
|
|
029bec831a | ||
|
|
e506d0dbbf | ||
|
|
dba83559d3 | ||
|
|
a25fe30f1a | ||
|
|
950d2298a1 | ||
|
|
c3613c2fae | ||
|
|
609e7cea69 | ||
|
|
2066631c0a | ||
|
|
3c0b2f6bae | ||
|
|
e004da1a55 | ||
|
|
042c4cd4a9 | ||
|
|
c8d12780b9 | ||
|
|
f711db9315 |
12
.gitattributes
vendored
Normal file
12
.gitattributes
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# public/projects.ts embeds literal NUL bytes on purpose: the namespace-group
|
||||||
|
# sentinels ACTIVE_GROUP_KEY = '\0active' and OTHER_GROUP_KEY = '\0other' are
|
||||||
|
# prefixed with NUL so they can never collide with a real `First.Second` key.
|
||||||
|
#
|
||||||
|
# git classifies any file containing NUL as binary, so `git diff`/`git show`
|
||||||
|
# print "Binary files differ" and review of this file is impossible. The `diff`
|
||||||
|
# attribute tells git to diff it as text anyway. It does NOT set `text`, so
|
||||||
|
# nothing is line-ending-normalised and the bytes on disk are untouched.
|
||||||
|
#
|
||||||
|
# Same trap in the shell: `grep` prints only "Binary file ... matches" and looks
|
||||||
|
# like a no-match. Use `grep -a` on this file.
|
||||||
|
public/projects.ts diff
|
||||||
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")
|
||||||
|
|
||||||
@@ -82,15 +96,124 @@ 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())
|
||||||
NotificationManagerCompat.from(this).notify(id, notification)
|
try {
|
||||||
|
NotificationManagerCompat.from(this).notify(id, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Never log the payload — it carries the session id and the decision token (§8).
|
||||||
|
Log.w(TAG, "POST_NOTIFICATIONS revoked between check and notify; dropping notification", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST_NOTIFICATIONS is a runtime permission on API 33+; below that it is implicitly granted. */
|
/** 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,25 +599,59 @@ private fun CommitRow(commit: CommitLogEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `git log --format=%h` width — the same 7 the rest of the cockpit shows. */
|
||||||
|
private const val SHORT_HASH_LENGTH = 7
|
||||||
|
|
||||||
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
|
// ── 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
|
||||||
private fun SessionRow(session: ProjectSessionRef) {
|
private fun SessionRow(session: ProjectSessionRef) {
|
||||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -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,32 +303,116 @@ 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),
|
||||||
) {
|
) {
|
||||||
when {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
state.activeHostId == null ->
|
// The un-pair failed and NOTHING was removed (all-or-nothing) — say so above the live list.
|
||||||
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
|
if (state.hasRemoveError) RemoveErrorBanner(onDismiss = onDismissRemoveError)
|
||||||
state.rows.isEmpty() ->
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
EmptyState(
|
when {
|
||||||
message = if (state.hasLoadError) "无法加载会话列表。下拉重试。" else "该主机没有运行中的会话。",
|
state.activeHostId == null ->
|
||||||
actionLabel = "开新会话",
|
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
|
||||||
onAction = onNewSession,
|
state.rows.isEmpty() ->
|
||||||
)
|
EmptyState(
|
||||||
else -> SessionList(state = state, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails)
|
message = if (state.hasLoadError) {
|
||||||
|
"无法加载会话列表。下拉重试。"
|
||||||
|
} else {
|
||||||
|
"该主机没有运行中的会话。"
|
||||||
|
},
|
||||||
|
actionLabel = "开新会话",
|
||||||
|
onAction = onNewSession,
|
||||||
|
)
|
||||||
|
else -> SessionList(
|
||||||
|
state = state,
|
||||||
|
onOpen = onOpen,
|
||||||
|
onKill = onKill,
|
||||||
|
thumbnails = thumbnails,
|
||||||
|
layoutMode = layoutMode,
|
||||||
|
onNewSessionInCwd = onNewSessionInCwd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "nothing was removed" notice — the host is still paired and the action is safe to retry. */
|
||||||
|
@Composable
|
||||||
|
private fun RemoveErrorBanner(onDismiss: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8))
|
||||||
|
.padding(Spacing.md12),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "移除主机失败,未做任何更改。可以再试一次。",
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onDismiss) { Text("知道了") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The Hilt escape hatch for app-scoped collaborators (see TerminalScreen's palette store) ─────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app-scoped [AppEnvironment], resolved from the Hilt `SingletonComponent` — the standard escape
|
||||||
|
* hatch for a Composable, which cannot take constructor injection. Returns `null` outside a Hilt
|
||||||
|
* application (a `@Preview` or a plain-Compose test host), so those surfaces degrade to no durable
|
||||||
|
* watermarks and no un-pair affordance instead of crashing.
|
||||||
|
*
|
||||||
|
* Resolving it is `Main`-safe: every heavy collaborator inside is behind `dagger.Lazy` and the instance
|
||||||
|
* already exists by the time any screen composes (`MainActivity` field-injects it in `onCreate`).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun rememberAppEnvironment(): AppEnvironment? {
|
||||||
|
val application = LocalContext.current.applicationContext
|
||||||
|
return remember(application) {
|
||||||
|
runCatching {
|
||||||
|
EntryPointAccessors
|
||||||
|
.fromApplication(application, AppEnvironmentEntryPoint::class.java)
|
||||||
|
.appEnvironment()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default `onForeground` action: run one silent certificate-rotation pass if due. A no-op outside a
|
||||||
|
* Hilt application. Fire-and-forget by construction — [AppEnvironment.runCertificateRotationIfDue]
|
||||||
|
* launches on its own app-scoped IO scope, so nothing here can delay the session list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun rememberCertificateRotationTrigger(): () -> Unit {
|
||||||
|
val env = rememberAppEnvironment()
|
||||||
|
return remember(env) { { env?.runCertificateRotationIfDue() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the app-scoped [AppEnvironment] for [rememberAppEnvironment]. */
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
internal interface AppEnvironmentEntryPoint {
|
||||||
|
public fun appEnvironment(): AppEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SessionList(
|
private fun SessionList(
|
||||||
state: SessionListUiState,
|
state: SessionListUiState,
|
||||||
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 ->
|
||||||
@@ -276,7 +469,17 @@ private fun SwipeToKillRow(
|
|||||||
enableDismissFromStartToEnd = false,
|
enableDismissFromStartToEnd = false,
|
||||||
backgroundContent = { KillBackground() },
|
backgroundContent = { KillBackground() },
|
||||||
) {
|
) {
|
||||||
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
|
PointerContextMenu(
|
||||||
|
mode = layoutMode,
|
||||||
|
actions = sessionRowMenuActions(
|
||||||
|
row = row,
|
||||||
|
onOpen = onOpen,
|
||||||
|
onNewSessionInCwd = onNewSessionInCwd,
|
||||||
|
onKill = onKill,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop.
|
val held = state.gate ?: return // canDecide line 1: no gate held → drop.
|
||||||
|
if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop.
|
||||||
|
// The host's own policy outranks the phone: auto-accept-edits is refused unless a successful
|
||||||
|
// `GET /config/ui` said it is allowed (fail closed — see the class doc).
|
||||||
|
if (decision == GateDecision.ACCEPT_EDITS && !state.allowAutoMode) return
|
||||||
val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop.
|
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.
|
}
|
||||||
if (tier.isCertGated && !hasDeviceCert()) {
|
|
||||||
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
|
/**
|
||||||
return@launch
|
* 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
|
||||||
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
* pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns
|
||||||
when (val result = prober.probe(candidate.endpoint)) {
|
* [probeJob], which would cancel the coroutine it is called from).
|
||||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
*/
|
||||||
is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed(
|
private suspend fun performProbe(candidate: Candidate) {
|
||||||
endpoint = candidate.endpoint,
|
val tier = candidate.tier
|
||||||
name = candidate.name,
|
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both
|
||||||
tier = tier,
|
// re-hit this same guard.
|
||||||
error = result.error,
|
if (tier.isCertGated && !hasDeviceCert()) {
|
||||||
message = PairingCopy.describe(result.error, tier),
|
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
|
||||||
)
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
||||||
|
when (val result = prober.probe(candidate.endpoint)) {
|
||||||
|
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||||
|
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a
|
||||||
|
* `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token
|
||||||
|
* gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim.
|
||||||
|
*/
|
||||||
|
private suspend fun onProbeFailure(candidate: Candidate, error: PairingError) {
|
||||||
|
if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) {
|
||||||
|
promptForToken(candidate, error = null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_uiState.value = PairingUiState.Failed(
|
||||||
|
endpoint = candidate.endpoint,
|
||||||
|
name = candidate.name,
|
||||||
|
tier = candidate.tier,
|
||||||
|
error = error,
|
||||||
|
message = PairingCopy.describe(error, candidate.tier),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun promptForToken(candidate: Candidate, error: TokenError?) {
|
||||||
|
_uiState.value = PairingUiState.TokenRequired(
|
||||||
|
endpoint = candidate.endpoint,
|
||||||
|
name = candidate.name,
|
||||||
|
tier = candidate.tier,
|
||||||
|
error = error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onProbeSuccess(candidate: Candidate) {
|
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 {
|
||||||
val preview = previewSource.fetch(sessionId)
|
// The timeout is INSIDE the permit so it budgets the work, not the wait for a permit, and
|
||||||
rasterizer.rasterize(preview)
|
// the catch/fallback is OUTSIDE it so no placeholder is ever allocated holding a permit.
|
||||||
|
withTimeout(renderTimeoutMs) {
|
||||||
|
val preview = previewSource.fetch(sessionId)
|
||||||
|
rasterizer.rasterize(preview)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (timeout: TimeoutCancellationException) {
|
||||||
|
// MUST precede the CancellationException branch — a timeout IS one. Rethrowing it would turn a
|
||||||
|
// slow host into a permanently dead feature: the two permits are the only ones there are, and a
|
||||||
|
// render is deliberately not cancellable by any caller.
|
||||||
|
placeholderOrNull()
|
||||||
} catch (e: CancellationException) {
|
} 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"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user