Compare commits

52 Commits

Author SHA1 Message Date
Yaojia Wang
0da9e7d175 Merge diff-panel-design: style the project-detail diff panel (52 unstyled classes)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-30 22:34:49 +02:00
Yaojia Wang
38274a5271 fix(projects): style the diff panel — 52 classes had no CSS at all
The project-detail Git panel's diff viewer emitted 52 class names that
style.css had no rule for, so it rendered as browser defaults: Working /
Staged / the base picker / ✕ Close as native OS buttons on a dark warm
panel, and a diff body with no monospace font and no +/- colouring at
all. The user's screenshot showed the toolbar; the audit (literal AND
template-literal class names) found the rest.

Adds the B1 diff-viewer block and the proj-*/tl-* rules, all on :root
tokens, following the file's existing two-button-role doctrine.

Also fixes, in passing:
- .df-commit-btn hardcoded #1a1712 where THEME.md mandates
  var(--on-accent) for text on a gold background
- .proj-pr-hidden — a "hidden" state class with no rule, so it never hid
- .proj-dot-stuck — ClaudeStatus has 'stuck' but only four of the five
  dots were styled, so a stuck session was the same colour as "no signal"
- --warn-wash: a derived token for the deletion wash, documented in
  THEME.md, so a re-skin moves the wash with --warn

Notable decisions, recorded in the CSS comments:
- .proj-tl-container gets NO height cap. Every capping mechanism tried
  (overflow-y:auto, mask-image, an ::after fade, a :has() row gate) cost
  more than the height it saved — the 5s wipe-and-re-append render loop
  cannot host a scroll position, and mask-clip:border-box ate the well's
  own border. It grew freely before this CSS existed; maxEvents=50 bounds it.
- The horizontal scroller stays per-hunk on .df-lines. Hoisting it to
  .df-file would drag the file path out of view when you scroll right.
- .df-file > .df-file-header keeps a smaller unscoped padding: the
  terminal page's .approval-preview also calls renderDiffFile().

Verified against the real DOM in a browser at 900px and 390x844 via a
throwaway harness (removed before commit), not from the CSS text alone.
typecheck exit 0; 2216 unit + 27 e2e tests pass.
2026-07-30 22:34:40 +02:00
Yaojia Wang
576c033031 Merge orphan-parity-plan: schedule the orphan tmux session client work
Some checks failed
android / build-and-test (push) Has been cancelled
android / instrumented-tests (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (ClientTLS) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (iPad Pro 11-inch (M4), -skip-testing:WebTermTests/LiveServerSmokeTests, ipad, false) (push) Has been cancelled
ios / app-tests (iPhone 16, , iphone, true) (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (iPad Pro 11-inch (M4), ipad, -only-testing:WebTermUITests/ProjectsLayoutUITests) (push) Has been cancelled
ios / ui-test (iPhone 16, iphone, ) (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-30 18:25:18 +02:00
Yaojia Wang
f000a9d979 docs(plan): schedule the orphan tmux session client parity work
The server half shipped 2026-07-30 as feature A (4892fa7/f6ef19e/d39a0ab) and
both native clients are at zero. This is NOT a regression — OrphanSessionInfo is
deliberately a separate type rather than a flag on LiveSessionInfo, and
src/server.ts:600 names the reason: the clients decode /live-sessions, so a
variant shape there would have broken them.

It is worth planning anyway because SessionManager.shutdown only detaches the
tmux client for tmux-backed sessions (manager.ts:410-417), so a server restart
or a desktop-app quit turns every session into an orphan. The web launcher lists
them and re-adopts on tap; the phone still shows an empty list — which is the
walk-away case this product exists for.

The plan doc carries the eleven traps a client implementer will hit, each one a
shipped review finding or a documented server subtlety — notably that preview
returns cols/rows of literally 0 (both clients currently size thumbnails from
that response), that a 429 must not collapse to "no orphans", and that the
destructive confirmation must come from count-idle rather than card count (that
one nearly ended 33 shells while the dialog said 24).

Also corrects two now-false claims found while cross-referencing: the ROADMAP
row for tmux discovery was half-stale, and ANDROID_CLIENT_PLAN's do-not-consume
list still listed POST /projects/worktree (both clients consume it now) and
GET /sessions (iOS consumes it, Android does not — so "match iOS" now argues
for building it, not against).
2026-07-30 18:25:09 +02:00
Yaojia Wang
97e39949a8 Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
2026-07-30 18:12:03 +02:00
Yaojia Wang
ddab77b337 test(ios): close T-iOS-32's end-to-end half — real worktree lifecycle against a real server
T-iOS-32's acceptance is "builder 测试 + 端到端一次". The builder half was green,
but grep for projects/worktree across the whole test tree returned nothing — no
test had ever created or removed a real git worktree against a real server.

Six tests on a throwaway git fixture: create/remove/prune verified against both
the filesystem and git's own `worktree list --porcelain` + .git/worktrees/, and
asserting the values the SERVER derives rather than echoing our input (branch
feature/e2e-worktree becomes directory feature-e2e-worktree; the raw branch name
must never appear in the path). Includes a differential leg for the guarded-route
rule — the same request without Origin is 403 with zero side effects, and with it
is 200 — which needs raw URLSession, since APIClient structurally cannot emit an
Origin-less guarded request.

GET /sessions gets its own HOME-isolated server: history.ts reads
os.homedir()/.claude/projects, so against the shared harness the assertions would
land on the developer's real Claude history, and in CI the directory is absent so
it degrades to [] and proves nothing.

Integration tests 26 -> 32, independently re-run.
2026-07-30 16:58:22 +02:00
Yaojia Wang
5cc755b0b6 fix(ios,android): close the acceptance gaps the review found, add Android CI
- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar
  froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped —
  recorded as a withKnownIssue rather than fixed. Height now derives from the
  content size category (and tracks live changes via registerForTraitChanges);
  the known-issue marker is gone, replaced by positive assertions including a
  12-category no-clip sweep and a guard that stays red if anyone writes the
  constant back. Honest tradeoff: the keycap font is clamped at .accessibility2,
  the same policy the design system already applies to dense content, because an
  unclamped AX5 bar would eat the terminal. A test pins the clamp so the two
  cannot drift.

- Thumbnails silently 401'd on a token-gated host: the pipeline built its own
  transport with no token source, and by design never throws, so every preview
  degraded to a placeholder with no signal. Assembled from AppEnvironment now.

- Android had zero CI while the token wave shipped 24 files of secret-handling
  code. The instrumented leg is workflow_dispatch-only and says why in the file:
  no one has ever seen it green on a runner, and a required leg nobody trusts
  just produces a false green.

- Android persisted a validated token before the host probe succeeded, stranding
  a secret for a host that never paired.

App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691.
2026-07-30 16:46:20 +02:00
Yaojia Wang
822364d12b test(server): prove the H1 precondition instead of assuming it
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
`H1 shell state survives a server restart` failed in the full parallel suite
and passed in isolation, which reads like a timing flake in the assertion. It
was not. Polling for the marker instead of sleeping a fixed 900ms showed the
shell replying promptly with:

    echo MARK-$WEBTERM_MARK
    MARK-

An EMPTY variable. The post-restart shell was fine; the variable had never been
set in the PRE-restart shell, because a fixed 500ms is not enough for the shell
to become ready and run the assignment when 81 test files are competing for the
machine. The symptom then appears at the far end of the test and reads as "the
tmux session did not survive the restart", sending whoever debugs it after
entirely the wrong bug.

So the setup now proves itself: it echoes the variable back and waits for
`SET-tmuxlives` before restarting, and fails there with "the variable was never
set in the pre-restart shell" if it did not take. The final assertion likewise
waits for its marker with a bounded timeout rather than guessing a duration.

Neither wait can mask a genuine failure: if the tmux session really did not
survive, the shell is fresh, the variable is empty, and the marker never appears
no matter how long we wait.

Added a `waitUntil` helper for this. It carries its own sleep because the
`delay` the cases use is a local const inside a test body, not module scope.

Verified: full `npx vitest run test/` -> 81 files, 2243 tests, 0 failures.

NOT fixed, and not mine to guess at: `server.test.ts` has at least one more
real-PTY case that flakes under the same parallel load (⑤ attach → attached →
shell prompt output times out in `waitForMessage`, then its afterEach hook
times out too). It passes in isolation. Same class of fixed-delay assumption,
different case.
2026-07-30 16:27:32 +02:00
Yaojia Wang
217206e9a9 Merge android-blocker-fixes: make the Android client work on a device
The client reported COMPLETE with 617 green tests and a buildable APK, but had
never run on hardware, and the first interaction on a device crashed it. Seven
real defects, four of them crashes:

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

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

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

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

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

# Conflicts:
#	docs/PROGRESS_LOG.md
2026-07-30 16:09:22 +02:00
Yaojia Wang
284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00
Yaojia Wang
14f28e9d66 docs(android): record what the device run proved and what is still open
The correction section written earlier today said A34/A35 had no code and
device QA was ~0%. Both were true when written and are not any more, so they
are marked resolved rather than left to mislead the next reader — with the
instrumented invocation recorded (the emulator needs
ALLOWED_ORIGINS=http://10.0.2.2:<port>, because allowed origins are derived
from NIC IPs and 10.0.2.2 is not one).

What stays open is stated plainly: S2 needs two physical handsets under Doze,
the rest of DEVICE_QA_CHECKLIST has no automated cover, and one instrumented
test kills the process when driven without host arguments on a heavily-recycled
emulator while passing reproducibly under the documented invocation — which is
recorded as unresolved rather than explained away.
2026-07-30 15:38:10 +02:00
Yaojia Wang
390dd11202 feat(android): close the audit's remaining parity gaps
Seven items the earlier repair waves deliberately left alone, so that the
blocker fixes could land with a working safety net first.

CERTIFICATE LIFECYCLE (the audit's only "high"). iOS ships a
CertificateRotationScheduler; Android had no counterpart, so a device
certificate simply expired and needed a manual re-enroll. The decision is a
pure total function over five answers, with RE_ENROLL_REQUIRED checked first so
it outranks any backoff. Renewal is single-flight — a second trigger coalesces,
and a cancelled leader releases the slot so cancellation cannot wedge rotation
for the process lifetime. Failure leaves the prior identity fully live (the
existing atomic ping-pong flip already guaranteed that) and is published rather
than swallowed.

Two real defects surfaced while building it:

  renew() decoded its 201 with EnrollResponseDto, whose deviceId is required —
  but /device/:id/renew returns only {cert, caChain, notAfter}; only
  /device/enroll echoes deviceId. Every silent renewal would have thrown
  MalformedResponse. Fixed with a RenewResponseDto plus the deviceId from the
  path segment we addressed.

  And because no renew 201 carries renewAfter, a client that persisted it would
  rotate exactly once and then report not-due until the cert died. That is why
  renewAfter is re-derived from the live leaf as notBefore + 2/3·lifetime — the
  control plane's own formula. This is a latent iOS defect that Android now
  avoids rather than inherits.

/device/:id/recover was confirmed real (control-plane/src/api/renew.ts:443,
contract pinned by that suite's CP6e) and is now wired. It goes over a separate
NON-mTLS client that throws rather than falling back, because an expired client
certificate cannot authenticate its own recovery — the deadlock a previous
session already hit and recorded.

Asked explicitly about step-up: rotation is unaffected. stepUp appears nowhere
in control-plane/src; it lives only on the relay's WebSocket upgrade. On a
step-up host the SESSION is denied, not the rotation, so the certificate still
stays alive and that principal gap is orthogonal.

FOLLOW-UP QUEUE (W2). The queue frame decoded but the three routes managing it
were missing, so Android could see "N queued" and not queue anything. One
outcome union covers both guarded writes and distinguishes full / too-large /
disabled / session-gone / rate-limited, because they need different copy. 429 is
surfaced and never auto-retried — POST and DELETE share one 20/min bucket.
Queued text is raw shell input and travels verbatim, proven with control
characters and CJK.

UNREAD WATERMARK now persists, so the unread state survives process death —
which is exactly when it matters, since the product premise is walking away and
coming back. It stores a plain map rather than an UnreadLedger: the reducer
lives in :session-core, which :host-registry may not depend on, so the logic is
not restated anywhere.

COPY-OUT. Text selection was a recorded feature gap: the stock ActionMode's own
Copy handler dereferences the absent TerminalSession, so making it reachable
puts an NPE on the button — worse than no selection. The refusal was
re-verified from the bytecode, and the same disassembly showed the way out:
TerminalBuffer.getSelectedText touches only mLines/TerminalRow, no session and
no view. A first-party selection layer now builds on that, with a pure
row-major model so a backwards drag normalises correctly. Terminal content
reaches the clipboard only on an explicit user copy; OSC 52 stays declined.

HOST REMOVAL. PushRegistrar.unregisterHost had zero call sites, so a removed
host kept receiving pushes. Removal is now confirm-gated and cleans everything
keyed by host id — a half-removed host is worse than none.

/config/ui is finally honoured. It was implemented and never called, so
allowAutoMode was ignored and the plan gate offered "approve + auto" even where
the operator had disabled it. It fails CLOSED: an unfetchable config treats auto
as disabled, because the permissive default is the dangerous one.

Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 1150 JVM
tests, 0 failures (903 -> 1150). Instrumented suite re-run on emulator-5554
against live servers: 67 tests, 0 failures, 0 skipped — no device regression.
2026-07-30 15:37:09 +02:00
Yaojia Wang
8075d2c671 test(android): the instrumented suite A34/A35 never had
The plan marked A34 (instrumented E2E against a real host) and A35
(macrobenchmark) DONE. Neither had a single line of code — :app had no
androidTest source set at all, and `testInstrumentationRunner` named a
HiltTestRunner class that did not exist. They had been converted into checklist
bullets. This adds 67 instrumented tests and 3 macrobenchmarks, and every one
of them has actually been executed on an emulator against this repo's own
server — not merely compiled.

Why this layer had to exist: four crash-on-first-interaction defects shipped
behind a green 900-test suite, for one structural reason — no JVM test
instantiates an android.view.View, creates an InputConnection, dispatches a
MotionEvent or runs a layout pass. So the rig builds the REAL RemoteTerminalView
in a REAL Activity and drives it through the REAL framework entry points,
faking only the wire so assertions can be about BYTES rather than "did not
crash". That distinction matters: a no-crash-only test would have passed against
the silent-black-hole variant of the IME bug, which was arguably the worse half
of it.

The four crash regressions (28 tests):
  keys — Enter is \r not \n, Ctrl+C is 0x03, Backspace 0x7f, and arrows in BOTH
    DECCKM modes driven off the live emulator bit, so ESC[A vs ESC OA is
    asserted rather than assumed; BACK falls through and emits nothing; soft
    keyboard commitText/sendKeyEvent/deleteSurroundingText and a CJK
    composition pass through verbatim.
  alternate-screen scroll — every test feeds ESC[?1049h FIRST and asserts the
    buffer is active before touching anything, because that is the branch that
    used to crash. Real dispatchTouchEvent drags both directions, the DECCKM
    form, the mouse-tracking branch emitting a wheel report and NO arrows
    (branch order), the wheel's 3-rows-per-notch, and the fling proven not to
    start by 800 ms of silence after ACTION_UP.
  autofill — the framework's own autofill-structure gate is closed on both the
    frame and the stock child, before and after focus.
  resize — including the draw-race counterpart of the AIOOBE fixed in 538c8eb.

A34 E2E against a live server (15 tests): attach → attached → output round
trip, ring-buffer replay on reconnect, spawn failure via a second server
deliberately configured with a bad SHELL_PATH, a held gate resolved through
POST /hook/decision, and CswshDefenceE2eTest — the F9 bad-Origin rejection,
which the checklist calls the one non-skippable defence and which had never
been executed anywhere.

A35 (3 macrobenchmarks): startup, and pair → attach → type → approve.

Reaching a host from the emulator needs ALLOWED_ORIGINS=http://10.0.2.2:<port>,
because the server derives allowed origins from host NIC IPs and 10.0.2.2 is
the emulator's alias for the host, not an interface address. Tests take the
host as an instrumentation argument and SKIP with a clear message when it is
absent, so "passed" can never be confused with "never ran".

Verified independently of the authoring agent, on emulator-5554 (webterm AVD,
android-35, arm64, software GPU), with both servers live:

    Starting 67 tests on webterm(AVD) - 15
    Finished 67 tests on webterm(AVD) - 15
    tests="67" failures="0" skipped="0"

JVM gate unaffected: ./gradlew test :app:assembleDebug
:app:assembleDebugAndroidTest :macrobenchmark:assembleBenchmark koverVerify ->
BUILD SUCCESSFUL, 903 tests, 0 failures.
2026-07-30 13:42:26 +02:00
Yaojia Wang
538c8ebf34 fix(android): move emulator mutation to the render thread
The instrumented suite was run on a device for the first time and the app died
on the first test:

    ArrayIndexOutOfBoundsException: length=31; index=31
        at com.termux.terminal.TerminalRow.getStyle
        at com.termux.view.TerminalRenderer.render
        at com.termux.view.TerminalView.onDraw

PROGRESS_ANDROID.md had classified exactly this as "Accepted (not a defect):
steady-state append runs concurrently with the UI-thread onDraw ... torn read
self-corrects next frame". Both halves of that were wrong.

It is not cosmetic. TerminalRenderer.render caches its column bound once from
TerminalEmulator.mColumns and then indexes TerminalRow.getStyle(column) into a
raw long[] sized when that row was allocated. TerminalEmulator.resize publishes
the new mColumns BEFORE resizeScreen() reallocates the rows, so a draw landing
in that window reads the new bound against old-width rows. The first
out-of-range column is exactly oldColumns, which is why the exception was
always length == index — the consistency was a deterministic signature of a
grow, not a rare interleaving. A shrink is harmless, which is probably why
"self-corrects" looked plausible. TerminalBuffer.resize is non-atomic in
several more ways, and TerminalRow.setChar swapping mText for a larger array is
the same hazard on the append path.

Nor does it match upstream. Upstream's background reader only fills a
ByteQueue; TerminalSession$MainThreadHandler is what calls append, and
TerminalView.updateSize resizes — both on the main thread. Upstream has no
concurrent reader at all. There is also no lock to lean on: monitorenter
appears nowhere in TerminalEmulator, TerminalBuffer, TerminalRow or
TerminalRenderer.

The race was dormant until this session. updateSize had no call sites, so
mColumns never changed after bind and there was never a width mismatch to tear
on. Making resize work is what made this reachable — the same lesson as the
rest of this pass: the green suite could not see any of it because no JVM test
instantiates a View.

Fix: every emulator mutation now runs on the render thread, from inside the
same single confined consumer. §6.2 is intact — still one writer draining one
ordered channel, suspending across the main hop, so a resize and an append
still cannot interleave and submission order still holds. They are now also
serialised against onDraw, because one Handler work item cannot run inside
another. Resize still reaches the wire and the §6.4 forced re-assert still
bypasses the dedup.

Chunking becomes load-bearing rather than incidental: each 4 KiB slice is its
own main-thread work item, so a multi-MB ring replay interleaves with frames
and input instead of blocking behind one long call — which is what §6.2's
no-ANR requirement actually needs, rather than "runs off the main thread".

Gating the child's dispatchDraw was considered and rejected: it would have to
cover appends too, leaving a choice between blanking the terminal and blocking
the UI thread on a full reflow. A read/write lock was rejected because append
calls back out through TerminalOutput (title, bell, DA/DSR reply), so holding a
write lock across it would bury a deadlock invariant in app-level callbacks.

The KDoc that made the false claims now states the mechanism with offsets, and
RemoteTerminalHostView records why dispatchDraw is deliberately not gated.

Verified: the device crash is reproduced as a JVM test first (red), so this
regression is now guarded without a device. ./gradlew test :app:assembleDebug
koverVerify -> 903 tests, 0 failures. On the emulator the previously-crashing
test now passes and 5 of 6 alternate-screen scroll tests pass.
2026-07-30 13:25:42 +02:00
Yaojia Wang
53bee034ca docs(progress): log the desktop USE_TMUX default fix 2026-07-30 12:48:02 +02:00
Yaojia Wang
2a24935b8d Merge desktop-tmux-default: app-started sessions survive quitting the app 2026-07-30 12:47:25 +02:00
Yaojia Wang
5833529c0c fix(desktop): default USE_TMUX to auto so quitting the app stops killing sessions
The desktop shell pinned USE_TMUX=0 on every platform, not just Windows. Without
tmux the shell is a direct child of the embedded server, so SessionManager.shutdown
takes the kill branch and quitting the app — the tray Quit, a logout, an update —
ends every session with it.

That defeats the app's premise. The point is to hand Claude Code a task, walk away,
and reconnect from another device; a session that cannot survive the window closing
does not do that. With tmux the node-pty process is only a tmux CLIENT, so shutdown
detaches and the shell keeps running, which is exactly what the standalone server
has always done (USE_TMUX unset → 'auto' → is the binary present?).

Non-Windows now defaults to 'auto'. Windows stays forced off — tmux is a *nix
keepalive. An explicit ambient USE_TMUX still wins either way, so USE_TMUX=0 remains
the opt-out.

Tests assert the end-to-end result, not just the emitted string: buildServerEnv fed
through loadConfig yields useTmux === true with tmux installed, and false for an
explicit opt-out. The original bug was these two layers disagreeing about what a
value meant, so checking the string alone would not have caught it.

Sessions started in the app before this change are NOT retroactively protected —
they have no tmux session behind them and will still die with the app. Only sessions
created after the new build is installed survive.
2026-07-30 12:47:05 +02:00
Yaojia Wang
9114630c3a feat(android): access-token support — same frozen contract as iOS
Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
2026-07-30 12:45:27 +02:00
Yaojia Wang
a5fa843f00 test(ios): ClientTLS coverage 55.76% -> 89.49%, gate it, fix the three dead CI legs
ClientTLS was the most security-sensitive package in the tree and the least
covered, and it was not in the coverage gate at all (the gate's 4-package set
predates it). 48 -> 84 tests against the real macOS keychain, serialized with a
custom Testing trait after a @globalActor proved insufficient (actors yield at
await, so cross-await critical sections got interleaved by other cases' cleanup).

CI: the app/ipad/ios17 legs ran a bundle containing LiveServerSmokeTests, which
spawns tsx, with no npm ci -- a hard failure, not a skip, on a bare checkout.
Adds the missing iPad UI-test leg, and makes a missing iOS 17 runtime fail loudly
instead of silently reporting green.
2026-07-30 12:45:26 +02:00
Yaojia Wang
850531fd07 feat(ios): access-token support + git-panel endpoints across the package layer
APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four
distinct outcomes from the frozen contract, Cookie/Accept landed at the same
single header choke point that already enforces Origin-iff-G, plus the whole
project-ops surface the server has had since late July and iOS consumed none of:
/projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/
fetch, worktree create/remove/prune, GET /sessions, follow-up queue.

HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain
under the existing SecItemShim conventions (device-only, never synchronizable),
charset/length validated at the boundary, old token-less records still decode.

SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from
the same point that writes Origin, and a 401 handshake is a terminal
.unauthorized -- never entering the backoff loop, since retrying one wrong
shared token is a brute-force generator against the server's 10/min limiter.
2026-07-30 12:45:26 +02:00
Yaojia Wang
c4f8b5b47f feat(ios): unblock device builds — dev team, push-entitlements env switch, mic/speech/background keys
DEVELOPMENT_TEAM=C738Z66SRW + target-level CODE_SIGN_IDENTITY (XcodeGen's
settingPreset injects the deprecated "iPhone Developer" at target level, so a
project-level override cannot win). Device build now BUILD SUCCEEDED on the free
personal team with an on-the-fly 7-day profile.

aps-environment lives in a new WebTerm.entitlements that is NOT attached by
default: CODE_SIGN_ENTITLEMENTS expands ${WEBTERM_PUSH_ENTITLEMENTS}, empty
unless set at xcodegen time. Proven both ways — attaching it on a free team
fails with "Personal development teams ... do not support the Push
Notifications capability".

Also pins SwiftTerm to exactVersion 1.13.0: the floating "from: 1.13.0" now
resolves to 1.15.0, whose new public hasActiveSelection collides with the local
one in TerminalScreen.swift:328 and broke every generate+build.
2026-07-30 12:45:08 +02:00
Yaojia Wang
c1612d0145 build(android): make release buildable and instrumented testing possible
Four gaps that made "ship it" and "test it on hardware" impossible.

RELEASE SIGNING. assembleRelease was silently producing
app-release-unsigned.apk because there was no signingConfigs block at all —
the failure mode where you only notice at install time. Credentials now
resolve from keystore.properties, then local.properties, then
WEBTERM_RELEASE_* env; nothing is created or committed, and
keystore.properties.example is the template.

The degradation is ENFORCED, not documented. The signingConfig is only
created when credentials actually resolve — an empty config is precisely what
makes AGP emit an unsigned artifact quietly — and a guard on packageRelease
throws with an actionable message, distinguishing "not configured" from
"credentials found but the keystore file is missing: <path>". It hooks the
PACKAGE task on purpose: R8 has already run and reported by then, so a
developer without a keystore still gets the full shrink result rather than an
early abort. Verified: minifyReleaseWithR8 completes, packageRelease fails
loudly, and no unsigned APK is written.

VERSIONING. Was still the scaffold 1 / "0.1.0". Now 1 / "0.1.0-alpha01",
where the suffix is a factual claim about device verification rather than
decoration: alpha means device QA is essentially unstarted, beta means the
A34/A35 checklist blocks pass on hardware, and plain 0.1.0 means the whole
checklist is ticked. versionCode stays 1 because no artifact has ever left
the build machine.

MINIFICATION. release had isMinifyEnabled = false and an empty rules file —
so nothing had ever exercised R8 on this app, and the first release build
would have been the experiment. Now minify + resource shrinking, with a
deliberately short rules file: the actual artifacts were checked, and
kotlinx.serialization, Tink, Hilt, FCM, OkHttp, Compose and CameraX all ship
their own consumer rules, so none are re-declared. The termux packages are
kept whole because the JitPack artifacts ship no rules and :terminal-view
binds to non-API internals — it writes the public TerminalView.mEmulator
field and calls TerminalRenderer.getFontWidth()/getFontLineSpacing(). Getting
that wrong yields a build that crashes only in production.
lintVitalRelease also passes now, the two pre-existing lint ERRORS having
been fixed earlier this session.

ANDROID TEST ENABLEMENT. :app had no androidTest source set and no
instrumentation runner, which is the mechanical reason A34/A35 have no code
at all. The runner (Hilt-aware) and dependencies are wired, and a
:macrobenchmark module exists. The tests themselves are a separate change.

Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:app:assembleBenchmark :macrobenchmark:assembleBenchmark
:app:lintVitalRelease koverVerify -> BUILD SUCCESSFUL; 901 JVM tests, 0
failures on a forced re-execution with test-results deleted and
--no-build-cache.
2026-07-30 12:14:53 +02:00
Yaojia Wang
b09b90e5bb fix(android): the ThumbnailPipeline bugs its skipped review would have caught
This file's cross-review was explicitly skipped when it was written —
PROGRESS_ANDROID.md says so — and it had no production call sites until this
session, so nothing had ever reviewed OR executed it. Wiring it to the home
screen finally made an adversarial concurrency review worthwhile. It confirmed
the hard parts are right (the check-then-insert dedup critical section,
withPermit's exactly-once release with the fallback outside it, the
NonCancellable finally, and rethrowing CancellationException) and then found
five real defects. Every premise was re-verified from the termux v0.118.0
bytecode before fixing.

1. Thumbnails drew DUPLICATED ROWS. TerminalBuffer.externalToInternalRow is
   `(mScreenFirstRow + row) % mTotalRows` and mTotalRows is the constructor's
   transcriptRows, so passing TERMINAL_TRANSCRIPT_ROWS_MIN (100) while rows
   went up to 200 aliased external rows 100..199 onto 0..99 — and left the
   emulator's scroll state invalid during append(). A pure ThumbnailGrid now
   derives transcriptRows = max(rows, MIN).

2. A legitimately-sized session was LMK bait. cols/rows are server-supplied
   and the protocol validates resize only to [1,1000], so a 1000x1000 session
   forced 2800x2800 ARGB_8888 = 31.4 MB, twice over for two permits, every
   poll tick. Rather than clamp the grid — which would CROP a real screen —
   the cell size is now derived from the target, shrinking the font's own cell
   by an exact integer fraction with the aspect preserved. Any geometry in
   1..1000 now yields at most 480x480 = 900 KiB; a real 161x50 goes from
   3.1 MB to 322 KiB.

3. Two stalled fetches wedged the feature PERMANENTLY. There is no OkHttp
   callTimeout (the per-socket read timeout is reset by every trickled byte)
   and the render is deliberately uncancellable by any caller, so two
   slow-trickle previews held both permits until close() and no thumbnail
   rendered again. A withTimeout now sits inside withPermit, budgeting the
   work rather than the permit wait. The subtlety that makes this correct:
   TimeoutCancellationException IS a CancellationException, so it is caught
   BEFORE that branch and routed to the placeholder — otherwise a slow host
   would still kill the pipeline instead of degrading.

4. The in-flight entry was leakable, contradicting a comment that claimed it
   never was. `scope.async` on an already-cancelled scope never runs its body,
   so the finally never ran: a post-close() request left a dead Deferred that
   every later request awaited forever, and inFlight grew unbounded. Now
   guarded inside the mutex, and the comment names the one path that genuinely
   cannot release (where the map dies with the pipeline anyway).

5. Obsolete renders did guaranteed wasted work. lastOutputAt advances on every
   PTY byte and the list polls every 5s, so an active session mints a new key
   each tick; the row cancelled the old caller but the old render ran a full
   GET plus a full raster whose bitmap was cached under a key nothing would
   request again — occupying both permits ahead of the key actually on screen,
   and growing without bound once render latency exceeded arrival rate. A
   newer key now cancels the older in-flight render for that session.

Also made two false statements true rather than leaving them: retainOnly now
prunes under the same monitor publish() takes, so a render landing after the
prune cannot resurrect a killed session's bitmap; and PreviewCap's KDoc no
longer claims to prevent an oversized-body OOM (the body is already buffered
and parsed by then) nor to "mirror the server's cap" (the server defaults to
24 KiB, not 256 KiB, and an operator can raise it unbounded).

RED was observed for all four behavioural fixes before implementing.

Verified: ./gradlew test :app:testDebugUnitTest :app:assembleDebug koverVerify
-> BUILD SUCCESSFUL, 901 JVM tests, 0 failures (893 -> 901). The agent
distrusted a FROM-CACHE green, forced a real execution, and confirmed the code
reached the packaged APK by scanning its dex.
2026-07-30 12:09:25 +02:00
Yaojia Wang
3ad8d8beba docs(progress): log the round-2 tmux target prefix-matching hole + dual clocks 2026-07-30 11:48:32 +02:00
Yaojia Wang
a6cf547f04 Merge orphan-review-round2: exact tmux targets, dual activity clocks 2026-07-30 11:47:11 +02:00
Yaojia Wang
d39a0ab8d1 fix(sessions): tmux -t prefix-matching let orphan ops reach the user's own sessions
Round two of adversarial review. The skeptic confirmed the session_activity finding
with extra evidence from this host and raised three things the first pass missed.

SECURITY — `tmux -t <name>` resolves exact name, then NAME PREFIX, then fnmatch.
The whole module rests on "we only ever touch `web_` + a UUID v4, so a tmux session
the user made for their own work is never enumerated and never offered for
deletion". Prefix matching goes around that: a session named `web_<uuid>_mine` is
refused by parseSessionList (its suffix is not a UUID) and so never appears in the
listing — but `-t web_<uuid>` matches it. So a DELETE aimed at an id that does not
exist would end the user's session, and a preview would print its screen.

Verified on tmux 3.6a, isolated socket:
  has-session -t web_<uuid>    → succeeds against web_<uuid>_MINE
  has-session -t =web_<uuid>   → "can't find session"
  capture-pane -p -t web_<uuid>   → printed the DECOY's screen
  capture-pane -p -t =web_<uuid>: → "can't find session"

Two target forms are needed, because `=` only qualifies the session part of a
target: session targets (has-session, kill-session) take `=name`, while
capture-pane takes a PANE and rejects a bare `=name` outright ("can't find pane"),
so it needs `=name:`. Both are now the only way a name reaches tmux, with an
integration test that stands up a real decoy and asserts the listing omits it and
both the preview and the DELETE report 404 while it stays alive.

CORRECTNESS — take max(window_activity, session_activity), not window_activity
alone. The previous commit swapped one clock for the other, but they are not
ordered: window_activity tracks pane output while an attach with no output bumps
only session_activity. On this host one session's session_activity was 4 s NEWER
than its window_activity (and another's window_activity was 25 days newer). The
question is "has anything happened here", so the answer is the later of the two.
Also documents the caveat that window_activity resolves against the session's
current window only — exact for the single-window sessions this app creates, and
bounded by the max for anything else.

CORRECTNESS — parseSessionList accepted an empty numeric field. Number('') is 0
and 0 is finite, so a blank clock parsed as "created at the epoch, idle ever
since": instantly eligible for the idle cleanup. Fields must now be actual digits.

SAFETY — bulk cleanup re-reads the world immediately before killing. The candidate
list is a snapshot and the kill loop takes time, so a session could be attached, or
adopted into the table, inside that window. This is the irreversible path; it now
verifies each candidate against a fresh listing rather than trusting the snapshot.

PERFORMANCE — captureOrphan no longer probes with hasSession first. That was a
second tmux spawn per thumbnail, and a SYNCHRONOUS one on the path a whole grid
refreshes, buying nothing: capture-pane already fails cleanly on a missing session
and we already turn that into null. The guard splits into a pure half
(mayActOnOrphan: UUID shape + not in the table) used by both paths, and the
existence probe, which only the destructive path needs.

Tests: 2213 unit, 9 orphan integration (incl. the decoy regression). Confirmed
`has-session -t =<name>` still resolves the 69 real sessions, so the Case 3.5
re-attach path is unaffected.

Note: test/integration/server.test.ts "H1 shell state survives a server restart"
flakes ~1 run in 3 when all 27 run together on a loaded machine, and passes 4/4 in
isolation. That is the pre-existing real-PTY timing flake already recorded in
PROGRESS_LOG, not this change — it exercises Case 3.5, which is verified above.
2026-07-30 11:46:51 +02:00
Yaojia Wang
2bfc76b397 docs(progress): log the orphan tmux session feature + the session_activity clock bug 2026-07-30 11:30:24 +02:00
Yaojia Wang
22a7929a7b Merge orphan-tmux-sessions: surface and manage untracked tmux sessions 2026-07-30 11:28:56 +02:00
Yaojia Wang
f6ef19ebf6 fix(sessions): orphan cleanup was reading the wrong tmux clock
Adversarial review of the previous commit found ten defects. The critical one
would have destroyed exactly the work this feature exists to protect.

CRITICAL — `#{session_activity}` is a CLIENT clock, not an output clock.
It advances on attach and on keypresses, and never moves for a detached session
however much its shell is printing. Measured on tmux 3.6a: a detached session
running `while true; do echo tick; sleep 1; done` still reported
session_activity == session_created after 6 s, while `#{window_activity}` tracked
the output exactly. So "idle for 7 days" was really "nobody has typed for 7 days",
and `Clean up idle 7d+` would have killed long-running unattended sessions that
were busy working — the walk-away case the whole app is built around.
Measured against this host's 69 sessions: the old clock condemned 36, the new one
condemns 33, so three sessions doing real work were one click from deletion.
Now reads window_activity, with a test that fails if the format string regresses.

HIGH — no timeout on any tmux call. maxBuffer bounds a flood but nothing bounded
the wait, and a synchronous exec cannot be interrupted by the event loop, so a
tmux server stopped mid-syscall hung the whole server permanently. All five calls
(including the three that predate this feature) now carry one.

HIGH — listSessions and capturePane were synchronous on a POLLED path. Measured
73 ms and 58 ms per call on this host; every home screen on every device refreshes
on a 5 s timer, and each of those was 73 ms with the event loop stopped, i.e.
73 ms in which no PTY byte reached any terminal anywhere. Both are async now, so
the manager's listOrphans/captureOrphan/killOrphansIdleSince are too. The server
is a byte-shuttle first: nothing polled may block it.

HIGH — GET /orphan-sessions had no rate limiter although, unlike every other read
route, it spawns a subprocess. It shares the preview bucket now.

HIGH — the cleanup confirmation counted CARDS, and the grid is capped at 24 while
the cleanup matches across every session the server enumerates. On this host it
would have said "24" and ended 33 shells. New GET /orphan-sessions/count-idle
answers the real question, and the dialog states that number, or refuses to
proceed if it cannot be determined.

MEDIUM — fetchOrphanSessions collapsed every failure into [], so one 429 or 500
read as "all recovered sessions are gone", tearing down every mounted xterm and
rebuilding it on the next success. It returns null for failure now, and the
refresh leaves the section untouched.

MEDIUM — the preview latch was "card was created", not "preview succeeded", so a
card that lost its single request stayed blank forever. Latches on success.

MEDIUM — overlapping refreshes. refresh() is entered from a 5 s timer, from
killOrphan and from cleanup, with awaits inside and no guard, so a slow poll could
resurrect cards for sessions already killed or re-mount them into a root that
setVisible(false) had just cleared. A generation counter, bumped on teardown and
checked after every await, invalidates stale runs.

MEDIUM — renderPreview could write to a disposed xterm (it throws "Object has
been disposed"). PreviewCard carries an explicit `disposed` flag set by a new
disposeCard() helper, so no caller can dispose a terminal and forget the flag.
Deliberately NOT el.isConnected: a card not yet appended is alive, and two
existing preview-grid tests correctly caught that conflation.

LOW — kill and cleanup discarded their results, so a refusal (403 behind a proxy
whose Origin differs, 404 on a lost race) looked like a dead button. Both report.

Tests: 2209 unit, 27 e2e, 8 orphan integration. Verified live against all 69 real
sessions with none harmed; the window_activity fix confirmed on an isolated tmux
socket (-L) so the developer's sessions were never involved.
2026-07-30 11:28:39 +02:00
Yaojia Wang
980dbaa928 feat(android): wire the dead code, render the git panel, make the token gate real
Four agents' worth of :app wiring. Everything here existed as tested code with
zero production call sites, or as a screen rendering a shape the server stopped
sending months ago.

Dead code that shipped as features. All three were fully implemented and
unit-tested while being reachable from nothing:

  - Session-list preview thumbnails. SessionsHome called SessionListScreen
    without the `thumbnails` argument, so the `= null` default won and every
    row rendered a placeholder forever — on the home session chooser, whose
    entire purpose is seeing what each session is doing.
  - Quick-reply chips and their palette editor.
  - PointerContextMenu (large-screen secondary click).

ThumbnailPipeline's formal cross-review had been explicitly skipped when it was
written — PROGRESS_ANDROID.md said so in as many words, and it is concurrency
code, so nothing had ever reviewed OR executed it. It has now been reviewed and
kept: the LruCache key, the fair Semaphore(2), the Mutex-guarded in-flight
dedup and the NonCancellable in-flight release are all sound, and a retained
map entry would have poisoned a key forever.

The v0.6 project-detail git panel. Android rendered the pre-w6 shape: one bare
dirty dot, a bare hash+subject commit row, and a worktree heading that renamed
itself to "Branch" at n=1 — the exact behaviour G5 removed. Now the sync band,
unpushed marking with a single upstream boundary, and "Worktrees (n)". The rule
that drives it is enforced, not documented: exactly one state may render green,
and no-upstream is not that state.

Remote approvals are no longer blind. status.preview reaches GateBanner and
PlanGateSheet, so the user can see the command or diff they are approving
instead of a tool name. Absent preview stays an ordinary gate, not an error.

The WEBTERM_TOKEN gate is no longer inert. NetworkModule was calling
OkHttpClientFactory.create(identityProvider) with no cookieJar, so the shared
client ran on CookieJar.NO_COOKIES and none of the cookie plumbing did
anything. It now installs the real AuthCookieJar — one client, so the cookie
rides the WS upgrade too — backed by the Tink-sealed store, and binds
InMemoryAuthCookieStore if a cipher cannot be constructed rather than ever
persisting a shell credential in the clear. The persister runs on an IO scope
because OkHttp calls a CookieJar synchronously and must never block on
DataStore or Tink.

And the user-facing half that made the gate unusable: a token-gated host
answers the pairing probe's unauthenticated GET with 401, which the frozen
taxonomy could only report as "this port is running something else" — so such
a host was impossible to pair AND the copy was misleading. Pairing now routes
to a token prompt and resumes through the same cert-gate choke point. The
token is a parameter, never a field and never part of any UI state.

Docs corrected rather than left aspirational: the plan asserted a cleartext
allowlist "permitted only for private LAN/Tailscale CIDRs", which the platform
cannot express at all — the network-security-config format has no netmask or
prefix syntax. PROGRESS_ANDROID.md now opens with what "COMPLETE" actually
meant, and PROGRESS_LOG.md records the whole pass including what is still
undone: A34/A35 have no code, and device QA remains ~0%.

Verified: ./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify
-> BUILD SUCCESSFUL, 893 JVM tests, 0 failures (baseline was 617). The :app
run was re-measured with outputs deleted and --no-build-cache, and the APK
re-packaged, so Hilt graph validity is an observation rather than an
up-to-date claim.
2026-07-30 11:23:23 +02:00
Yaojia Wang
4892fa7b49 feat(sessions): surface tmux sessions the server does not track
A `web_*` tmux session that outlives the process which created it was unreachable
from the UI, permanently. Nothing enumerated tmux — src/session/tmux.ts had
hasSession but no list — so recovery only worked if a client still had the id in
localStorage. list() walks the in-memory table, and so does reapIdle, so such a
session could not be listed, previewed, joined or killed, and never aged out.

On this host that was 69 tmux sessions with 5 clients: 64 unreachable, the oldest
from 26 Jun, one of them still running a Claude Code session with 7 agents.

This is the "catalogue" approach: make them visible and actionable WITHOUT
adopting them. Nothing is attached, because attaching makes tmux resize the window
to the new client's dimensions and SIGWINCH whatever is running inside — doing
that to dozens of live TUIs at startup is exactly the outcome to avoid. Adoption
stays an explicit user action: opening an orphan re-attaches by id through the
existing re-attach path, and it becomes an ordinary live session.

  src/session/tmux.ts   listSessions() + parseSessionList() (pure, so the filter
                        rules are unit-testable) and capturePane(), which reads a
                        session's current screen via `capture-pane -p -e` — no
                        client, no resize, colour preserved.
  src/session/manager.ts listOrphans/captureOrphan/killOrphan/killOrphansIdleSince
  src/server.ts         GET /orphan-sessions, GET /orphan-sessions/:id/preview,
                        DELETE /orphan-sessions/:id, DELETE /orphan-sessions
                        ?idleDays=N
  public/               a "Recoverable" section under the home grid, reusing the
                        existing card/thumbnail machinery via orphanAsLive()

Guards, all enforced in the manager so no route can forget one:
- the tmux name must be `web_` + a UUID v4 (SESSION_ID_RE, the same gate the
  attach protocol applies). This is what stops a hostile or malformed name from
  reaching `tmux -t` — a literal `-t`, or path traversal — and it means a tmux
  session the user created for their own work is never enumerated, never
  previewed and never offered for deletion.
- an id the table already owns is refused everywhere: killing it via tmux would
  end the shell behind a live PTY's back and leave a zombie table entry. Those go
  through killById.
- every function is inert when cfg.useTmux is off, so the non-tmux deployment is
  byte-for-byte unchanged.
- bulk cleanup skips ATTACHED sessions and requires idleDays >= 1: there is no
  "delete everything" form of the route. "This server does not track it" is not
  evidence that it is abandoned — a plain `tmux attach` and a second server
  process both report as attached.
- reads carry no Origin guard (same threat model as /live-sessions); both DELETEs
  do. Preview gets its own rate-limit bucket since each call spawns a process.

Two things the live run exposed and this fixes: previews were loading
sequentially (69 tmux spawns per 5 s refresh) — now once per card, fire-and-forget
— and the grid is capped at 24 cards because each thumbnail is an xterm instance.
The remainder is stated in the UI with the tmux command to reach it, never
silently truncated.

Separate wire type from LiveSessionInfo on purpose: an orphan supports none of the
live-session operations, and the Android/iOS clients decode /live-sessions, so a
variant shape there would break them.

Tests: 2203 unit (+42: parse/filter rules, all four manager guards, tmux-off
inertness) and 8 integration against real tmux — create a throwaway session,
list it, capture it while asserting it stays unattached, reject non-UUID ids,
reject a foreign Origin, kill it, 404 the second time. The integration file never
issues the bulk DELETE: it runs against the host's real tmux server and would end
the developer's own sessions. Verified live against all 69 with none harmed.
2026-07-30 10:40:10 +02:00
Yaojia Wang
35d32f4670 feat(android): decode the v0.6 git-panel surface
The server shipped the project-detail git panel in 8fe1f52/f711db9/042c4cd,
all after the last Android commit, so the client silently ignored every new
field (they are additive and optional, so nothing crashed — it just rendered
the pre-w6 shape: one bare dirty dot and no sync information at all).

Added: SyncState + ProjectDetail.sync/dirtyCount, ProjectInfo.dirtyCount,
ProjectSessionRef.cwd, GitLogResult.upstream, CommitLogEntry.unpushed, plus
GET /projects/worktree/state (read) and POST /projects/git/fetch (write).

Two rules from the server's own commit message are encoded as behaviour
rather than left to each call site to remember:

  `SyncState.isFullySynced(now)` is the only sanctioned way to ask whether
  the green all-clear may be rendered, and it answers false for anything
  unknown. ahead/behind compare against @{u}, a locally cached ref that only
  a fetch moves, so `behind = 0` from a stale FETCH_HEAD proves nothing. Most
  importantly NO UPSTREAM leaves both counts undefined — which is not zero —
  and that is the normal state of a fresh worktree branch. The server calls
  that fall-through the easiest bug to ship here; there is a test per way it
  could regress, including a fetch timestamp in the future, which is clock
  skew rather than evidence that anything was checked.

  `GitLogResult.upstreamBoundaryIndex` finds the LAST unpushed commit rather
  than assuming the unpushed ones come first. git log is date-ordered, so
  merging an older branch interleaves unpushed commits below pushed ones, and
  the row-count shortcut fails in the dangerous direction — it would label an
  unpushed commit as pushed. It returns null, not -1, when no boundary may be
  drawn, so "no upstream" cannot be mistaken for "boundary before everything".

Origin policy is asserted in both directions: worktree/state carries no
Origin, git/fetch carries a byte-equal one, and a test also asserts the fetch
body contains no remote or refspec — the remote is derived server-side
precisely so a client cannot aim a fetch at an arbitrary URL. Fetch has its
own rate-limit bucket, so a 429 is surfaced rather than retried, and a failed
fetch leaves lastFetchMs unknown so the UI keeps saying "stale" instead of
pretending it refreshed.

Verified: :api-client:test + koverVerify green; 22 new tests.
2026-07-30 09:45:59 +02:00
Yaojia Wang
3fe719f874 feat(android): decode the two server frames the client was throwing away
Both had shipped on the server for a while and Android silently discarded
them, which is worse than not supporting them — the UI looked correct while
missing information the user needed.

W2 `queue`: `ServerMessageType` had no member for it, so `decodeServer`
returned null and `SessionEngine.onFrame` counted it as undecodable and
dropped it. The "N queued" badge could therefore never appear on Android,
on any device, ever. Adding the branch made the compiler flag the
non-exhaustive `when` in the engine, which is the same gap seen from the
other side. A negative depth drops the frame rather than feeding nonsense
to a badge; the next broadcast carries the real value.

W1 `status.preview`: not modelled at all, so a remote approval was BLIND —
the gate could show that `Bash` wanted to run, but not what it wanted to
run, and the same for an Edit's diff. Approving blind defeats the point of
the remote gate, which exists so whoever is holding the phone can make an
informed call. `ApprovalPreview` now models both variants and
`GateState.preview` carries it to the UI.

Two decode rules are load-bearing and pinned by tests:

  - A broken preview must never cost the FRAME. `pending` is what makes the
    gate appear at all, so trading a missing preview for a missing gate
    would be a bad bargain. Every malformed shape — null, non-object, no
    kind, unknown kind, command without text, diff without a file — degrades
    the preview to null and still yields the Status.
  - Unknown values are kept, not coerced. A diff line kind or file status
    this client has never seen renders as itself; dropping a line we cannot
    classify would hide part of what is being approved. Only a diff file
    missing its paths is dropped, because then the preview cannot be trusted
    to describe the right file.

A sustained pending refreshes the preview without minting a new epoch: the
server's bounded render can arrive a frame after the gate rises, and a
decision tapped in between has to stay valid.

The preview's diff subset lives in :wire-protocol rather than reusing the
:app diff model — :session-core may not depend on :app, and new wire types
belong in the frozen contract module (CLAUDE.md).

Verified: :wire-protocol:test :session-core:test + both koverVerify green;
16 new tests (10 codec, 6 engine-level).
2026-07-30 09:39:50 +02:00
Yaojia Wang
0de5557921 docs(ios): freeze ios-completion coordination contracts (token/git-panel/signing) 2026-07-30 09:19:31 +02:00
Yaojia Wang
3e5cfdc1cf fix(android): make the terminal actually usable on a device
The client built to an APK and 617 JVM tests passed, but nothing had ever
run on hardware, and three defects made it unusable the moment it did. All
three were invisible to the JVM suite because none of those tests
instantiate an Android View.

1. Every key press crashed. `RemoteTerminalView` bound the forked emulator
   but never installed a `TerminalViewClient`, and the stock Termux view
   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`). Installing any client is not enough:
   returning false continues into `mTermSession.write()` at offset 150, and
   `mTermSession` is null forever because `TerminalSession` is final and
   forking a process is what this app must not do. So the client consumes
   the key itself and `KeyRouting` has no defer-to-stock branch at all —
   the crash is unrepresentable, not merely avoided. Termux's `KeyHandler`
   stays the authority for the key tables, so DECCKM still emits `ESC O A`.

2. The PTY never learned the real size. Nothing called
   `RemoteTerminalSession.updateSize`, and the stock fallback is dead
   (`TerminalView.updateSize` early-returns without a session), so every
   full-screen TUI rendered into 80x24. `TerminalResizeDriver` now drives
   it from real cell metrics — read through the public `getFontWidth()` /
   `getFontLineSpacing()` accessors rather than reflection, which would
   break silently under R8, or a re-measured Paint, which would drift from
   what the renderer actually draws (plan risk R5).

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://. The format has no CIDR syntax, so the allowlist
   its own comment promised cannot be written; cleartext is permitted in
   base-config, the tunnel domain keeps a TLS block, trust anchors are
   pinned to system-only, and the guard moves to the §5.4 pairing tiers.

Adversarial review then found three more, and one it got wrong:

  - Swipe-scrolling inside an alternate-screen TUI still crashed. The touch
    path bypasses `TerminalViewClient` entirely: `doScroll` turns a scroll
    into `handleKeyCode` whenever the alternate buffer is active, and that
    dereferences `mTermSession`. Claude Code is an alternate-screen TUI, so
    this was a crash during normal use on the app's main screen.
    `TerminalScrollGesture` 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 it.
    The subtree is excluded from the autofill structure.
  - The review asked for stock text selection to be restored. It cannot be:
    the ActionMode's own Copy and Paste handlers dereference the absent
    session, so a reachable selection UI has an NPE on its Copy button.
    Long press stays consumed and copy-out is recorded as a feature gap.

Also: the WEBTERM_TOKEN cookie is carried on REST and the WS upgrade and
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 off Google Drive.

Verified: ./gradlew test :app:assembleDebug -> 757 JVM tests, 0 failures.
Nothing here is device-verified yet; that is the next step.
2026-07-30 08:59:01 +02:00
Yaojia Wang
ffc185aa2d fix(desktop): stop shipping node-gyp, which broke codesign
`npm run dist:mac` failed at the signing step:

  codesign --timestamp .../node_modules/node-gyp/gyp/pylib/gyp/generator/\
    __pycache__/make.cpython-313.pyc
  → "A timestamp was expected but was not found."

node-gyp only COMPILES native modules. Nothing loads it at runtime — node-pty
resolves its prebuilt .node directly, and a grep over the packaged tree finds no
require of it outside the package itself. But it vendors a Python tree, and
`npmRebuild: true` runs it and leaves __pycache__/*.pyc behind. Those got copied
into Contents/Resources and codesign then tried to sign Python bytecode, which
takes the entire build down.

Excluding it removes both the dead weight (5 MB) and the failure surface. Also
excludes **/__pycache__ outright, so no Python bytecode can reach the bundle by
another route.

Verified on a real build: signature verifies --strict, node-gyp absent, zero .pyc
in the bundle, public/style.css and build/main.js byte-identical to the repo,
express/ws/web-push/qrcode/google-auth-library all still resolve from the
packaged tree, and node-pty's Electron-ABI pty.node is in place.

Note: the immediate failure was transient — a plain retry of the same config
succeeded (Apple's timestamp server). So this is the durable fix for a landmine
that would otherwise re-arm on every npmRebuild, not the cause of that one run.

Deviation from the one-worktree-per-session rule: the desktop build packages from
desktop/node_modules and its script does `cd .. && npm run build`, so it can only
be exercised in the main checkout. Committed on develop directly.
2026-07-30 08:27:25 +02:00
Yaojia Wang
029bec831a docs(progress): log the design-fidelity pass + the stale-desktop-bundle diagnosis 2026-07-30 07:59:56 +02:00
Yaojia Wang
e506d0dbbf Merge panel-fidelity-leftovers: last four design gaps + readable projects.ts diffs 2026-07-30 07:57:19 +02:00
Yaojia Wang
dba83559d3 fix(projects): close the last four git-panel design gaps
The low-severity leftovers from the panel audit, plus the tooling trap that made
reviewing this work harder than it needed to be.

.gitattributes (new) — `public/projects.ts diff`
  That file embeds literal NUL bytes on purpose: the namespace sentinels
  '\0active' / '\0other' are prefixed so they cannot collide with a real
  `First.Second` key. git therefore classified it as binary and printed
  "Binary files differ" for every diff, and `git diff --stat` reported
  "Bin 66815 -> 68701 bytes" instead of line counts — the file was effectively
  unreviewable. The `diff` attribute makes git diff it as text without setting
  `text`, so no line endings are normalised and the bytes on disk are untouched.
  (Same trap in the shell: grep prints only "Binary file ... matches", which
  reads as a no-match. The comment in the file says to use grep -a.)

Detached HEAD now shows its short sha
  The Head cell held the "detached HEAD" chip and nothing else: it said what was
  wrong and nothing about where you were. SyncState gains an optional `head`,
  populated from the .git/HEAD text readSyncState ALREADY reads for the detached
  test — so this costs no extra I/O and no extra git call. Guarded by a hex regex
  and rendered as textContent.

One geometry for the ghost button role
  The design defines two button roles. The outline one had drifted to three
  different sizes (.proj-wt-open at 11.5px/4px 12px/6px, .proj-diff-toggle at
  12.5px/7px 15px/7px) and both used full-strength --text instead of the muted
  label. All four outline buttons are now declared once, together, at the
  design's geometry; the duplicate .proj-diff-toggle block further down the file
  is gone, since a second block at equal specificity is exactly how the role
  drifted in the first place.

Header branch chip joins the chip language
  .proj-branch was the only chip with no border and no mono face, and 1px tighter
  all round, so the detail header's branch did not match the branch chips in the
  worktree list right below it.

"Changes" heading over the View Diff toggle
  Every other block on the page is introduced by a section heading. Without one
  the toggle hung 4px off the bottom of the sync band and read as part of it.

Tests: 2167 unit (4 new — detached sha present/absent on both the server and the
panel) and 27 integration, twice.
2026-07-30 07:57:00 +02:00
Yaojia Wang
a25fe30f1a Merge panel-design-fidelity: close the git-panel design gaps 2026-07-29 20:47:38 +02:00
Yaojia Wang
950d2298a1 fix(projects): close the design gaps in the git panel
An audit of the project-detail panel against docs/mockups/project-detail-git.html
turned up 59 differences. The ones that changed what you see:

Controls that had class names but no rules, so they rendered as raw OS widgets:
- .proj-wt-form had no layout at all — the branch input and Create Worktree
  button sat flush together, and the button jumped to its own line the moment a
  validation error appeared between them.
- .proj-wt-error / .proj-wt-actions-error had no rule, so a validation failure
  rendered as 16px near-white body text, indistinguishable from content.
- .proj-wt-remove (the worktree ✕) and .proj-wt-prune had no rule either.
- .proj-fanout-mode never reset `appearance`, so WebKit kept its own light
  dropdown — the one control in the panel that stayed a native grey widget.
- --danger was never defined, so every validation message fell back to an
  off-palette red.

Cascade bugs — a later rule at equal specificity was silently winning:
- .proj-wt-row-current's amber wash and border were completely dead. "You are
  here" rendered identically to every other row.
- .proj-wt-branch inherited the 16px user-agent default in the UI sans, making a
  branch name the largest text on the page. Now 13.5px mono.

Layout:
- .proj-syncband dropped flex-wrap. Wrapping produced ragged half-empty rows
  between 721px and 1000px — at 900px the Fetch button dropped alone onto a
  second row. One cell now absorbs the shrink so the band stays a single row
  down to the 720px column break.
- Commit rows are a 4-column grid, not flex, and the ↑ mark cell is rendered on
  every row instead of only unpushed ones. Previously no two lines agreed on
  where the sha started, so the unpushed group did not read as a group.
- Pushed commit subjects step down to --text-dim, which is what makes the
  upstream boundary mean anything.

Honesty of the sync band — the design's two hard rules:
- Green "✓ in sync" was reachable when ahead/behind were UNKNOWN, because
  `?? 0` folded undefined into zero. Unknown counts now render "↑ —" / "↓ —"
  and are marked unverified. Green requires real zeros and a fresh fetch.
- A stale ↓ painted the digit warning-red. Since a missing FETCH_HEAD is the
  default state, the largest glyph in the band was almost always red and the
  panel read as "this repo is broken". The doubt now sits on the `unverified`
  chip and the footnote, never on the number.

Also: semantic tokens (--sunk/--warn/--ok/--dirty/--mono-font) replacing hex
literals, one chip language across the panel, section headings as tracked caps
with the count as a separate lighter element, and a ≤720px rule that drops the
age column — placed after the base rule, since a media query adds no specificity.

Verified in a real browser at 1280/1000/900/780/721/600/420px: the sync band
holds one row down to 721px and stacks below it, commit columns align on every
row, and nothing overflows or scrolls the page horizontally.
2026-07-29 20:35:07 +02:00
Yaojia Wang
c3613c2fae Merge wt-path-alignment: path sits under its branch, not across the card 2026-07-29 18:43:18 +02:00
Yaojia Wang
609e7cea69 fix(projects): stop stranding the worktree path across the card
margin-left:auto was correct for the old single-line row, where it pushed the
path to the far right of a flex row. The two-line card stacks branch over path
in a column, so the same declaration shoved the path to the opposite edge from
the branch it labels. Removed, with a note saying why it must not come back.

Same shape as the .proj-wt-row duplicate earlier in this series: an element
reused in a new layout kept a positioning declaration that only made sense in
the old one. Caught by screenshot; the DOM assertions could not see it.
2026-07-29 18:43:05 +02:00
Yaojia Wang
2066631c0a Merge projects-control-styling: dark-theme controls, real theme tokens 2026-07-29 18:39:44 +02:00
Yaojia Wang
3c0b2f6bae fix(projects): style the panel controls and use the real theme tokens
Two things, both invisible in code review and obvious on screen.

1. Eight controls in the project detail had class names but no CSS rule at
   all, so they rendered as raw user-agent widgets — white input boxes and grey
   buttons sitting inside a dark panel: View Diff, the new-branch input, Create
   Worktree, and the five fan-out fields. Gave them the skin the panel already
   established, with two button roles: filled accent for the control that
   commits a form (matching Fetch), outline for one that only reveals or
   toggles (matching Open). Layout stays in each element's existing rule; this
   block sets only the shared skin, so no property is declared twice.

2. The w6 CSS referenced --fg, --fg-dim, --fg-faint, --bg-sunk and --mono,
   none of which exist in :root. Every one of those 19 references was silently
   falling through to its hardcoded fallback, which happened to match the
   default amber theme — so it looked correct while being wired to nothing.
   Under any other theme the sync band would have kept the default colours
   while the rest of the app changed. Rewired to the real tokens: --text,
   --text-dim, --text-faint, --surface-1, --on-accent, and the mono stack the
   file already uses.

The fallback values are what hid this: a var() fallback turns a typo into a
silent success. Worth remembering when reaching for one.

Verified: tsc clean, npm test green (78 files / 2161, e2e 27), bundle rebuilt.
2026-07-29 18:39:34 +02:00
Yaojia Wang
e004da1a55 Merge git-panel-layout-fixes: right-pinned worktree actions, count on the heading 2026-07-29 18:30:09 +02:00
Yaojia Wang
042c4cd4a9 fix(projects): pin the worktree actions right and put the count on the heading
Two layout defects the screenshots caught that the DOM tests could not.

1. `.proj-wt-row` was declared twice — my two-line-card rule earlier in the
   file, the original flex rule later. Same specificity, so the later copy won
   and the Open button sat against the text instead of at the card's right
   edge. Folded the grid into the ORIGINAL rule and left a note where the
   duplicate was. One rule per selector; a second declaration of a layout
   property is a silent override waiting to happen.

2. The unpushed count rendered as its own line under "Recent commits". The
   design has it ON the heading, because it qualifies that heading rather than
   making a separate statement. renderGitLog now takes the heading element and
   writes the badge there, clearing any previous badge first so a re-render
   cannot stack duplicates.

Both were invisible to the existing assertions: they checked that the elements
exist and carry the right text, which was true in each case. Added a test for
the no-duplicate-badge path; the right-edge alignment is a pure CSS outcome and
is verified by screenshot, not by the suite.
2026-07-29 18:29:53 +02:00
Yaojia Wang
c8d12780b9 Merge git-panel-design-fidelity: match the approved design
Closes the three deviations between the shipped git panel and
docs/mockups/project-detail-git.html: the four-cell sync band with its
explanatory footnotes, the dirty count on the title line, and worktree rows as
two-line cards with a working Open button. Adds structural assertions so
layout drift is caught by the suite, not by eye.
2026-07-29 18:23:02 +02:00
Yaojia Wang
f711db9315 fix(projects): build the git panel the way the design specifies
The shipped panel was functionally right — every number matched git — but it
was not the approved design. I implemented from the plan's semantics (which
state is green, when to flag stale) and let the mock's layout drift, and
nothing caught it: the tests asserted behaviour, and no assertion covered
structure. Three deviations, now closed against docs/mockups/project-detail-git.html.

1. The sync band was compressed into one row of chips. Restored to the four
   labelled cells: Upstream / To push / To pull / Fetch, with display-size
   numerals and a footnote under each count. The footnotes are load-bearing,
   not decoration — "Last fetched 19d ago — this number cannot be trusted" is
   what tells a reader WHY the zero is suspect. Compressed to
   "fetched 19d ago", the causal link was left for the reader to infer, and
   not having to infer it is the entire point of the panel. The unverified
   state is a chip on the count again, so the doubt attaches to the digit.

2. The working-tree count sat inside the band. Moved to the title line beside
   the branch, where the design puts it: the band is about the remote, and the
   dirty count is not. Reads "● 3 uncommitted" instead of a bare dot; the dot
   remains as the fallback when a server sends no count.

3. Worktree rows were the old single line with the path right-aligned against
   the chips. Rebuilt as the two-line card the design calls for — identity and
   state on top, path underneath — plus the Open button, which was specified
   and simply missing, so a worktree row could be read but not entered. Open
   reuses the detail view pointed at the worktree path: a linked worktree is a
   project directory, so this needs no new concept and no new route.

Also added the unpushed count beside the commit-list heading, so the number is
readable before the eye walks the rows hunting for rails.

Tests now assert structure, not just behaviour: cell captions exist, the stale
footnote says why, counts render at display size, the path is outside the chip
row, and Open fires with its worktree. That is the gap that let the drift
through, so it is the gap that is now covered.

Verified: tsc and build clean; npm test green (unit 78 files / 2159, e2e 27).
2026-07-29 18:22:50 +02:00
Yaojia Wang
e81c426329 Merge w6-project-git-panel: ambient git state on the project detail page
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Adds the sync band (upstream, ↑ to push, ↓ to pull, staleness), the unpushed
boundary in the commit list, a reworked worktree panel with per-row state and
session counts, and a read-only POST /projects/git/fetch so the numbers can be
refreshed without a pull.

The governing rule throughout: only one state may look reassuring — even with
upstream and a fetch inside the hour. behind is derived from a locally cached
ref, so a stale zero is a guess and is marked as one; no upstream renders as
'no upstream', never as synced.

Also fixes the test suite's long-standing flakiness (5-11 shifting failures per
run) so the feature could actually be gated on it.
2026-07-29 17:12:36 +02:00
Yaojia Wang
cc811dd18d test: stop the suite flaking on real-git and real-PTY fixtures
`npm test` was failing 5-11 tests per run with the set shifting between runs,
which made it useless as a gate. Two independent causes, neither of them a
product defect.

1. Fixtures had outgrown vitest's 5 s default. The git ones spawn 6-12
   sequential `git` processes (init/config/commit/clone/push); the real-server
   ones boot a server and a shell. Alone they finish easily; under a full
   parallel run they do not, and the failure reads `Test timed out in 5000ms`.
   Gave those describes an explicit 30 s ceiling, and the real-PTY waits a
   named PTY_WAIT_MS. The deliberate short bounds in the "must be rejected"
   handshake tests are left alone — there a timeout IS the assertion.
   (The same rebudgeting also touched the test files in the preceding commit.)

2. test/integration/server.test.ts cannot share the machine with the rest of
   the suite. It asserts on real prompt output within seconds, which is not
   achievable while ~8 workers saturate the box: it passed alone (27/27,
   repeatedly) and flaked in the full run. Raising the numbers further only
   moved the flake around, so this is fixed as a scheduling problem — `npm
   test` now runs two passes, `test:unit` (everything else, parallel) then
   `test:e2e` (that file on its own). `vitest run` still runs everything at
   once for anyone who wants that.

Also bounded the srv.close() in H1's finally. Unbounded, it swallowed whatever
really went wrong in the body — the inner waits threw, control jumped to the
finally, close() blocked, and the case reported a bare timeout instead of its
own diagnosis. The root-causing above only became possible after making that
failure legible.

Note on the `[needs real PTY (sandbox-off)]` labels: those cases were NOT
skipping here. PTY_AVAILABLE is true on this machine, so they were running and
failing on timing, not being gated out by a sandbox.

Verified: npm test green end to end across repeated runs (unit 78 files /
2147 tests, e2e 27).
2026-07-29 17:12:16 +02:00
Yaojia Wang
8fe1f52e5d feat(projects): show real git state on the project detail page
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.

One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:

  - `ahead` needs only local refs and is never flagged.
  - `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
  - 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.
  - No upstream (the normal state of a fresh worktree branch) leaves ahead and
    behind undefined — it renders an explicit `no upstream`, never the green
    path. That fall-through is the easiest bug to ship here.

What landed:

G1  SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
    and .dirtyCount. All additive and optional — the Android and iOS clients
    decode these shapes. The ahead/behind helper already existed for the list
    view; buildProjectDetail had simply never called it.
    Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
    directly, so it returned nothing inside a LINKED worktree, where .git is a
    file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
    the shared common dir (FETCH_HEAD).

G2  POST /projects/git/fetch. Same discipline as push: 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. Touches refs/remotes only — no
    working tree, no index, no merge; it is not a pull. Own rate-limit bucket
    so refreshes cannot eat the budget a real push needs. On failure
    lastFetchMs is left alone, so the UI keeps saying "stale" instead of
    pretending it refreshed.

G3  makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
    dirty count, Fetch button (disabled on a detached HEAD).

G4  The commit list marks unpushed commits and draws the upstream boundary
    once, after the last of them. Marking is server-side from `rev-list`,
    deliberately NOT "the first N rows": `git log` is date-ordered, so merging
    an older branch interleaves unpushed commits BELOW pushed ones, and that
    shortcut fails in the dangerous direction — calling an unpushed commit
    pushed. A regression test builds exactly that backdated-merge shape.

G5  The worktree section is always "Worktrees (n)" (it used to rename itself
    to "Branch" at n=1) and the current row carries its own state chips.

G6  Cost control. The plan called for a .git-mtime cache; that was dropped
    during implementation because a fingerprint over HEAD/index/reflog does
    NOT move when push updates a remote-tracking ref — the cached `ahead`
    would still claim "9 to push" right after a successful push, which is the
    exact lie the feature exists to prevent. Replaced with three measures that
    cannot go stale: in-flight coalescing (N devices watching one repo cost
    one probe, entry dropped as it settles, nothing cached across time),
    skipping the re-render when the payload is byte-identical (this also stops
    the 5 s re-mount of the commit log, two more git spawns per tick), and
    pausing the timer while the document is hidden.

G7  Per-worktree state via GET /projects/worktree/state, kept narrower than
    /projects/detail so N rows do not pay for worktree listing and CLAUDE.md
    reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
    carried no cwd, so sessions could not be attributed to a worktree. Added
    it, plus countSessionsByWorktree, which matches DEEPEST-first because
    .claude/worktrees/<name> lives INSIDE the main checkout and prefix
    matching would count every worktree session against the parent repo too.

Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.

Verified: tsc and build clean; 46 new tests.
2026-07-29 17:12:00 +02:00
377 changed files with 51719 additions and 1139 deletions

12
.gitattributes vendored Normal file
View 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

184
.github/workflows/android.yml vendored Normal file
View File

@@ -0,0 +1,184 @@
# Android CI (F2). Until this file existed the Android client had ZERO CI — 10 modules,
# 691 JVM tests and the whole `WEBTERM_TOKEN` secret-handling wave (Tink AEAD under an
# AndroidKeystore master key, the POST /auth probe, cookie stamping on every route and on
# the WS upgrade) were gated by nothing at all. Modelled on ios.yml: same `on:` shape
# (push + pull_request, path-filtered), same "one job per verification layer" structure,
# and — like ios.yml — deliberately NO `concurrency:` group, so the two workflows behave
# the same way on rapid pushes.
#
# ── HONESTY NOTE (read before trusting a green badge) ────────────────────────────────────
# This repository's only remote is a self-hosted Gitea instance, so GitHub Actions has
# NEVER run here and this file has never been executed by the platform. What IS verified:
# every command below was run locally on macOS against the same Gradle build
# (`./gradlew test :app:assembleDebug koverVerify koverLog` + the androidTest compile),
# and the YAML parses. What is NOT verified: the runner-side pieces — action resolution,
# the preinstalled Android SDK layout on `ubuntu-latest`, and the emulator leg.
#
# ── Why no `src/**` path trigger (ios.yml HAS one) ───────────────────────────────────────
# ios.yml watches `src/**` because it owns `integration-tests`, a Swift layer that boots the
# real Node server and would catch client↔server protocol drift. Android has no equivalent
# leg (android/README.md, "DEFERRED — end-to-end access token": nothing here starts a server
# with WEBTERM_TOKEN set and completes a cookie-gated WS upgrade). Triggering on `src/**`
# would therefore burn runner minutes without checking a single contract. When an Android
# server-contract leg is added, add `src/**` here at the same time.
name: android
on:
push:
paths:
- "android/**"
- ".github/workflows/android.yml"
pull_request:
paths:
- "android/**"
- ".github/workflows/android.yml"
# Manual trigger for the instrumented (device) leg below, which is dispatch-only.
workflow_dispatch:
jobs:
# Layer 1 — everything that runs without a device. This is exactly the green gate
# android/README.md documents ("./gradlew test :app:assembleDebug koverVerify"), plus a
# compile-only pass over the instrumented sources so they cannot silently rot.
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 45
defaults:
run:
working-directory: android
steps:
- uses: actions/checkout@v4
# jvmToolchain(17) is declared by every module; Gradle resolves it from the
# installed JDKs, so 17 must actually be present (the runner image's default may
# be a different major).
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle
# :app/:terminal-view/:host-registry/:client-tls-android compile against SDK 36 with
# build-tools 36.0.0 (android/README.md "Android SDK setup"). The runner image ships an
# Android SDK, but which platforms it holds drifts image to image — so install what the
# build actually needs instead of assuming, and FAIL LOUDLY if there is no sdkmanager.
- name: Install the Android SDK packages this build compiles against
run: |
# `set -eu` WITHOUT pipefail on purpose: `yes | sdkmanager --licenses` kills `yes`
# with SIGPIPE (141) as soon as the prompts stop, and pipefail would report that
# normal ending as a failed step.
set -eu
sdkmanager_bin="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sdkmanager_bin" ]; then
sdkmanager_bin="$(ls -1 "$ANDROID_HOME"/cmdline-tools/*/bin/sdkmanager 2>/dev/null | tail -n 1 || true)"
fi
if [ ! -x "$sdkmanager_bin" ]; then
echo "::error title=No sdkmanager::none found under $ANDROID_HOME/cmdline-tools — the Android SDK is not usable on this runner" >&2
exit 1
fi
yes | "$sdkmanager_bin" --licenses > /dev/null
"$sdkmanager_bin" "platforms;android-36" "build-tools;36.0.0" "platform-tools"
# 10 modules of JVM unit tests (JUnit 5 + coroutines-test + Turbine + MockK). Includes
# the Android-library modules' testDebugUnitTest and :app's — no device needed.
- name: JVM unit tests (all modules)
run: ./gradlew test
# The APK build is its own step: a Kotlin/KSP/Hilt/Compose breakage that unit tests do
# not reach (resources, manifest merge, DI graph assembly) must not hide behind them.
- name: Debug APK builds
run: ./gradlew :app:assembleDebug
# Coverage floor: >=80% line coverage on the four modules that apply the Kover plugin
# (:wire-protocol, :session-core, :api-client, :client-tls). koverLog prints the actual
# percentages into the run log so a near-miss is visible before it becomes a failure.
# The Android-framework modules are NOT gated (no Kover on them) — a known gap.
- name: Coverage gate (Kover, >= 80% on the four gated modules)
run: ./gradlew koverVerify koverLog
# The instrumented sources cannot RUN here, but they must keep COMPILING. Three modules
# carry them, 119 @Test in total: `:app` (67 — the device-blocker suite: key routing,
# resize, alternate-screen scroll, autofill, driven through a custom HiltTestRunner),
# `:host-registry` (31 — incl. TinkAccessTokenStoreTest, the only assertion anywhere that
# the access token is ciphertext at rest) and `:client-tls-android` (21 — AndroidKeyStore
# import). A silent compile break would delete all of that without anyone noticing.
- name: Instrumented test sources still compile
run: ./gradlew :app:assembleDebugAndroidTest :host-registry:assembleDebugAndroidTest :client-tls-android:assembleDebugAndroidTest
- name: Upload test + coverage reports
if: failure()
uses: actions/upload-artifact@v4
with:
name: android-reports
path: |
android/*/build/reports/tests/**
android/*/build/test-results/**
android/*/build/reports/kover/**
retention-days: 7
if-no-files-found: ignore
# Layer 2 — instrumented (device) tests. DISPATCH-ONLY, on purpose.
#
# What is at stake: `TinkAccessTokenStoreTest` is the ONLY test anywhere that proves the
# frozen at-rest properties of the access token (ios-completion §1.1) — that a fresh store
# instance decrypts it after a cold start, and that the bytes on disk are ciphertext rather
# than the token in the clear. Tink's AEAD + the AndroidKeystore-wrapped master key have no
# JVM double, so this can only run on a device or emulator.
#
# The suite itself is NOT unproven: the android-blocker-fixes session ran `:app`'s 67
# instrumented tests green on real hardware. What has never been proven is this leg — the
# CI *emulator* path. Those are different claims and only the second one gates this `if:`.
#
# Why it is not on push: this workflow has never executed on any runner (see the honesty
# note at the top), and an emulator leg is the single most fragile thing in Android CI —
# it needs KVM, a system image download, and an AVD boot. Wiring an unvalidated, ~15-minute,
# KVM-dependent job into every push would either block the repo on a leg nobody has seen
# pass, or push people to make it non-blocking — and a leg that cannot fail is a false green
# (the same trap ios.yml's `ios17-floor-tests` comment calls out).
#
# THEREFORE: run it by hand from the Actions tab (workflow_dispatch) whenever the token
# store, the DataStore stores, the AndroidKeyStore importer, or the terminal view/input
# path change. Once it has been seen green on a real runner, promote it by deleting the
# `if:` below — the suite has already earned it on hardware.
#
# MANUAL EQUIVALENT (the path that has actually been walked — DEVICE_QA_CHECKLIST.md):
# with a device/emulator attached,
# cd android && ANDROID_HOME=<sdk> ./gradlew :app:connectedDebugAndroidTest \
# :host-registry:connectedDebugAndroidTest \
# :client-tls-android:connectedDebugAndroidTest
# (StrongBox falls back to software keys on an emulator, so hardware-backed key claims still
# need real hardware.)
instrumented-tests:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Enable KVM for the emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# API 29 == the app's minSdk: the deployment floor is where at-rest crypto behaviour is
# most likely to differ (mirrors ios.yml's iOS 17 floor leg).
- name: connectedDebugAndroidTest on an API 29 emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
target: google_apis
arch: x86_64
working-directory: android
script: ./gradlew :app:connectedDebugAndroidTest :host-registry:connectedDebugAndroidTest :client-tls-android:connectedDebugAndroidTest
- name: Upload instrumented reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-instrumented-reports
path: android/*/build/reports/androidTests/**
retention-days: 7
if-no-files-found: ignore

View File

@@ -6,8 +6,9 @@
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
# iPhone 16 simulator): ViewModels/components of the app glue layer.
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on
# an iPhone AND an iPad simulator: ViewModels/components of the app glue
# layer, on both the compact and the regular size class.
# 3. integration-tests — Swift Testing against the REAL Node server. The
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
@@ -35,14 +36,15 @@ on:
jobs:
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs
# tests below without a gate (test doubles are excluded from the gate).
# The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the
# mTLS identity/keychain package, previously the only ungated one at 55.76%).
# TestSupport runs tests below without a gate (test doubles are excluded).
package-tests:
runs-on: macos-15
strategy:
fail-fast: false
matrix:
package: [WireProtocol, SessionCore, HostRegistry, APIClient]
package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS]
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
@@ -56,47 +58,66 @@ jobs:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages)
- name: swift test (TestSupport — no coverage gate; it IS the test doubles)
run: swift test --package-path ios/Packages/TestSupport
# Layer 2: app-target unit tests (WebTermTests, hosted by the app).
# Layer 2: app-target unit tests (WebTermTests, hosted by the app), on the
# compact iPhone path AND the regular iPad path (T-iPad-1: the adaptive
# NavigationSplitView layout of T-iPad-2 must be exercised in CI, not only
# compact). One matrix instead of two near-identical jobs.
#
# THE NODE DEPENDENCY (B4 fix): the WebTermTests bundle contains
# LiveServerSmokeTests, whose SimServerHarness spawns
# `node_modules/.bin/tsx src/server.ts`. On a bare checkout there is no
# node_modules, so the harness throws
# setup: 找不到 …/node_modules/.bin/tsx — 先在 repo 根目录跑 npm install/npm ci
# and the test HARD-FAILS (it is not a skip) — i.e. both legs were red on every
# run. Fix: `npm ci` on the canonical iPhone leg, which is the one that owns the
# live-server smoke; the iPad leg SKIPS that suite instead of paying a second
# node-pty native build. Rationale: the iPad leg exists to exercise the app's
# regular-width layout, not to re-verify the client↔server contract, which is
# already covered three times over (this leg, integration-tests, ui-test).
# `-skip-testing` (rather than a test plan) keeps the change inside this file:
# test plans live in ios/project.yml, owned by another task.
app-tests:
runs-on: macos-15
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- leg: iphone
device: iPhone 16
needsNode: true
extraArgs: ""
- leg: ipad
device: iPad Pro 11-inch (M4)
needsNode: false
extraArgs: "-skip-testing:WebTermTests/LiveServerSmokeTests"
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- uses: actions/setup-node@v4
if: matrix.needsNode
with:
node-version: 22
- name: npm ci (LiveServerSmokeTests spawns node_modules/.bin/tsx)
if: matrix.needsNode
run: npm ci
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPhone 16 simulator)
- name: xcodebuild test (WebTermTests, ${{ matrix.device }} simulator)
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
# real data-protection keychain, which returns -34018 for UNSIGNED test
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
# handoff finding, verified locally in the ui-test leg runs.)
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPhone 16' \
test
# iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so
# the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is
# exercised in CI, not only the compact iPhone path.
ipad-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator)
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
${{ matrix.extraArgs }} \
test
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
@@ -121,9 +142,27 @@ jobs:
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
#
# iPad leg (B4 fix): ProjectsLayoutUITests has an explicit `isPad` branch
# (landscape + NavigationSplitView sidebar reveal, T-iPad-4) that NEVER ran —
# the only UI leg was iPhone. It now runs on an iPad simulator too. Only THAT
# suite: HappyPathUITests has no regular-width branch at all (no sidebar
# reveal), so running it on iPad would fail for a missing-feature reason rather
# than a regression — making the happy path iPad-adaptive is app-layer work,
# tracked separately.
ui-test:
runs-on: macos-15
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- leg: iphone
device: iPhone 16
suites: ""
- leg: ipad
device: iPad Pro 11-inch (M4)
suites: "-only-testing:WebTermUITests/ProjectsLayoutUITests"
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
@@ -159,7 +198,7 @@ jobs:
# inputAccessoryView — it only exists while the SOFT keyboard is up.
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator)
- name: xcodebuild test (WebTermUITests, ${{ matrix.device }} simulator)
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
# strips the prefix and injects <VAR> into the test-runner process);
# passing it as a KEY=VALUE argument makes it a build setting, which
@@ -172,7 +211,8 @@ jobs:
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
-destination 'platform=iOS Simulator,name=iPhone 16' test
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
${{ matrix.suites }} test
- name: Server log + shutdown
if: always()
run: |
@@ -181,15 +221,21 @@ jobs:
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so
# Xcode 16.x can build against it. Local machines are NOT expected to
# download the ~8 GB runtime. If the runner image drops the 17.x runtime,
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures
# fail the job (no silent pass).
#
# CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17
# runtime, so this leg is never reproducible on a dev box.
#
# NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the
# runner image had no iOS 17.x runtime, and the test step was gated on
# `if: steps.sim17.outputs.runtime != ''` — so on image drift the whole
# deployment-floor round vanished and the job still reported GREEN. A leg that
# can silently stop testing is worse than no leg. Now: if the runtime is
# missing, it is DOWNLOADED (`xcodebuild -downloadPlatform iOS
# -buildVersion`); if the download does not produce a usable runtime, the job
# FAILS. The test step is unconditional.
ios17-floor-tests:
runs-on: macos-15
timeout-minutes: 45
timeout-minutes: 90 # the runtime download alone can take ~15 min
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3 (build toolchain)
@@ -198,23 +244,40 @@ jobs:
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: Create iOS 17.x simulator (skip-with-notice if runtime absent)
- name: Ensure an iOS 17.x simulator runtime (download if the image lacks it)
id: sim17
env:
IOS17_FALLBACK_VERSION: "17.5"
run: |
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)"
find_runtime() {
xcrun simctl list runtimes \
| grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' \
| tail -n 1
}
runtime="$(find_runtime || true)"
if [ -z "$runtime" ]; then
echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run"
echo "runtime=" >> "$GITHUB_OUTPUT"
exit 0
echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)"
sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true
runtime="$(find_runtime || true)"
fi
if [ -z "$runtime" ]; then
echo "::error title=iOS 17 floor leg cannot run::no iOS 17.x simulator runtime and the download failed — the deployment-floor round did NOT run, so this job fails instead of reporting a false green" >&2
xcrun simctl list runtimes >&2
exit 1
fi
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
echo "created $udid with $runtime"
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
echo "udid=$udid" >> "$GITHUB_OUTPUT"
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
#
# -skip-testing LiveServerSmokeTests: same reason as the iPad leg — that
# suite spawns node_modules/.bin/tsx and would hard-fail without `npm ci`.
# This leg's job is the DEPLOYMENT FLOOR (does the app build and behave on
# iOS 17), not the server contract, so it stays Node-free.
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
if: steps.sim17.outputs.runtime != ''
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" \
-skip-testing:WebTermTests/LiveServerSmokeTests \
test

View File

@@ -4,7 +4,7 @@ A self-hosted, browser-based terminal **and** Claude-Code session/project workbe
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY.
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only by design; the optional `WEBTERM_TOKEN` access token raises the bar but is **not** a substitute for TLS/Tailscale. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
---
@@ -42,7 +42,8 @@ On a large screen (≥ 1024px) the terminal area can split into a grid so severa
### Projects (v0.6)
- **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks.
- **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count).
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and a **create-worktree** action.
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and worktree **create / prune / remove**.
- **Git panel** — ambient git state on the detail page: a **sync band** (`↑ahead ↓behind`, upstream / detached-HEAD / "never fetched" states, green "in sync" only when nothing is pending *and* the last fetch is recent), a **commit log** with the unpushed boundary marked, **PR status** (via the host's `gh`, when installed), and **stage / commit / push / fetch** actions. All writes are Origin-guarded `POST /projects/git/*` routes running `git` through `execFile` (no shell) with timeouts.
### Walk-away workbench (v0.7)
- **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push.
@@ -59,19 +60,35 @@ On a large screen (≥ 1024px) the terminal area can split into a grid so severa
## Clients
The browser is the reference client; two native clients and a remote-access
The browser is the reference client; three native clients and a remote-access
service are also in the repo (all consume the same wire protocol — the server
stays a byte-shuttle).
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop:
native terminal with scrollback replay, QR/manual pairing (Keychain), the
remote **Approve / Reject** gate + three-way plan gate, **APNs push with
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
changes; P1 adds two declared additive touch-points. See
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
- **iOS / iPad app** ([`ios/`](ios/)) — a native SwiftUI + SwiftTerm **pure
remote client** built for the walk-away loop: native terminal with scrollback
replay, QR/manual pairing (Keychain), the remote **Approve / Reject** gate +
three-way plan gate, **APNs push with lock-screen Allow / Deny behind Face ID**,
deep links (including the web's `?join=` share link), Projects with a **git
panel + worktree lifecycle**, `claude --resume` history, multi-session switcher,
timeline, diff, quick-reply, **terminal find bar**, **voice push-to-talk with a
confirm step**, theme (system/dark/light) + Dynamic Type, and an **adaptive
iPhone/iPad split layout**. Supports the optional `WEBTERM_TOKEN` access token
(per-host, in the Keychain). Looks like the desktop (amber-gold, dark-first).
P0 needed zero server changes; P1 added two declared additive touch-points.
**Merged into `develop`.** See [`ios/README.md`](ios/README.md).
- **Android app** ([`android/`](android/)) — a Kotlin/Compose client targeting
functional parity with iOS, module-for-module mirroring the iOS package set
(`:wire-protocol` / `:session-core` / `:api-client` / `:client-tls` /
`:host-registry` / `:terminal-view` / `:app`). Same wire protocol, same
Origin-iff-guarded discipline, same **`WEBTERM_TOKEN`** contract (hand-written
cookie, Keystore-backed storage), plus FCM push with lock-screen Allow / Deny,
Projects, timeline, diff, quick-reply and an adaptive layout. Terminal
rendering wraps the Apache-2.0 **Termux** terminal view. All modules build and
unit-test locally (`./gradlew test :app:assembleDebug koverVerify`) and the app
has been installed and launched on an emulator; **real-device QA is still
pending**. Design in
[`docs/ANDROID_CLIENT_PLAN.md`](docs/ANDROID_CLIENT_PLAN.md), setup notes in
[`android/README.md`](android/README.md).
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
embeds this Node server + node-pty** (all-in-one), for native notifications,
tray, deep links and launch-at-login. Design in
@@ -146,6 +163,7 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
| `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). |
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH. |
| `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. |
| `WEBTERM_TOKEN` | (unset → auth **disabled**, **secret**) | Optional shared access token gating every HTTP route + the WS upgrade behind a `webterm_auth` cookie (alongside, never instead of, the Origin check). 16512 chars of `[A-Za-z0-9._~+/=-]` or the server refuses to start. Never logged. See [Security & deployment](#security--deployment) for the honest limits. |
| `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. |
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
@@ -191,14 +209,15 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
## Security & deployment
This is a **no-auth, LAN-only** tool by design — it hands a full shell to anyone who can reach the port. The defenses that matter:
This is a **LAN-only** tool by design — with no token set it hands a full shell to anyone who can reach the port, and even with one set it is not an internet-facing service. The defenses that matter:
- **WS Origin validation (cannot be skipped):** the WebSocket handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`. Only `WS_PATH` is accepted for upgrade.
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree` + `/worktree/prune` + `DELETE /projects/worktree`, `POST /projects/git/{stage,commit,push,fetch}`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
- **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host.
- **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap.
- **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout).
- **No authentication yet.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
- **Optional shared access token (`WEBTERM_TOKEN`) — a bar-raiser, not authentication.** Unset ⇒ the gate is **disabled** and behaviour is byte-identical to a no-auth server (LAN zero-config preserved). Set (16512 chars of `[A-Za-z0-9._~+/=-]`, else the server refuses to start) ⇒ every HTTP route **and** the WS upgrade require an `HttpOnly; SameSite=Strict` `webterm_auth` cookie, delivered once by `GET /?token=<t>` or `POST /auth`; the loopback hook ingest (`/hook*`) stays exempt so the Claude Code side-channel keeps working. It sits *in front of* the Origin check and never replaces it. **Honest limits:** on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it is one shared secret with no per-user identity and no revocation short of changing the env var and restarting. It meaningfully hardens only the TLS-terminated relay/tunnel path. See `src/http/auth.ts` and [`docs/plans/w5-access-token.md`](docs/plans/w5-access-token.md).
- **Beyond that, there is no authentication.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
---

3
android/.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -9,6 +9,13 @@ This directory is a **Gradle multi-module** project. The module set mirrors the
SPM package set and inherits its rule: *dependencies only flow down; nothing points
upward* (ARCHITECTURE §1).
> **State, honestly:** the app builds, minifies, signs (given a keystore) and cold-starts
> to the pairing screen on an emulator. It has **never talked to a real WebTerm host from
> a device**, and almost nothing in
> [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md) is ticked. Version is
> `0.1.0-alpha01` for exactly that reason. Read that checklist before calling anything
> here done.
## Build environment (SDK installed — all modules build)
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
@@ -19,10 +26,12 @@ against SDK 35/36.
`:client-tls`, `:test-support`, `:transport-okhttp`.
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
- **Instrumented-only:** `:macrobenchmark` (`com.android.test`; assembles here, runs only
on a device/emulator).
Setup: `local.properties``sdk.dir=/usr/local/share/android-commandlinetools`;
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
`./gradlew test :app:assembleDebug koverVerify`.
`./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest koverVerify`.
## Module map (mirror of the iOS SPM packages — plan §3)
@@ -37,10 +46,23 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
| `URLSession*Transport` | `:transport-okhttp` | pure Kotlin/JVM (OkHttp) | ✅ built |
| — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources |
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
> impls, JVM) is owned by task **A7** and will be added then. The iOS
> `URLSession*Transport`s consolidate into it (plan §3 framing note).
> `:transport-okhttp` (A7) holds the OkHttp `TermTransport`/`HttpTransport`
> implementations that the two iOS `URLSession*Transport`s consolidate into
> (plan §3 framing note). OkHttp is a plain JVM library, so the module builds and
> unit-tests (MockWebServer) with **no** Android SDK — including the
> access-token cookie on the WS upgrade (`OkHttpAccessTokenTest`) and the
> public-host cleartext refusal (`CleartextGuardTest`).
`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a
`com.android.test` module — a **separate APK** that drives `:app` out of process via
UiAutomator, which is the only way startup/frame timings are real. It therefore cannot
violate "dependencies only flow down": nothing depends on it, and it reaches `:app`
through `targetProjectPath`, not a `project()` dependency. It instruments `:app`'s
`benchmark` variant (see "Build types" below). The benchmark sources themselves are not
written yet.
### Dependency graph (arrows = "depends on")
@@ -62,8 +84,45 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
`:wire-protocol` is the **frozen shared contract** (Android analogue of
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
interfaces. New wire types are added **only** here (a coordination point).
`AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport`
boundary interfaces. New wire types are added **only** here (a coordination point).
## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates
A server started with `WEBTERM_TOKEN=<16512 chars of [A-Za-z0-9._~+/=-]>` gates **every**
HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android
client therefore:
- **hand-writes `Cookie: webterm_auth=<t>` itself** for a token it already holds — one
derivation point (`AuthCookie`), stamped in exactly three places: `:api-client`'s
`ApiRoute.toHttpRequest` (all 12+ routes, RO GETs included), the pairing probe's two
hand-built legs, and `:transport-okhttp`'s WS upgrade. The hand-built
`GET /projects/diff` fetcher in `:app` stamps it too.
- **also installs one `AuthCookieJar`** on the single shared `OkHttpClient`, which captures
the `Set-Cookie` a live pairing (`POST /auth`) hands back and replays it on REST *and* on
the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls). The two are
orthogonal: the hand-written header covers a token restored from the Keystore store, the
jar covers a cookie the server just issued. A host with neither sends no cookie at all.
- keeps the header **orthogonal to `Origin`**: the token never replaces the CSWSH
Origin check (the server tests Origin first, then the cookie), and read-only routes
still carry no Origin.
- validates a typed token ONCE at pairing time with `POST /auth`
(`{"token":"…"}`, `Accept: application/json` — an `Accept` containing `text/html`
makes the server answer 302 instead of 204/401). Four outcomes: 204+`Set-Cookie` =
correct → store it; 204 **without** `Set-Cookie` = that host has no auth → store
nothing; 401 = wrong token; 429 = rate-limited (10/min/IP).
- stores it per host (keyed by the canonical origin) in `TinkAccessTokenStore`:
Tink AEAD under an **AndroidKeystore-wrapped, non-exportable** master key, in an
app-private `SharedPreferences` file, with `android:allowBackup="false"` +
`data_extraction_rules.xml` excluding cloud backup and device transfer. The token is
**never logged, never in a URL, never in app UI state**.
- treats a **401 on the WS upgrade as terminal** (`FailureReason.UNAUTHORIZED`): no
reconnect back-off loop, and the banner tells the user to re-enter the token. A 401 on
any REST route is the typed `ApiClientError.Unauthorized`.
A host with no `WEBTERM_TOKEN` is byte-identical to before the feature: no token stored ⇒
no `Cookie` header at all (LAN zero-config preserved).
## Toolchain
@@ -82,15 +141,142 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
```bash
# Use the committed wrapper for everything.
./gradlew help # sanity: the build configures
./gradlew projects # lists the 5 pure modules
./gradlew build # compile all pure modules
./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
./gradlew help # sanity: the build configures
./gradlew projects # lists every module
./gradlew test # JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
./gradlew :app:assembleDebug # the installable debug APK
./gradlew koverVerify # the ≥80% gate on the pure modules
# Release path (needs a keystore — see "Release signing")
./gradlew :app:assembleRelease # R8 + resource shrinking + signing
./gradlew :app:lintVitalRelease # the release-blocking lint subset; must be clean
# Minified build WITHOUT a keystore — the practical way to test R8 keep rules
./gradlew :app:assembleBenchmark # same shrinking as release, debug-signed → installable
```
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
> small focused files — same discipline as the rest of the repo.
> `:session-core`, `:api-client`, `:client-tls` pure half — the four modules that
> apply the Kover plugin, and therefore the only ones `koverVerify` gates). TDD,
> immutable data, small focused files — same discipline as the rest of the repo.
>
> **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug
> (`FirDeclaration was not found for class KtProperty, fir is null`, on
> `ThumbnailPipeline.kt`). `lintVitalRelease` passes normally and from a cold lint state.
## Build types
| Type | Minified | Shrunk res | Debuggable | Signed with | Purpose |
|---|---|---|---|---|---|
| `debug` | no | no | yes | debug key | development; stable applicationId for deep-link tests (A32) |
| `release` | **yes** | **yes** | no | **release key (required)** | the shipping artifact |
| `benchmark` | **yes** | **yes** | no | debug key | `initWith(release)` + `isProfileable`; what `:macrobenchmark` measures, and the only way to exercise R8 without a keystore |
`benchmark` exists because a benchmark must measure the code that actually ships. It sets
`isProfileable = true`, which makes AGP inject `<profileable android:shell="true"/>` into
the merged manifest — done there rather than in `AndroidManifest.xml` so the shipping
manifest carries no benchmark-only tag.
## Versioning
Current: `versionCode = 1`, `versionName = "0.1.0-alpha01"`.
- **`versionCode`** is a plain monotonic counter — **+1 for every artifact handed to anyone**
(a Play track, an APK sent to a tester, an archived benchmark build). It is deliberately
NOT derived from `versionName`; keeping them independent is what lets a hotfix ship
without renumbering. It is still `1` because no artifact has ever left the build machine;
the first distributed build takes `2`.
- **`versionName`** is `<server-line>-alphaNN`. The client tracks the server's `0.1.x` line
(root `package.json` is `0.1.0`). The `-alpha01` suffix is a factual claim about device
verification, not marketing:
- `-alphaNN` — builds, minifies, JVM-tested; **device QA essentially unstarted**. ← today
- `-betaNN` — the A34/A35 blocks of `DEVICE_QA_CHECKLIST.md` pass on real hardware.
- `0.1.0` — the whole checklist is ticked.
Bump the suffix on any user-visible change while still in alpha; move to `0.1.1-alphaNN`
only when the server line moves.
## Release signing
There is **no keystore in this repository and there must never be one.** Credentials are
read at configuration time from the first of these that has them:
1. `android/keystore.properties` — the conventional path. Copy
[`keystore.properties.example`](keystore.properties.example).
⚠️ **Confirm `keystore.properties` is listed in `android/.gitignore` before creating it.**
The existing rules cover `*.jks` / `*.keystore` / `*.p12` but not this filename.
2. `android/local.properties` — already gitignored, so it needs no new rule. Same four keys.
3. `WEBTERM_RELEASE_STORE_FILE` / `_STORE_PASSWORD` / `_KEY_ALIAS` / `_KEY_PASSWORD` — for CI.
Keys: `webterm.release.storeFile` (absolute, or relative to `android/`),
`.storePassword`, `.keyAlias`, `.keyPassword`.
**Degradation contract:** with no credentials, `debug`, `benchmark`, `assembleDebugAndroidTest`
and every test still work, and `:app:assembleRelease` **fails at `packageRelease`** with
instructions. It fails at *packaging*, deliberately after R8, so an unconfigured machine
still gets a full, verifiable R8 run. It never silently emits `app-release-unsigned.apk`
(which is exactly what it used to do).
**Archive `app/build/outputs/mapping/release/mapping.txt` with every distributed artifact**
release builds keep line numbers but obfuscate names, so without the mapping file a crash
report cannot be retraced.
### R8 / keep rules
`app/proguard-rules.pro` is deliberately short and every rule is justified in place; most
of the stack (kotlinx.serialization, Hilt, Tink, Firebase, OkHttp, Compose, CameraX)
ships its own consumer rules and must not be re-declared. Two rules are load-bearing and
were both derived from **observed** failures, not guesswork:
- `com.termux.**` is kept whole, because `:terminal-view` binds to non-API internals of the
pinned v0.118.0 (the public `TerminalView.mEmulator` field, `TerminalRenderer.getFontWidth()`)
and the JitPack artifacts ship no consumer rules.
- `implements com.google.firebase.components.ComponentRegistrar { <init>(); }`, because
without it R8 strips the reflectively-invoked constructors of ML Kit's registrars and
**QR scanning silently stops working in release builds only**.
Before adding a rule, read the merged configuration R8 actually used:
`app/build/outputs/mapping/<variant>/configuration.txt`. To validate rules, install the
`benchmark` APK and exercise the feature — a wrong keep rule is invisible in debug.
## Instrumented tests
`:app` has an androidTest classpath (androidx.test + Espresso + Compose UI test +
`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to
`wang.yaojia.webterm.HiltTestRunner`.
`app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt` supplies it:
```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.
The suite has been **run green on real hardware** (67/0 instrumented tests against this
repo's own server), so `src/androidTest/**` is verified, not aspirational — but it still
needs a connected device/emulator and is therefore **not** part of the `./gradlew test`
gate; run it with `connectedAndroidTest`. An emulator additionally needs
`ALLOWED_ORIGINS=http://10.0.2.2:<port>` on the server, because the server derives its
allowed origins from NIC IPs and `10.0.2.2` (the emulator's alias for the host) is not one.
### What is still NOT verified here
- **DEFERRED — end-to-end access token**: the `WEBTERM_TOKEN` path is covered by
unit tests (`OkHttpAccessTokenTest`, `AuthProbeTest`, `AccessTokenCookieTest`, the
`:api-client` route tests, the `AuthCookie` derivation tests) and by
`TinkAccessTokenStoreTest` on device, but **no automated leg on the Android side starts
a real server with `WEBTERM_TOKEN` set and completes a cookie-gated WS upgrade** — that
end-to-end coverage exists only on the iOS side (`ios/IntegrationTests`).
- **DEFERRED — the rest of `DEVICE_QA_CHECKLIST.md`**: FCM push delivery under Doze on two
physical handsets (S2) and the lock-screen Allow/Deny walkthrough have no automated cover.
## Android SDK setup (proven working)
@@ -127,5 +313,32 @@ AGP library module compiled against SDK 35 and produced an AAR):
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
To add more SDK pieces later (e.g. an emulator image for instrumented tests):
- **A `com.android.test` module cannot use a versioned plugin alias.** The root build
script already puts AGP on the classpath via the `android.library`/`android.application`
aliases, so a third versioned request for the same artifact fails with *"plugin is already
on the classpath with an unknown version"*. `:macrobenchmark` therefore applies
`id("com.android.test")` bare — same as `:terminal-view` with `com.android.library`. The
version is still pinned once, as `agp` in the catalog.
## Emulator (installed)
An AVD named **`webterm`** exists (android-35, arm64, software GPU) and is what produced the
on-device evidence recorded in [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md):
```bash
export ANDROID_HOME=/usr/local/share/android-commandlinetools
$ANDROID_HOME/emulator/emulator -avd webterm -gpu swiftshader_indirect &
$ANDROID_HOME/platform-tools/adb devices -l
# validate the MINIFIED build (this is what catches bad keep rules)
./gradlew :app:assembleBenchmark
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk
adb logcat -c && adb shell am start -W -n wang.yaojia.webterm/.MainActivity
adb logcat -d --pid=$(adb shell pidof wang.yaojia.webterm) | grep -E "FATAL|NoSuchMethod|NoClassDefFound"
```
Note it is a **software-GPU** emulator: it is fine for crash/keep-rule/route validation and
useless for performance numbers. Real macrobenchmark figures need physical hardware.
To add more SDK pieces later:
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,4 +42,9 @@ public data class LiveSessionInfo(
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
val lastOutputAt: Long? = null,
/** W2 pending prompt-queue depth (`src/types.ts` `queueLength?`) — what the "N queued" badge
* renders. Additive OPTIONAL: `null` on a pre-W2 server (queue unsupported / unknown), `0` when
* the queue is empty; the badge shows only for a positive value. Decoded tolerantly
* ([LossyIntSerializer]) so a garbled value can never hide a running session. */
@Serializable(with = LossyIntSerializer::class) val queueLength: Int? = null,
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
package wang.yaojia.webterm.api.pairing
import wang.yaojia.webterm.api.routes.Endpoints
import wang.yaojia.webterm.wire.AccessTokenRule
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
/**
* The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases
* are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side
* pre-check and [Unexpected] keeps an unknown status from being mistaken for success.
*/
public sealed interface AuthProbeResult {
/** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */
public data object Accepted : AuthProbeResult
/**
* **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to
* authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated"
* (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy.
*/
public data object AuthDisabled : AuthProbeResult
/** **401** — wrong token. UI: "访问令牌不正确". */
public data object InvalidToken : AuthProbeResult
/** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */
public data object RateLimited : AuthProbeResult
/** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */
public data object Malformed : AuthProbeResult
/** Any other status (e.g. a captive portal's 302). Never treated as success. */
public data class Unexpected(val status: Int) : AuthProbeResult
}
/**
* Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a
* session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and
* ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a
* BOOLEAN signal (was the token accepted, or is auth disabled on this host?).
*
* Transport-level failures propagate unwrapped so the caller can classify them with
* [PairingError.classify] (same convention as [runPairingProbe]).
*
* Never logs the token; the token appears only in the JSON body, never in the URL.
*/
public suspend fun probeAccessToken(
endpoint: HostEndpoint,
http: HttpTransport,
token: String,
): AuthProbeResult {
// Boundary validation first: a token outside the server's charset/length can never be correct, and
// there is no point spending a rate-limit slot (or shipping it over the wire) to find that out.
val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed
val request = Endpoints.auth(normalized).toHttpRequest(endpoint)
?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST)
val response = http.send(request)
return classifyAuthResponse(response)
}
private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) {
HTTP_NO_CONTENT ->
// The ONE discriminator between "token correct" and "this host has no auth at all".
if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled
HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken
HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited
else -> AuthProbeResult.Unexpected(response.status)
}
/**
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
* value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves
* nothing about our token.
*/
private fun HttpResponse.carriesAuthCookie(): Boolean =
headers.entries.any { (name, value) ->
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
}
/**
* Sentinel status for "the request could not even be built" — unreachable for a validated
* [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status).
*/
private const val UNBUILDABLE_REQUEST = 0
private const val SET_COOKIE_HEADER = "Set-Cookie"
private const val HTTP_NO_CONTENT = 204
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_TOO_MANY_REQUESTS = 429

View File

@@ -52,6 +52,17 @@ public sealed interface PairingError {
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
public data object TlsFailure : PairingError
/**
* The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented
* no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by
* entering the host's access token, not by editing `ALLOWED_ORIGINS`.
*
* NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP
* first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS
* upgrade can only be the Origin allow-list (the server writes the same bare 401 for both).
*/
public data object AccessTokenRequired : PairingError
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
public data object Timeout : PairingError

View File

@@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
@@ -50,8 +52,9 @@ public suspend fun runPairingProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens)
/**
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
@@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore(
http: HttpTransport,
ws: TermTransport,
timeout: Duration?,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult {
if (timeout == null) return performProbe(endpoint, http, ws)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
if (timeout == null) return performProbe(endpoint, http, ws, tokens)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
?: PairingProbeResult.Failure(PairingError.Timeout)
}
@@ -75,10 +79,16 @@ private suspend fun performProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
tokens: AccessTokenSource,
): PairingProbeResult {
// The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets
// it from the transport's own AccessTokenSource (the same store), so all three legs authenticate.
val cookieHeaders = authHeaders(tokens.tokenFor(endpoint))
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
probeReachability(endpoint, http)?.let { return it }
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
// wants an access token we do not have.
probeReachability(endpoint, http, cookieHeaders)?.let { return it }
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
// unrecognizable connect failure is classified as originRejected.
@@ -105,7 +115,7 @@ private suspend fun performProbe(
return try {
when (val adoption = adoptAttachedSession(connection)) {
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders)
}
} finally {
withContext(NonCancellable) { runCatching { connection.close() } }
@@ -120,14 +130,20 @@ private suspend fun performProbe(
private suspend fun probeReachability(
endpoint: HostEndpoint,
http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult.Failure? {
val response = try {
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
}
// Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and
// reporting "端口对吗?" for a token problem would send the user chasing the wrong thing.
if (response.status == HTTP_UNAUTHORIZED) {
return PairingProbeResult.Failure(PairingError.AccessTokenRequired)
}
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
}
@@ -162,13 +178,14 @@ private suspend fun killProbeSession(
sessionId: String,
endpoint: HostEndpoint,
http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult {
val response = try {
http.send(
HttpRequest(
method = HttpMethod.DELETE,
url = killUrl(endpoint, sessionId),
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
),
)
} catch (cancel: CancellationException) {
@@ -178,6 +195,7 @@ private suspend fun killProbeSession(
}
return when (response.status) {
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
)
@@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String {
return "$scheme://$host$portPart"
}
/**
* `Cookie: webterm_auth=<t>` for the probe's HTTP legs, or NO header when the host has no token —
* derived from the single point ([AuthCookie]), never hand-assembled.
*/
private fun authHeaders(token: String?): Map<String, String> =
if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token))
private const val LIVE_SESSIONS_PATH = "/live-sessions"
private const val ORIGIN_HEADER = "Origin"
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

View File

@@ -11,14 +11,20 @@ import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.QueueSnapshot
import wang.yaojia.webterm.api.models.QueueWriteOutcome
import wang.yaojia.webterm.api.models.decodeQueueDepth
import wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.WorktreeState
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
@@ -36,10 +42,18 @@ import java.util.UUID
*
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
*
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
* an unauthenticated LAN host behaves exactly as before. A **401** becomes the typed
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token — except on the
* routes that define their own 401 ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write family).
*/
public class ApiClient(
public val endpoint: HostEndpoint,
private val http: HttpTransport,
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
) {
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
@@ -135,6 +149,19 @@ public class ApiClient(
}
}
/**
* `GET /live-sessions/:id/queue` (W2) — the pending prompt queue: depth + the queued texts,
* verbatim. READ-ONLY, so no Origin (the server guards only the two writes). 404 → the session is
* gone; a non-object body → [ApiClientError.InvalidResponseBody] rather than a fake empty queue
* (claiming "nothing queued" when entries exist would mislead a cancel-all decision).
*/
public suspend fun queue(id: UUID): QueueSnapshot {
val response = perform(Endpoints.queue(id))
requireOk(response)
return LossyDecode.objectOrNull(response.body, QueueSnapshot.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs {
@@ -180,6 +207,52 @@ public class ApiClient(
}
}
// ── G: W2 prompt queue (enqueue / cancel-all) → QueueWriteOutcome ──────────────────────
/**
* `POST /live-sessions/:id/queue` (W2) — queue a follow-up prompt the server injects into the PTY
* the next time Claude goes idle. Works with zero tabs attached, which is the whole point.
*
* [text] travels VERBATIM (invariant #9 — raw keyboard bytes for a shell); [appendEnter] is a flag
* the SERVER turns into a trailing `\r`, so callers must not append one. An empty prompt is
* rejected as [ApiClientError.QueueTextEmpty] before any network I/O; size is NOT pre-checked
* client-side (the byte cap is server config) — an over-cap prompt comes back as
* [QueueWriteOutcome.TooLarge].
*/
public suspend fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean = true): QueueWriteOutcome {
if (text.isEmpty()) throw ApiClientError.QueueTextEmpty
return queueWrite(Endpoints.enqueueFollowup(id, text, appendEnter))
}
/**
* `DELETE /live-sessions/:id/queue` (W2) — cancel every pending entry (escape hatch). 200 →
* [QueueWriteOutcome.Ok] with depth 0.
*/
public suspend fun clearQueue(id: UUID): QueueWriteOutcome = queueWrite(Endpoints.clearQueue(id))
/**
* Shared status mapping for the two guarded queue writes. Distinct typed outcomes because each one
* demands different UI behaviour (see [QueueWriteOutcome]); in particular a 429 from the shared
* `QUEUE_RATE_MAX = 20`/min/IP bucket is surfaced, NEVER auto-retried. Only 409/413/503 are
* POST-specific — the DELETE handler cannot produce them.
*/
private suspend fun queueWrite(route: ApiRoute): QueueWriteOutcome {
val response = perform(route)
return when (response.status) {
HttpStatus.OK -> QueueWriteOutcome.Ok(decodeQueueDepth(response.body))
HttpStatus.CONFLICT -> QueueWriteOutcome.Full
HttpStatus.PAYLOAD_TOO_LARGE -> QueueWriteOutcome.TooLarge
HttpStatus.SERVICE_UNAVAILABLE -> QueueWriteOutcome.Disabled
HttpStatus.NOT_FOUND -> QueueWriteOutcome.SessionGone
HttpStatus.TOO_MANY_REQUESTS -> QueueWriteOutcome.RateLimited
// `decodeGitError` reads the generic `{ error }` failure shape the queue routes share with
// the git ones (400/403 here) — reused rather than duplicated.
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
QueueWriteOutcome.Rejected(response.status, decodeGitError(response.body))
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
@@ -206,6 +279,36 @@ public class ApiClient(
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
/**
* `POST /projects/git/fetch` (w6/G2) — refresh `refs/remotes` so `behind` becomes trustworthy.
*
* Shares the guarded-write dispatch, so a disabled kill-switch or an Origin failure both surface as
* [GitWriteOutcome.Rejected] with the server's safe message. Fetch has its OWN rate-limit bucket on
* the server precisely so refreshes cannot eat the budget a real push needs — surface a 429 rather
* than retrying.
*/
public suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> =
gitWrite(Endpoints.gitFetch(path), FetchResult.serializer())
/**
* `GET /projects/worktree/state?path=` (w6/G7) — the per-row git state of ONE worktree.
*
* Mapped like the other project reads. A malformed body throws rather than degrading to an empty
* state: a row that silently claims "clean, in sync" would be a lie, and the caller is expected to
* isolate this failure to its own row (the panel must still render).
*/
public suspend fun worktreeState(path: String): WorktreeState {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.worktreeState(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, WorktreeState.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/**
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
@@ -249,9 +352,27 @@ public class ApiClient(
// ── Internals ──────────────────────────────────────────────────────────────────────────
/**
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
* status mapping — otherwise a 401 on a read route would degrade into a generic status error and
* the UI would never learn to ask for the token (ios-completion §1.1).
*
* The one exception is declared AT the route ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write
* family): there a 401 is the server classifying a HOST-side git credential failure
* (`src/http/git-ops.ts:108`), so it must flow on to [gitWrite]'s mapping and reach the user as the
* server's own message. Swallowing it would tell the user to rotate an access token that is fine.
*/
private suspend fun perform(route: ApiRoute): HttpResponse {
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
return http.send(request)
val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
?: throw ApiClientError.InvalidRequest
val response = http.send(request)
if (response.status == HttpStatus.UNAUTHORIZED &&
route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE
) {
throw ApiClientError.Unauthorized
}
return response
}
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */

View File

@@ -20,6 +20,15 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
/**
* **401** from a route whose 401 can only be the access-token gate (RO or guarded): this host has
* `WEBTERM_TOKEN` set and our request carried no cookie / a wrong one (ios-completion §1.1).
* Distinct from [Forbidden] (that is the Origin guard) — the UI must send the user to enter or fix
* the host's access token. The git-write family is excluded (it defines its own 401 — see
* [UnauthorizedPolicy]), so this copy can never be shown for a host-side git credential failure.
*/
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
/** 403 from a G route's Origin guard (CSWSH defence). */
public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。")
@@ -44,6 +53,11 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** An empty follow-up prompt was passed to `POST /live-sessions/:id/queue` (which the server 400s
* as `text must be a non-empty string`). Raised client-side, before any network I/O — mirrors the
* [ProjectPathInvalid] precedent. */
public data object QueueTextEmpty : ApiClientError("要排队的内容为空——请先输入要发送的提示词。")
/** 500 from `GET /projects/log` — the server failed reading the git log. */
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")

View File

@@ -1,5 +1,6 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
@@ -10,10 +11,14 @@ internal object HttpStatus {
const val OK = 200
const val NO_CONTENT = 204
const val BAD_REQUEST = 400
const val UNAUTHORIZED = 401
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val CONFLICT = 409
const val PAYLOAD_TOO_LARGE = 413
const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500
const val SERVICE_UNAVAILABLE = 503
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
const val CLIENT_ERROR_MIN = 400
@@ -24,6 +29,12 @@ internal object HttpStatus {
internal object HeaderName {
const val ORIGIN = "Origin"
const val CONTENT_TYPE = "Content-Type"
/**
* Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser
* form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`).
*/
const val ACCEPT = "Accept"
}
internal object ContentType {
@@ -44,6 +55,23 @@ internal enum class OriginPolicy {
GUARDED,
}
/**
* How a **401** on this route must be READ (ios-completion §1.1) — declared at the route so the
* exception is visible instead of hidden in a call site. The Android analogue of iOS
* `UnauthorizedPolicy` (APIClient/Endpoints.swift).
*/
internal enum class UnauthorizedPolicy {
/** Default — on this route a 401 can only be the access-token gate (`src/server.ts:465`). */
ACCESS_TOKEN_GATE,
/**
* The route owns its 401 and it must NOT be read as "your access token is wrong": the git-write
* family, where the server classifies a HOST-side git credential failure as
* `{status:401, error:"Push authentication required on the host."}` (`src/http/git-ops.ts:108`).
*/
ROUTE_DEFINED,
}
/**
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
@@ -55,19 +83,37 @@ internal class ApiRoute(
val originPolicy: OriginPolicy,
val body: ByteArray? = null,
val percentEncodedQuery: String? = null,
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
val accept: String? = null,
/** See [UnauthorizedPolicy]. Defaults to the gate reading. */
val unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
) {
/**
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
*
* Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1):
* - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged);
* - `Cookie: webterm_auth=<t>` whenever [accessToken] is non-null — on EVERY route, read-only
* ones included, because the server's access-token gate sits in front of the whole app. A null
* token adds no header at all, keeping an unauthenticated host byte-identical to before.
* The token is secret material: it is only ever placed in this header — never in [path], never in
* [percentEncodedQuery], never logged.
*/
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? {
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
val headers = LinkedHashMap<String, String>()
if (originPolicy == OriginPolicy.GUARDED) {
headers[HeaderName.ORIGIN] = endpoint.originHeader
}
if (accessToken != null) {
headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken)
}
if (accept != null) {
headers[HeaderName.ACCEPT] = accept
}
if (body != null) {
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
}

View File

@@ -12,10 +12,10 @@ import java.util.UUID
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
* pair (this client uses FCM, not APNs/VAPID).
*
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
* · `POST|DELETE /push/fcm-token`
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue`
* · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue`
* · `POST /hook/decision` · `PUT /prefs` · `POST|DELETE /push/fcm-token`
*
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
@@ -54,9 +54,29 @@ internal object Endpoints {
percentEncodedQuery = "path=${percentEncode(path)}",
)
/**
* `GET /projects/worktree/state?path=` — RO (w6/G7). Deliberately NARROWER than
* `/projects/detail`: it is fetched once per worktree row, so N rows must not each pay for a
* worktree listing and a CLAUDE.md read that nothing renders.
*/
fun worktreeState(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/worktree/state",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
/**
* `GET /live-sessions/:id/queue` — RO (W2). Same threat model as `GET /live-sessions`, so the
* server stamps no Origin guard on it and neither may we.
*/
fun queue(id: UUID): ApiRoute =
ApiRoute(HttpMethod.GET, queuePath(id), OriginPolicy.READ_ONLY)
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
fun projectPr(path: String): ApiRoute =
ApiRoute(
@@ -114,7 +134,40 @@ internal object Endpoints {
private const val FCM_TOKEN_PATH = "/push/fcm-token"
// ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ───────────────
/**
* `POST /auth` — the pairing-time token-validation probe. Body `{"token":"<t>"}`.
*
* Two non-obvious requirements, both server-driven (`src/server.ts`):
* - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request
* is treated as a browser form and answered with a **302** redirect instead of 204/401.
* - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no
* auth cookie (the token is in the body — this IS the login).
*
* Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not
* Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case.
*/
fun auth(token: String): ApiRoute {
val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray()
return ApiRoute(
HttpMethod.POST,
AUTH_PATH,
OriginPolicy.GUARDED,
body = body,
accept = ContentType.JSON,
)
}
private const val AUTH_PATH = "/auth"
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
//
// Guarded writes, but NOT [UnauthorizedPolicy.ROUTE_DEFINED] (F5): these land in
// `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500), and
// `src/server.ts:1182-1260` adds none. The only 401 they can ever see is the access-token gate, so
// they take the DEFAULT gate reading — pinning them route-defined made a gated host's 401 surface
// as a worktree failure instead of the token flow.
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
@@ -142,27 +195,98 @@ internal object Endpoints {
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
gitWriteRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
/** `POST /projects/git/commit` — `{ path, message }`. */
fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
gitWriteRoute(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 =
gitWriteRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
gitWriteRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
// ── 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]).
*
* The 401 reading defaults to [UnauthorizedPolicy.ACCESS_TOKEN_GATE] — correct for every route
* whose handler never writes a 401 of its own (the W2 queue: 400/404/429/503 only; the worktree
* trio: 403/400/404/500 only). Only the git-ops writes opt out, via [gitWriteRoute].
*/
private fun <T> jsonBodyRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
): ApiRoute {
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
return ApiRoute(
method,
path,
OriginPolicy.GUARDED,
body = body,
unauthorizedPolicy = unauthorizedPolicy,
)
}
/**
* Build a GUARDED **git-ops** route with a `ModelJson`-encoded JSON body (Origin stamped in
* [ApiRoute]).
*
* Every route built here is [UnauthorizedPolicy.ROUTE_DEFINED]: the server classifies a HOST-side
* git credential failure as **401** with its own message (`src/http/git-ops.ts:108`), which the UI
* must display verbatim instead of the access-token copy (mirrors iOS `gitOpsRoute`).
*
* Scope is exactly the four routes that reach that classifier — `stage`, `commit`, `push`, `fetch`.
* The worktree trio must NOT be built here (F5): its handler has no 401 of its own.
*/
private fun <T> gitWriteRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
): ApiRoute = jsonBodyRoute(method, path, serializer, value, UnauthorizedPolicy.ROUTE_DEFINED)
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
private const val GIT_LOG_MAX = 50
@@ -199,6 +323,10 @@ internal object Endpoints {
@Serializable
private data class FcmTokenBody(val token: String)
/** `POST /auth` body. Holds secret material — never log a request built from it. */
@Serializable
private data class AuthBody(val token: String)
@Serializable
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
@@ -216,4 +344,10 @@ internal object Endpoints {
@Serializable
private data class PushBody(val path: String)
@kotlinx.serialization.Serializable
private data class FetchBody(val path: String)
@Serializable
private data class EnqueueBody(val text: String, val appendEnter: Boolean)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.io.IOException
/**
* B5 · `POST /auth` as the pairing-time validation probe (FROZEN contract, ios-completion §1.1).
*
* The four distinct server outcomes must stay distinguishable:
* | 204 **with** `Set-Cookie` | token correct → persist it |
* | 204 **without** `Set-Cookie` | the host has NO auth configured → do NOT persist, and do NOT treat as authenticated |
* | 401 | the token is wrong → UI says "令牌不正确" |
* | 429 | rate-limited (10/min/IP) → UI says "尝试过多" |
*
* Plus the request shape the server needs: JSON body `{"token":"<t>"}` and an `Accept` that does NOT
* contain `text/html` (an HTML-accepting request is treated as a browser form and answered with a 302
* instead of 204/401).
*/
class AuthProbeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val transport = FakeHttpTransport()
private fun queueAuth(status: Int, headers: Map<String, String> = emptyMap()) {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/auth", status = status, headers = headers)
}
private val setCookieHeader = mapOf(
"Set-Cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict",
)
// ── the four frozen outcomes ────────────────────────────────────────────────────────────────
@Test
fun `204 with Set-Cookie means the token is correct`() = runTest {
queueAuth(204, setCookieHeader)
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `204 without Set-Cookie means the host has no auth configured`() = runTest {
queueAuth(204)
assertEquals(
AuthProbeResult.AuthDisabled,
probeAccessToken(endpoint, transport, TOKEN),
"204-no-cookie must NEVER be read as 'authenticated' (frozen §1.1)",
)
}
@Test
fun `401 means the token is wrong`() = runTest {
queueAuth(401)
assertEquals(AuthProbeResult.InvalidToken, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `429 means rate-limited`() = runTest {
queueAuth(429)
assertEquals(AuthProbeResult.RateLimited, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `any other status is surfaced as Unexpected with the code`() = runTest {
queueAuth(302) // e.g. a proxy/login redirect — never silently treated as success
assertEquals(AuthProbeResult.Unexpected(302), probeAccessToken(endpoint, transport, TOKEN))
}
// ── request shape ───────────────────────────────────────────────────────────────────────────
@Test
fun `the probe posts the token as JSON with an Accept that excludes text-html`() = runTest {
queueAuth(204, setCookieHeader)
probeAccessToken(endpoint, transport, TOKEN)
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/auth", request.url)
assertEquals("""{"token":"$TOKEN"}""", request.body?.decodeToString())
assertEquals("application/json", request.headers["Content-Type"])
val accept = request.headers["Accept"].orEmpty()
assertTrue(accept.isNotEmpty(), "Accept must be explicit, not left to the HTTP stack")
assertFalse(accept.contains("text/html"), "text/html would make the server answer 302, not 204/401")
}
@Test
fun `the token never appears in the URL`() = runTest {
queueAuth(401)
probeAccessToken(endpoint, transport, TOKEN)
assertFalse(
transport.recordedRequests.single().url.contains(TOKEN),
"a secret must never travel in a URL (query strings land in logs/history)",
)
}
// ── boundary validation + transport failure ─────────────────────────────────────────────────
@Test
fun `a malformed token is rejected before any network IO`() = runTest {
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "short"))
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "has spaces in it here"))
assertTrue(transport.recordedRequests.isEmpty(), "a malformed token must not hit the network")
}
@Test
fun `a whitespace-padded token is trimmed once and accepted`() = runTest {
queueAuth(204, setCookieHeader)
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, " $TOKEN\n"))
assertEquals("""{"token":"$TOKEN"}""", transport.recordedRequests.single().body?.decodeToString())
}
@Test
fun `a transport failure propagates so the caller can classify it`() = runTest {
transport.queueFailure(method = HttpMethod.POST, url = "$BASE/auth", error = IOException("refused"))
val thrown = runCatching { probeAccessToken(endpoint, transport, TOKEN) }.exceptionOrNull()
assertTrue(thrown is IOException, "network classification stays with PairingError.classify")
}
@Test
fun `a Set-Cookie header is recognised case-insensitively`() = runTest {
queueAuth(204, mapOf("set-cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/"))
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `a Set-Cookie for some other cookie does not count as accepted`() = runTest {
queueAuth(204, mapOf("Set-Cookie" to "sessionid=abc; Path=/"))
assertEquals(
AuthProbeResult.AuthDisabled,
probeAccessToken(endpoint, transport, TOKEN),
"only a webterm_auth cookie proves the token was accepted",
)
}
}

View File

@@ -0,0 +1,90 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/**
* B5 · the two-step pairing probe under an access-token-protected host:
* - both HTTP legs (`GET /live-sessions` and the guarded `DELETE /live-sessions/:id`) carry the
* hand-written `Cookie: webterm_auth=<t>`;
* - a **401** on the reachability leg is [PairingError.AccessTokenRequired] — NOT
* "端口对吗?" ([PairingError.HttpOkButNotWebTerminal]) and NOT an Origin problem, so the UI can ask
* for the token instead of sending the user on a wild goose chase;
* - with no token configured nothing changes (no `Cookie` header at all).
*/
class PairingProbeTokenTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
const val SERVER_ID = "11111111-1111-4111-8111-111111111111"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val http = FakeHttpTransport()
private val ws = FakeTermTransport()
private fun scriptHappyPath() {
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 204)
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
}
private suspend fun probe(tokens: AccessTokenSource): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = null, tokens = tokens)
@Test
fun `both HTTP legs of the probe carry the auth cookie`() = runTest {
scriptHappyPath()
val result = probe(AccessTokenSource { TOKEN })
assertEquals(PairingProbeResult.Success(endpoint), result)
assertEquals(2, http.recordedRequests.size, "reachability GET + guarded kill DELETE")
http.recordedRequests.forEach { request ->
assertEquals(
"${AuthCookie.NAME}=$TOKEN",
request.headers[AuthCookie.HEADER_NAME],
"every probe request must present the token",
)
}
}
@Test
fun `with no token the probe requests are byte-identical to before the feature`() = runTest {
scriptHappyPath()
probe(AccessTokenSource.NONE)
http.recordedRequests.forEach { request ->
assertFalse(request.headers.containsKey(AuthCookie.HEADER_NAME))
}
}
@Test
fun `a 401 on the reachability leg asks for an access token`() = runTest {
http.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
val result = probe(AccessTokenSource.NONE)
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
}
@Test
fun `a 401 on the guarded kill leg also asks for an access token`() = runTest {
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 401)
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
val result = probe(AccessTokenSource.NONE)
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
}
}

View File

@@ -0,0 +1,207 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* B5 · the access-token cookie on the REST surface (FROZEN contract, ios-completion §1.1):
* - `Cookie: webterm_auth=<t>` is stamped on **EVERY** route — read-only GETs included — because the
* server's auth gate runs in front of the whole app, not just the guarded routes;
* - it is **orthogonal to `Origin`**: a RO route carries the cookie and still NO Origin (the
* Origin-iff-guarded 铁律 is untouched);
* - no token configured ⇒ NO `Cookie` header at all (zero-config LAN behaviour is byte-identical);
* - a **401** on a route whose 401 can only be the gate is the typed [ApiClientError.Unauthorized],
* never a generic status error, so the UI can route the user to re-enter the token — while the
* git-write family, which defines its own 401 (`src/http/git-ops.ts:108`), keeps the server's
* message (E1 fix; see the rewritten push case below).
*/
class AccessTokenCookieTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val transport = FakeHttpTransport()
private fun clientWithToken(): ApiClient =
ApiClient(endpoint, transport, AccessTokenSource { TOKEN })
private fun clientWithoutToken(): ApiClient = ApiClient(endpoint, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
private fun assertCookie(index: Int) {
val headers = transport.recordedRequests[index].headers
assertEquals(
"${AuthCookie.NAME}=$TOKEN",
headers[AuthCookie.HEADER_NAME],
"every request must carry the hand-written auth cookie",
)
}
@Test
fun `a read-only GET carries the cookie and still no Origin`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
clientWithToken().liveSessions()
assertCookie(0)
assertFalse(
transport.recordedRequests[0].headers.containsKey(HeaderName.ORIGIN),
"the cookie is additive — a RO route must still never carry Origin",
)
}
@Test
fun `a guarded write carries BOTH the byte-equal Origin and the cookie`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
clientWithToken().hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
assertCookie(0)
assertEquals(endpoint.originHeader, transport.recordedRequests[0].headers[HeaderName.ORIGIN])
}
@Test
fun `the cookie rides every route shape - RO query, guarded DELETE and guarded PUT alike`() = runTest {
transport.queueSuccess(
url = "$BASE/projects/detail?path=%2Frepo",
body = """{"name":"repo","path":"/repo","isGit":true}""".toByteArray(),
)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = "{}".toByteArray())
val client = clientWithToken()
client.projectDetail("/repo")
client.killSession(ID)
client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create())
assertEquals(3, transport.recordedRequests.size)
transport.recordedRequests.indices.forEach { assertCookie(it) }
}
@Test
fun `no configured token means no Cookie header at all`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
clientWithoutToken().liveSessions()
assertFalse(
transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME),
"an unauthenticated host must see a byte-identical request to before the feature",
)
}
@Test
fun `the token is re-read per request so a token stored later takes effect`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
var stored: String? = null
val client = ApiClient(endpoint, transport, AccessTokenSource { stored })
client.liveSessions()
stored = TOKEN
client.liveSessions()
assertFalse(transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME))
assertCookie(1)
}
@Test
fun `a 401 on a read-only route throws the typed Unauthorized error`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
val error = errorOf { clientWithoutToken().liveSessions() }
assertEquals(ApiClientError.Unauthorized, error)
assertTrue(
ApiClientError.Unauthorized.userMessage.isNotBlank(),
"the UI needs actionable copy for a missing token",
)
}
/**
* E1 · REWRITTEN (this case previously asserted the opposite and pinned a real defect).
*
* `POST /projects/git/push` defines its OWN 401: `src/http/git-ops.ts:108` classifies a HOST-side
* git credential failure ("could not read Username", "permission denied (publickey)", …) as
* `{status:401, error:"Push authentication required on the host."}`. Swallowing that into
* [ApiClientError.Unauthorized] tells the user their *access token* is broken and sends them to
* rotate a secret that is fine, while hiding the real cause. iOS gets this right via
* `unauthorizedPolicy: .routeDefined` (APIClient/GitWrite.swift), and plan §4 item 49 requires the
* distinction — so the route's own message must reach [GitWriteOutcome.Rejected] verbatim.
*/
@Test
fun `a 401 on git push is the host's own git-credential failure, surfaced verbatim`() = runTest {
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/projects/git/push",
status = 401,
body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray(),
)
val outcome = clientWithToken().gitPush("/repo")
assertEquals(GitWriteOutcome.Rejected(401, "Push authentication required on the host."), outcome)
}
@Test
fun `the route-defined 401 covers the whole git-ops family, not just push`() = runTest {
val body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray()
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", status = 401, body = body)
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", status = 401, body = body)
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/fetch", status = 401, body = body)
val client = clientWithToken()
assertEquals(401, (client.gitStage("/repo", listOf("a.txt"), true) as GitWriteOutcome.Rejected).status)
assertEquals(401, (client.gitCommit("/repo", "msg") as GitWriteOutcome.Rejected).status)
assertEquals(401, (client.gitFetch("/repo") as GitWriteOutcome.Rejected).status)
}
/**
* F5 · the worktree trio is NOT part of that family. `src/http/worktrees.ts` emits no 401 (403
* kill-switch / 400 / 404 / 500 only) and `src/server.ts:1182-1260` adds none, so the ONLY 401 those
* routes can ever see is the access-token gate. Reading it as a git rejection stranded the user on a
* "worktree failed" message instead of the token flow.
*/
@Test
fun `a 401 on a worktree write is the access-token gate, not a git rejection`() = runTest {
val gateBody = """{"error":"authentication required"}""".toByteArray()
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", status = 401, body = gateBody)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", status = 401, body = gateBody)
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/projects/worktree/prune",
status = 401,
body = gateBody,
)
val client = clientWithToken()
assertEquals(ApiClientError.Unauthorized, errorOf { client.createWorktree("/repo", "feat/x") })
assertEquals(ApiClientError.Unauthorized, errorOf { client.removeWorktree("/repo", "/repo/wt", false) })
assertEquals(ApiClientError.Unauthorized, errorOf { client.pruneWorktrees("/repo") })
}
@Test
fun `a git write with no server message still reports the status, never the token copy`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 401)
val outcome = clientWithoutToken().gitPush("/repo")
assertEquals(GitWriteOutcome.Rejected(401, null), outcome)
}
}

View File

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

View File

@@ -158,4 +158,50 @@ class GitRouteShapeTest {
client.gitPush("/r")
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
}
/**
* E1 (narrowed, F5) · The 401 split declared AT the route (mirrors iOS `UnauthorizedPolicy`).
*
* **Route-defined 401 = exactly the four `/projects/git/…` writes, and only because of the ONE
* server line that can produce it:** `src/http/git-ops.ts:108` turns a HOST-side git credential
* failure into `{status:401, error:"Push authentication required on the host."}`. That classifier is
* reached only from `stageFiles`/`commit`/`push`/`fetch`.
*
* **The worktree trio is NOT route-defined.** `createWorktree`/`removeWorktree`/`pruneWorktrees`
* land in `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500),
* and `src/server.ts:1182-1260` adds none. Pinning them ROUTE_DEFINED meant that on a token-gated
* host the GATE's 401 surfaced as a git rejection instead of the token flow.
*
* Every other 401 the server can emit belongs to the gate: `src/server.ts:425` (`/auth` invalid),
* `:472` (the gate itself) and `:1453`/`:1463` (the WS upgrade) — none of which is a route here.
*/
@Test
fun `route-defined 401 is exactly the four git-ops writes, everything else is the gate`() {
val gitOpsWrites = listOf(
Endpoints.gitStage("/r", listOf("f"), true),
Endpoints.gitCommit("/r", "m"),
Endpoints.gitPush("/r"),
Endpoints.gitFetch("/r"),
)
val gateRoutes = listOf(
// The worktree trio: guarded writes, but the server has no 401 of its own for them.
Endpoints.createWorktree("/r", "b", null),
Endpoints.removeWorktree("/r", "/r/x", false),
Endpoints.pruneWorktrees("/r"),
Endpoints.liveSessions(),
Endpoints.projects(),
Endpoints.projectLog("/r", null),
Endpoints.killSession(UUID.randomUUID()),
Endpoints.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()),
)
assertTrue(gitOpsWrites.all { it.unauthorizedPolicy == UnauthorizedPolicy.ROUTE_DEFINED })
gateRoutes.forEach {
assertEquals(
UnauthorizedPolicy.ACCESS_TOKEN_GATE,
it.unauthorizedPolicy,
"${it.method} ${it.path} has no server-side 401 of its own — a 401 there IS the token gate",
)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,13 +12,35 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<!-- Cleartext: @xml/network_security_config is the AUTHORITATIVE policy (minSdk 29, so the
platform always reads it and IGNORES android:usesCleartextTraffic). The attribute is set
to true only so the manifest does not state the opposite of what actually ships: bare-LAN
`ws://` must work (plan §6.9). The config is where the real posture lives — cleartext ON
for user-typed LAN addresses (no CIDR syntax exists to scope it), system-only trust
anchors, and cleartext still BLOCKED for *.terminal.yaojia.wang. Read its header before
changing either value; they must stay in agreement. -->
<!-- Backup: OFF, and deliberately not an exclusion list (plan §8 "app-private, uninstall-wiped,
never in backups/cloud"). Auto Backup defaults to ON and would sweep up EVERY app-private
file: the `webterm` Preferences DataStore (which holds the sealed `webterm_auth` cookie — a
full-shell credential the server keeps alive for 30 days) and the Tink keyset/ciphertext pref
files. 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.
`allowBackup` alone only covers Auto Backup (cloud) / adb backup. Android 12+ DEVICE-TO-DEVICE
transfer is governed by a `dataExtractionRules` <device-transfer> block, which needs an @xml
resource — @xml/data_extraction_rules now supplies it, and it is deny-everything in BOTH
sections rather than an exclusion list, so a store added later is excluded by default instead
of leaking until someone remembers it. `fullBackupContent="false"` states the same for the
pre-31 path. Do not set any of these back to true. -->
<application
android:name=".WebTermApp"
android:allowBackup="false"
android:label="@string/app_name"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:supportsRtl="true"
android:theme="@style/Theme.WebTerm"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -79,11 +79,16 @@ public sealed interface BannerModel {
/**
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
* spinner** (reconnecting would hit the same wall forever), and — where a fresh session would
* actually help — a "新会话" affordance.
*
* [FailureReason.UNAUTHORIZED] deliberately offers NO new-session action (B5): the server 401'd the
* upgrade, so a new session would 401 too; the fix is to re-enter the host's access token (or fix its
* `ALLOWED_ORIGINS`), which the copy says.
*/
public data class Failed(val reason: FailureReason) : BannerModel {
override val showsSpinner: Boolean get() = false
override val offersNewSession: Boolean get() = true
override val offersNewSession: Boolean get() = reason != FailureReason.UNAUTHORIZED
}
/**
@@ -184,6 +189,8 @@ private fun bannerCopy(model: BannerModel): String = when (model) {
is BannerModel.Failed -> when (model.reason) {
FailureReason.REPLAY_TOO_LARGE ->
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
FailureReason.UNAUTHORIZED ->
"服务器拒绝了连接401。请在“配对主机”里重新填写访问令牌若已填写请检查主机的 ALLOWED_ORIGINS。"
}
is BannerModel.Exited ->
if (model.isSpawnFailure) {

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
package wang.yaojia.webterm.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.hostregistry.AccessTokenStore
import wang.yaojia.webterm.hostregistry.TinkAccessTokenStore
import wang.yaojia.webterm.wire.AccessTokenSource
import javax.inject.Singleton
/**
* Access-token boundary (B5 / ios-completion §1.1): the ONE per-host access-token store, exposed both as
* the mutable [AccessTokenStore] (the pairing screen writes it) and as the read-only
* [AccessTokenSource] the transports consume.
*
* It is its own Hilt module — not part of [StorageModule] — because the token is SECRET material:
* [StorageModule]'s DataStore holds only non-secret UI state, while this store is
* Tink-AEAD-encrypted under an AndroidKeystore-wrapped master key (the same posture as [TlsModule]'s
* cert store, with its own keyset so the two secrets never share a key).
*
* Both providers resolve to the SAME singleton, so a token stored during pairing is immediately visible
* to the WS upgrade and to every REST route — no cache to invalidate. The store is constructor-I/O-free
* (Tink/Keystore work is lazy and hops to `Dispatchers.IO` inside), so injecting it on `Main` is cheap.
*/
@Module
@InstallIn(SingletonComponent::class)
public object AuthModule {
@Provides
@Singleton
public fun provideAccessTokenStore(
@ApplicationContext context: Context,
): AccessTokenStore = TinkAccessTokenStore(context)
/** The transports' read seam — the SAME instance the pairing screen wrote to. */
@Provides
@Singleton
public fun provideAccessTokenSource(store: AccessTokenStore): AccessTokenSource = store
}

View File

@@ -5,6 +5,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.ConnectionPool
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import wang.yaojia.webterm.transport.ClientIdentity
import wang.yaojia.webterm.transport.ClientIdentityProvider
@@ -12,6 +13,7 @@ import wang.yaojia.webterm.transport.OkHttpClientFactory
import wang.yaojia.webterm.transport.OkHttpHttpTransport
import wang.yaojia.webterm.transport.OkHttpTermTransport
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport
import javax.inject.Singleton
@@ -27,6 +29,11 @@ import javax.inject.Singleton
* re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no
* client rebuild (R4). The provider is read ONCE, when the client is built.
*
* ### The WEBTERM_TOKEN cookie jar
* The shared client also carries the ONE [CookieJar] provided by [AuthCookieModule], so the optional
* access-token gate applies uniformly to REST and to the WS upgrade. See that module for the at-rest
* (Tink AEAD) custody of the cookie.
*
* ### Breaking the construction cycle (A11's frozen constructor)
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
* client needs the repository's SSL material — a cycle. It is broken with an explicit shared
@@ -53,22 +60,40 @@ public object NetworkModule {
ClientIdentity(material.sslSocketFactory, material.trustManager)
}
/** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */
/**
* The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool] and
* carrying the ONE [CookieJar] ([AuthCookieModule]).
*
* The cookie jar is what makes the optional `WEBTERM_TOKEN` gate work: a gated host answers pairing
* with `Set-Cookie: webterm_auth=…` and then requires that cookie on every remote route AND on the WS
* upgrade. Because both transports share this client, installing the jar HERE covers both at once
* (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Leaving it at `CookieJar.NO_COOKIES` — as
* this provider did before — makes the whole token feature inert.
*/
@Provides
@Singleton
public fun provideOkHttpClient(
identityProvider: ClientIdentityProvider,
connectionPool: ConnectionPool,
cookieJar: CookieJar,
): OkHttpClient =
OkHttpClientFactory.create(identityProvider)
OkHttpClientFactory.create(identityProvider, cookieJar)
.newBuilder()
.connectionPool(connectionPool)
.build()
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */
/**
* WS transport over the shared client (it derives a streaming-tuned variant sharing the pool).
*
* [tokens] ([AuthModule]) is what makes the **WS upgrade** carry `Cookie: webterm_auth=<t>` — the
* REST half gets its cookie from `:api-client`'s single stamping point instead (B5 / §1.1).
*/
@Provides
@Singleton
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client)
public fun provideTermTransport(
client: OkHttpClient,
tokens: AccessTokenSource,
): TermTransport = OkHttpTermTransport(client, tokens)
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
@Provides

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,10 @@ public fun PairingPane(
runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false)
}
},
// B5 · the access-token half: validated with POST /auth, then kept in the Keystore-backed store
// the transports read from.
tokenStore = env.accessTokenStore,
authProber = env.buildAccessTokenProber(),
)
}
LaunchedEffect(viewModel) { viewModel.bind(this) }
@@ -189,7 +193,8 @@ public fun DiffPane(
}
val viewModel = remember(resolved, path) {
DiffViewModel(
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport),
// The hand-built diff route needs the access-token cookie too (B5 / §1.1).
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport, env.accessTokenStore),
path = path,
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -38,6 +39,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
@@ -50,8 +54,10 @@ import com.google.mlkit.vision.common.InputImage
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.viewmodels.EntryError
import wang.yaojia.webterm.viewmodels.PairingCopy
import wang.yaojia.webterm.viewmodels.PairingUiState
import wang.yaojia.webterm.viewmodels.PairingViewModel
import wang.yaojia.webterm.viewmodels.TokenError
import wang.yaojia.webterm.viewmodels.WarningTier
import wang.yaojia.webterm.viewmodels.needsExplicitAck
@@ -65,16 +71,26 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck
* requires an explicit acknowledge, and a tunnel host routes to the device-cert screen when the probe is
* cert-gated. Every displayed URL/host is INERT [Text] (no autolink/markdown, plan §8).
*
* ### Access token (B5 / ios-completion §1.1)
* The confirm step carries an OPTIONAL access-token field (masked, with a reveal toggle). The typed token
* never enters the ViewModel's UI state — it is passed straight into `confirm()/retry()`, validated by
* `POST /auth`, and (only when the server accepts it) stored in the Keystore-backed store. A wrong token /
* rate-limit / "this host requires a token" comes back as an inert [TokenError] under the field.
*
* ### Permissions
* - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to
* manual entry (the QR path is never mandatory).
* - **POST_NOTIFICATIONS** is NOT requested here — push registration owns that prompt (A31); pairing
* only establishes the host.
*
* All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is
* JVM-tested in `PairingViewModelTest`.
* A host behind the optional `WEBTERM_TOKEN` gate cannot answer the probe at all until it is
* authenticated, so the flow has one more step: the masked access-token prompt (see [TokenRequiredStep]).
*
* @param viewModel the pairing state machine (nav layer builds it with the real [pairingProber]).
* All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is
* JVM-tested in `PairingViewModelTest` + `PairingTokenFlowTest`.
*
* @param viewModel the pairing state machine (the nav layer builds it with the real prober from
* `AppEnvironment.buildPairingProber()`).
* @param onPaired invoked once with the persisted [Host] after a successful pair.
* @param onImportCert opens the device-cert screen (A27) when a tunnel host is cert-gated.
*/
@@ -104,7 +120,7 @@ public fun PairingScreen(
is PairingUiState.Confirming -> ConfirmStep(
state = s,
onName = viewModel::setName,
onConfirm = viewModel::confirm,
onConfirm = { ack, token -> viewModel.confirm(acknowledgedPublicRisk = ack, accessToken = token) },
onCancel = viewModel::reset,
)
is PairingUiState.Probing -> ProbingStep(host = s.name)
@@ -114,6 +130,13 @@ public fun PairingScreen(
onRetry = { viewModel.retry() },
onCancel = viewModel::reset,
)
is PairingUiState.TokenRequired -> TokenRequiredStep(
host = s.name,
error = s.error,
onSubmit = viewModel::submitAccessToken,
onCancel = viewModel::reset,
)
is PairingUiState.TokenSubmitting -> BusyStep(message = "正在验证访问令牌 …")
is PairingUiState.Failed -> FailedStep(
message = s.message,
needsAck = s.tier.needsExplicitAck,
@@ -199,6 +222,9 @@ private fun QrScanner(onScanned: (String) -> Unit) {
}
}
// QrCodeAnalyzer is @ExperimentalGetImage (it reads ImageProxy.image to feed ML Kit); constructing it
// here propagates that requirement to this call site, so the opt-in has to be declared here too.
@androidx.annotation.OptIn(ExperimentalGetImage::class)
@Composable
private fun CameraPreview(onScanned: (String) -> Unit) {
val context = LocalContext.current
@@ -228,10 +254,16 @@ private fun CameraPreview(onScanned: (String) -> Unit) {
private fun ConfirmStep(
state: PairingUiState.Confirming,
onName: (String) -> Unit,
onConfirm: (Boolean) -> Unit,
onConfirm: (Boolean, String) -> Unit,
onCancel: () -> Unit,
) {
var acknowledged by remember(state.endpoint) { mutableStateOf(false) }
// The token lives ONLY here (and in the encrypted store once accepted) — never in the ViewModel's
// UI state, so it cannot leak through a state snapshot / crash report (§1.1). Keyed on the endpoint
// so a token typed for one host is dropped when the candidate changes, but survives a re-render
// after a wrong-token error (the field keeps what the user typed).
var token by remember(state.endpoint) { mutableStateOf("") }
var tokenVisible by remember(state.endpoint) { mutableStateOf(false) }
TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged)
// The dialed URL is validated but user/QR-sourced — render INERT (plan §8).
@@ -249,6 +281,14 @@ private fun ConfirmStep(
modifier = Modifier.fillMaxWidth(),
)
AccessTokenField(
token = token,
onToken = { token = it },
visible = tokenVisible,
onToggleVisible = { tokenVisible = !tokenVisible },
error = state.tokenError,
)
if (state.tier.needsExplicitAck) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
@@ -259,13 +299,58 @@ private fun ConfirmStep(
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
Button(
onClick = { onConfirm(acknowledged) },
onClick = { onConfirm(acknowledged, token) },
enabled = !state.tier.needsExplicitAck || acknowledged,
modifier = Modifier.weight(1f),
) { Text("连接") }
}
}
/**
* The optional access-token field (B5 / ios-completion §1.1). Masked by default with an explicit
* reveal toggle (a token is often typed from another screen and mistypes are the #1 failure), and
* `KeyboardType.Password` + `autoCorrectEnabled = false` so the IME never learns or suggests it.
* [error] renders the inert, app-authored copy for the typed [TokenError].
*/
@Composable
private fun AccessTokenField(
token: String,
onToken: (String) -> Unit,
visible: Boolean,
onToggleVisible: () -> Unit,
error: TokenError?,
) {
OutlinedTextField(
value = token,
onValueChange = onToken,
label = { Text("访问令牌可选WEBTERM_TOKEN") },
singleLine = true,
isError = error != null,
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
autoCorrectEnabled = false,
),
trailingIcon = {
TextButton(onClick = onToggleVisible) { Text(if (visible) "隐藏" else "显示") }
},
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Text(
text = PairingCopy.describeToken(error),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
} else {
Text(
text = "主机未设置 WEBTERM_TOKEN 时留空即可。",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun TierWarningCard(tier: WarningTier, highlight: Boolean) {
val (title, body) = tierCopy(tier)
@@ -292,14 +377,82 @@ private fun tierCopy(tier: WarningTier): Pair<String, String> = when (tier) {
@Composable
private fun ProbingStep(host: String) {
BusyStep(message = "正在验证 $host")
}
@Composable
private fun BusyStep(message: String) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
CircularProgressIndicator()
Text("正在验证 $host", style = MaterialTheme.typography.bodyMedium)
Text(message, style = MaterialTheme.typography.bodyMedium)
}
}
}
// ── Access token (the optional WEBTERM_TOKEN gate) ───────────────────────────────────────────────────
/**
* The access-token prompt for a `WEBTERM_TOKEN`-gated host.
*
* The token is a **shell credential**, so the field is masked ([PasswordVisualTransformation]) and typed
* as [KeyboardType.Password] (which also keeps it out of the keyboard's suggestion/learning store), and
* the composition drops it the instant it is handed to the ViewModel — nothing here, and nothing in the
* resulting UI state, retains it. All copy is app-authored and inert (plan §8).
*/
@Composable
private fun TokenRequiredStep(
host: String,
error: TokenError?,
onSubmit: (String) -> Unit,
onCancel: () -> Unit,
) {
var token by remember(host) { mutableStateOf("") }
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(Spacing.md12),
) {
Column(modifier = Modifier.padding(Spacing.md12), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text("需要访问令牌", style = MaterialTheme.typography.titleSmall)
Text(
text = "$host 启用了访问令牌WEBTERM_TOKEN。输入令牌后即可继续配对令牌只用于本次验证不会显示或记录。",
style = MaterialTheme.typography.bodySmall,
)
}
}
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text("访问令牌") },
singleLine = true,
isError = error != null,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Text(
text = PairingCopy.describeToken(error),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
Button(
onClick = {
// Hand it over and forget it in the same breath — never leave the credential in state.
val submitted = token
token = ""
onSubmit(submitted)
},
enabled = token.isNotBlank(),
modifier = Modifier.weight(1f),
) { Text("提交") }
}
}
@Composable
private fun CertRequiredStep(
host: String,

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,8 @@ import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
@@ -439,14 +441,23 @@ public class DiffUnavailable(public val status: Int) : Exception("diff unavailab
/**
* Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly.
* Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3).
*
* This route is hand-built (it is not one of `:api-client`'s 12), so the access-token cookie must be
* stamped HERE too: the server's auth gate covers EVERY route, and a missing cookie 401s (B5 /
* ios-completion §1.1). The value comes from the single derivation point ([AuthCookie]); [tokens] is
* the same store the transports read, and a host with no token adds no header at all.
*/
public class HttpDiffFetcher(
private val endpoint: HostEndpoint,
private val http: HttpTransport,
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
) : DiffFetcher {
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
val headers = tokens.tokenFor(endpoint)
?.let { mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(it)) }
?: emptyMap()
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url, headers = headers))
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
return decodeDiffResult(response.body)
}

View File

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

View File

@@ -1,16 +1,23 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.api.pairing.AuthProbeResult
import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.api.pairing.probeAccessToken
import wang.yaojia.webterm.api.pairing.runPairingProbe
import wang.yaojia.webterm.hostregistry.AccessTokenStore
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier
@@ -35,22 +42,64 @@ import java.util.UUID
* 3. **Public host needs an explicit acknowledge.** A [WarningTier.PUBLIC] host cannot be probed until
* the user ticks the risk acknowledge ([confirm]`(acknowledgedPublicRisk = true)`); otherwise the
* confirm is a no-op that re-arms the reminder.
* 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** Both [confirm] and [retry]
* funnel through the ONE private [runProbe]; for a [WarningTier.TUNNEL] host with no installed
* device cert, [runProbe] refuses BEFORE any network I/O and re-refuses on every retry (plan §8
* "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed").
* 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** [confirm], [retry] and the
* post-token resume all funnel through the ONE private `performProbe`; for a [WarningTier.TUNNEL]
* host with no installed device cert it refuses BEFORE any network I/O and re-refuses on every
* retry (plan §8 "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is
* installed").
*
* On probe success the validated host is persisted via [HostStore.upsert].
*
* ### The `WEBTERM_TOKEN` gate has TWO ways in, and both end in the same place
* A token-gated host can be met either way round, so both are supported:
* - **Typed up front.** The confirm step has an optional token field; [confirm]/[retry] carry it into
* RULE 5 below (validate with `POST /auth`, store, then probe).
* - **Discovered by the probe.** A host the user did not know was gated answers the probe's
* unauthenticated `GET /live-sessions` with **401**. When the probe classifies that as
* [PairingError.AccessTokenRequired] the user goes back to the confirm step with
* [TokenError.REQUIRED] under the field. An older/ambiguous host instead collapses the 401 into
* [PairingError.HttpOkButNotWebTerminal] ("端口对吗?"), which is actively misleading — so that ONE
* case is re-checked against [PairingProber.requiresAccessToken] and, if the host really is gated,
* routed to the dedicated [PairingUiState.TokenRequired] prompt. [submitAccessToken] posts the token
* (`POST /auth`), which lands the `webterm_auth` cookie in the shared cookie jar, keeps the token in
* [tokenStore] so the WS upgrade's own `Cookie` header can carry it too, then resumes pairing through
* the same choke point.
* Either way the token is a SHELL credential: it is a parameter, never a field and never part of any UI
* state.
*
* ### RULE 5 — the access token is validated and stored BEFORE the probe (B5, ios-completion §1.1)
* When the user typed a token, [confirm]/[retry] first validate it with `POST /auth` ([authProber]) and,
* only if the server ACCEPTED it (204 **with** `Set-Cookie`), persist it to the Keystore-backed
* [tokenStore]. That ordering is load-bearing: the two-step probe's own HTTP legs AND its WS upgrade
* read the token back out of that same store, so they can only authenticate if the token is already in
* it. A 204 **without** `Set-Cookie` means the host has no auth at all — nothing is stored and pairing
* continues (never treated as "authenticated"). A wrong token / rate-limit / malformed token keeps the
* user on the confirm step with a typed [TokenError] and NEVER reaches the network probe.
*
* ### RULE 6 — a pairing that does not end in [PairingUiState.Paired] strands no secret (F2)
* Storing before probing means the store can be left holding a token for a host that never paired (wrong
* port, unreachable, user backs out). So every non-paired exit — probe failure, a throw, and cancellation
* by [reset] or by a superseding attempt — rolls the store back to exactly what it held before this
* attempt ([TokenAttempt]): the previous token for a re-pair, nothing at all for a first pairing. The
* rollback runs under [NonCancellable] so an abandoned attempt still cleans up, and the next attempt
* `join`s the one it supersedes so the two never race on the same key.
*
* The token itself never enters [PairingUiState] — it lives in the screen's own field and is passed in
* per call, so no state snapshot, log or crash report can carry it.
*
* @param hostStore where a successfully-paired [Host] is saved.
* @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests.
* @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO).
* @param tokenStore per-host access-token storage (production: the Tink/AndroidKeystore store).
* @param authProber the `POST /auth` validation seam ([accessTokenProber] in production).
* @param newId host-id allocator (default random UUID; deterministic in tests).
*/
public class PairingViewModel(
private val hostStore: HostStore,
private val prober: PairingProber,
private val hasDeviceCert: suspend () -> Boolean,
private val tokenStore: AccessTokenStore,
private val authProber: AccessTokenProber,
private val newId: () -> String = { UUID.randomUUID().toString() },
) {
private val _uiState = MutableStateFlow<PairingUiState>(PairingUiState.Entry())
@@ -111,56 +160,252 @@ public class PairingViewModel(
/**
* Confirm the on-screen candidate and probe it. For a public host, [acknowledgedPublicRisk] MUST be
* true or this re-arms the acknowledge reminder without touching the network (RULE 3).
* [accessToken] is the (optional) `WEBTERM_TOKEN` the user typed — blank means "this host has none".
*/
public fun confirm(acknowledgedPublicRisk: Boolean = false) {
public fun confirm(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") {
val candidate = _uiState.value.candidate() ?: return
runProbe(candidate, acknowledgedPublicRisk)
runProbe(candidate, acknowledgedPublicRisk, accessToken)
}
/**
* Retry after a failure or a cert-gate refusal. Funnels through the SAME [runProbe] choke point as
* [confirm] — so the tunnel cert-gate (RULE 4) cannot be bypassed by retrying.
*/
public fun retry(acknowledgedPublicRisk: Boolean = false) {
public fun retry(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") {
val candidate = _uiState.value.candidate() ?: return
runProbe(candidate, acknowledgedPublicRisk)
runProbe(candidate, acknowledgedPublicRisk, accessToken)
}
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
val tier = candidate.tier
// RULE 3 — public host: no probe until the risk is explicitly acknowledged.
if (tier.needsExplicitAck && !acknowledgedPublicRisk) {
/**
* Submit the host's shared access token (`WEBTERM_TOKEN`) from the token prompt. 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.
*
* This is the DISCOVERED half of the gate; the typed-up-front half is [confirm]'s `accessToken`
* (RULE 5). [token] goes straight to the prober's `POST /auth` and is never held in a field or in
* UI state. On acceptance the shared cookie jar holds `webterm_auth` AND the token is kept in
* [tokenStore] — so the WS upgrade's own `Cookie` header authenticates exactly as it would for a
* typed token — and pairing resumes through the same [performProbe] choke point as [confirm]/[retry]
* (so RULE 4's cert-gate still applies, under RULE 6's rollback). 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
val superseded = probeJob
superseded?.cancel()
probeJob = scope.launch {
superseded?.join() // same reason as runProbe: never race a superseded attempt's rollback.
_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.
// This submit WAS the `POST /auth` validation, so RULE 5 only has to KEEP the token.
TokenSubmission.Accepted -> performProbe(candidate) {
writeToken(candidate, token) { error -> promptForToken(candidate, error); null }
}
// 204 with no Set-Cookie: the host has no auth at all (FROZEN §1.1). Store NOTHING and
// never read it as "authenticated" — same rule as `establishToken`'s AuthDisabled.
TokenSubmission.AuthDisabled -> performProbe(candidate) { TokenAttempt.NONE }
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, accessToken: String) {
// RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the
// pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe.
if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) {
_uiState.value = PairingUiState.Confirming(
endpoint = candidate.endpoint,
name = candidate.name,
tier = tier,
tier = candidate.tier,
awaitingAck = true,
)
return
}
val scope = scope ?: return
probeJob?.cancel()
val superseded = probeJob
superseded?.cancel()
probeJob = scope.launch {
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry re-hits this same guard.
if (tier.isCertGated && !hasDeviceCert()) {
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
return@launch
}
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
when (val result = prober.probe(candidate.endpoint)) {
is PairingProbeResult.Success -> onProbeSuccess(candidate)
is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed(
endpoint = candidate.endpoint,
name = candidate.name,
tier = tier,
error = result.error,
message = PairingCopy.describe(result.error, tier),
)
// Wait out the attempt this one replaces: its RULE 6 rollback must finish before we read or
// write the same store key, or the two attempts race and the loser's undo wins.
superseded?.join()
// RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store).
performProbe(candidate) {
if (accessToken.isBlank()) TokenAttempt.NONE else establishToken(candidate, accessToken)
}
}
}
private suspend fun onProbeSuccess(candidate: Candidate) {
/**
* The ONE probe body — reached from [confirm]/[retry] via [runProbe] and from an accepted access
* token ([submitAccessToken]). Holds RULE 4 (the cert-gate) so BOTH entry points hit it BEFORE any
* network I/O, then RULE 5's [tokenStep] and RULE 6's rollback; RULE 3 (the public-risk ack) is a
* pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns
* [probeJob], which would cancel the coroutine it is called from).
*
* @param tokenStep the RULE 5 step for this entry point — validate+store a typed token, or merely
* store one the prompt already validated. Returns the [TokenAttempt] to undo, or **null** after
* publishing why pairing stops (meaning "do not probe").
*/
private suspend fun performProbe(candidate: Candidate, tokenStep: suspend () -> TokenAttempt?) {
val tier = candidate.tier
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both
// re-hit this same guard.
if (tier.isCertGated && !hasDeviceCert()) {
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
return
}
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
val attempt = tokenStep() ?: return
// RULE 6 — anything short of Paired puts the store back the way this attempt found it.
val paired = try {
when (val result = prober.probe(candidate.endpoint)) {
is PairingProbeResult.Success -> onProbeSuccess(candidate)
is PairingProbeResult.Failure -> {
onProbeFailure(candidate, result.error)
false
}
}
} catch (error: Throwable) {
// Cancelled (reset / a newer attempt) or a transport throw — the rule is the same, and
// NonCancellable is what lets an already-cancelled attempt still clean up after itself.
withContext(NonCancellable) { rollBackToken(candidate, attempt) }
throw error
}
if (!paired) rollBackToken(candidate, attempt)
}
/**
* Validate [token] with `POST /auth` and, if the server accepted it, persist it for this host.
* Returns the [TokenAttempt] to undo should pairing not complete (RULE 6) — [TokenAttempt.NONE] when
* the host turned out to have no auth at all — or **null** after publishing the matching
* [TokenError] / failure state, meaning "stop, do not probe".
*/
private suspend fun establishToken(candidate: Candidate, token: String): TokenAttempt? {
val outcome = try {
authProber.validate(candidate.endpoint, token)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
// A network-level failure is not a token problem — classify it like any other probe error.
onProbeFailure(candidate, PairingError.classify(error, candidate.endpoint))
return null
}
return when (outcome) {
AuthProbeResult.Accepted -> writeToken(candidate, token) { failToken(candidate, it) }
// 204 with no Set-Cookie: the host has no auth. Store NOTHING (never "assume authenticated")
// and carry on — an open host pairs exactly as it did before this feature.
AuthProbeResult.AuthDisabled -> TokenAttempt.NONE
AuthProbeResult.InvalidToken -> failToken(candidate, TokenError.INVALID)
AuthProbeResult.RateLimited -> failToken(candidate, TokenError.RATE_LIMITED)
AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED)
is AuthProbeResult.Unexpected -> failToken(candidate, TokenError.UNVERIFIABLE)
}
}
/**
* Persist [token], remembering what the store held before so RULE 6 can put it back. [onFailure]
* publishes the reason in whichever step the caller is showing (the confirm field, or the token
* prompt) and returns null, so a token we could not keep never reaches the probe.
*/
private suspend fun writeToken(
candidate: Candidate,
token: String,
onFailure: (TokenError) -> TokenAttempt?,
): TokenAttempt? {
return try {
val previous = tokenStore.tokenFor(candidate.endpoint)
// put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT).
if (!tokenStore.put(candidate.endpoint, token)) onFailure(TokenError.MALFORMED)
else TokenAttempt(stored = true, previous = previous)
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
// A throw ⇒ encrypted storage failed; never continue with a token we could not keep.
onFailure(TokenError.STORE_FAILED)
}
}
/**
* Undo [attempt]'s write: restore the previous token (a failed re-pair must not cost the user the
* one that was working) or drop the key entirely when this host had none. Never publishes state —
* the caller has already published the reason pairing stopped.
*/
private suspend fun rollBackToken(candidate: Candidate, attempt: TokenAttempt) {
if (!attempt.stored) return
try {
val previous = attempt.previous
if (previous == null) tokenStore.remove(candidate.endpoint)
else tokenStore.put(candidate.endpoint, previous)
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
// Best effort: encrypted storage just failed on the undo. There is no recovery beyond this,
// and surfacing it would replace the pairing error the user actually needs to read.
}
}
/** Publish a token-specific error, keeping the user ON the confirm step (the field is there). */
private fun failToken(candidate: Candidate, error: TokenError): TokenAttempt? {
_uiState.value = PairingUiState.Confirming(
endpoint = candidate.endpoint,
name = candidate.name,
tier = candidate.tier,
tokenError = error,
)
return null
}
/**
* A failed probe, with TWO token-gate readings:
* - [PairingError.AccessTokenRequired] — the probe saw the gate's own 401, so the user goes back to
* the confirm step with the token field and [TokenError.REQUIRED];
* - [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a `WEBTERM_TOKEN`-gated
* host's 401 collapses into on a host the taxonomy cannot classify — so that one case is
* re-checked against [PairingProber.requiresAccessToken] and, when the host really is gated,
* routed to the dedicated prompt instead of the misleading "wrong port?" copy.
* Every other error is reported verbatim.
*/
private suspend fun onProbeFailure(candidate: Candidate, error: PairingError) {
// The host demands a token we do not have → back to the confirm step to enter one.
if (error == PairingError.AccessTokenRequired) {
failToken(candidate, TokenError.REQUIRED)
return
}
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),
)
}
/** Publish the dedicated token prompt (the discovered-gate step), optionally with a reason. */
private fun promptForToken(candidate: Candidate, error: TokenError?) {
_uiState.value = PairingUiState.TokenRequired(
endpoint = candidate.endpoint,
name = candidate.name,
tier = candidate.tier,
error = error,
)
}
/** Persist the paired host. Returns false when it could not be paired (so RULE 6 undoes the token). */
private suspend fun onProbeSuccess(candidate: Candidate): Boolean {
val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl)
if (host == null) {
// Unreachable in practice (the endpoint already validated), but never persist a null host.
@@ -171,21 +416,103 @@ public class PairingViewModel(
error = PairingError.HttpOkButNotWebTerminal,
message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier),
)
return
return false
}
hostStore.upsert(host)
_uiState.value = PairingUiState.Paired(host)
return true
}
private fun defaultName(endpoint: HostEndpoint): String =
HostClassifier.hostOf(endpoint).ifEmpty { endpoint.baseUrl }
}
/** The two-step pairing probe seam ([wang.yaojia.webterm.api.pairing.runPairingProbe]); faked in tests. */
/**
* 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 — which
* also leaves exactly one abstract member, so this stays a `fun interface` and a test/production
* binding can be written as `PairingProber { endpoint -> … }`. Their defaults are deliberately the
* honest "this seam cannot reach the token gate" answers — `false` and [TokenSubmission.Unavailable] —
* never a silent success.
*/
public fun interface PairingProber {
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
/**
* Does [endpoint] answer an unauthenticated read with the server's `401 authentication required`
* (`src/server.ts` `authGate`)? Only consulted to disambiguate
* [PairingError.HttpOkButNotWebTerminal].
*
* Implementations MUST NOT throw for a network failure — an unreachable host is `false` ("not known
* to be gated"), so the app never prompts for a credential because a host is merely down.
*/
public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = false
/**
* `POST /auth` with [token]. On [TokenSubmission.Accepted] the shared cookie jar holds the host's
* `webterm_auth` cookie, so the very next probe/attach is authenticated.
*
* [token] is a SHELL CREDENTIAL: implementations must put it in the request BODY only — never a URL,
* never a log, never an exception message — and must not retain it.
*/
public suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission =
TokenSubmission.Unavailable
}
/**
* Outcome of `POST /auth` (server `src/http/auth.ts` + the route in `src/server.ts`). A wrong token
* ([Rejected]) and a host that did not answer ([Unreachable]) are DISTINCT cases on purpose — collapsing
* them would tell the user to re-type a token that was fine.
*/
public sealed interface TokenSubmission {
/** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */
public data object Accepted : TokenSubmission
/**
* **2xx with NO `Set-Cookie: webterm_auth=…`** — this host has `WEBTERM_TOKEN` unset, so there is
* nothing to authenticate (FROZEN §1.1; the same discriminator as [AuthProbeResult.AuthDisabled] on
* the typed-token path). The submitted token MUST NOT be persisted and this MUST NOT be read as
* "authenticated": the host is simply open, and pairing continues exactly as for a zero-config LAN
* deploy.
*/
public data object AuthDisabled : 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
}
/**
* The `POST /auth` access-token validation seam ([probeAccessToken]); faked in tests. Kept separate from
* [PairingProber] so the ORDER (validate+store, then probe) is observable in a unit test.
*/
public fun interface AccessTokenProber {
public suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult
}
/**
* Production [AccessTokenProber]: the frozen one-shot `POST /auth` probe over the ONE shared
* `OkHttpClient`'s [HttpTransport]. The token travels in the JSON body only — never a URL, never a log.
*/
public fun accessTokenProber(http: HttpTransport): AccessTokenProber =
AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, http, token) }
/**
* Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
@@ -193,8 +520,12 @@ public fun interface PairingProber {
* `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 =
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) }
public fun pairingProber(
http: HttpTransport,
ws: TermTransport,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProber =
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws, tokens) }
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
@@ -265,6 +596,11 @@ public sealed interface PairingUiState {
val name: String,
val tier: WarningTier,
val awaitingAck: Boolean = false,
/**
* Set when the access-token step failed (or the host demands one). The token VALUE is never
* carried here — only why it went wrong (§1.1: a secret never enters app state).
*/
val tokenError: TokenError? = null,
) : PairingUiState
/** The two-step probe is running against [endpoint]. */
@@ -276,6 +612,27 @@ public sealed interface PairingUiState {
*/
public data class CertRequired(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState
/**
* The host is behind the optional `WEBTERM_TOKEN` gate and needs its shared access token before
* pairing can continue. [error] is null on the first prompt and set after a failed submit.
*
* This state deliberately carries NO token field — the credential is a
* [PairingViewModel.submitAccessToken] parameter and must not survive the call (plan §8).
*/
public data class TokenRequired(
val endpoint: HostEndpoint,
val name: String,
val tier: WarningTier,
val error: TokenError? = null,
) : PairingUiState
/** A submitted access token is in flight (`POST /auth`). Carries no token, for the same reason. */
public data class TokenSubmitting(
val endpoint: HostEndpoint,
val name: String,
val tier: WarningTier,
) : PairingUiState
/** The probe failed; [message] is inert, app-authored copy for [error]. Retryable. */
public data class Failed(
val endpoint: HostEndpoint,
@@ -295,19 +652,95 @@ public enum class EntryError {
INVALID_URL,
}
/**
* Why the access-token step failed. Rendered next to the token field on the confirm step (the typed-token
* path) or under the dedicated [PairingUiState.TokenRequired] prompt (the discovered-gate path), so the
* user can fix it in place (B5 / ios-completion §1.1).
*/
public enum class TokenError {
/** The host answered 401 to the probe: it HAS a token configured and we presented none/a wrong one. */
REQUIRED,
/** `POST /auth` → 401: the typed/submitted token is wrong. */
INVALID,
/**
* `POST /auth` → **429**: the server's 10/min/IP limiter tripped. ONE case for both entry points —
* the typed-token path and the prompt's submit path hit the same limiter on the same route, so two
* enum constants with two different strings were the merge keeping both branches' names for one
* server answer. Surfaced as-is; the app NEVER auto-retries.
*/
RATE_LIMITED,
/** The typed token cannot be a server token at all (charset / 16512 length). No request was sent. */
MALFORMED,
/** `POST /auth` answered something unexpected — the token could not be verified either way. */
UNVERIFIABLE,
/** The host did not answer the submit (or answered unintelligibly) — distinct from a wrong token. */
UNREACHABLE,
/** The app cannot submit a token at all on this seam (a probe-only prober). */
UNAVAILABLE,
/** The token was correct but encrypted on-device storage refused to keep it. */
STORE_FAILED,
}
/** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */
private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier)
/**
* What one pairing attempt wrote to the access-token store, and therefore what has to be put back if the
* attempt does not end in [PairingUiState.Paired] (RULE 6). [previous] is what the store held for this
* host beforehand — non-null only for a re-pair, whose working token a failed attempt must not cost.
*/
private data class TokenAttempt(val stored: Boolean, val previous: String?) {
companion object {
/** No token was written (none typed, or the host has no auth) — nothing to undo. */
val NONE: TokenAttempt = TokenAttempt(stored = false, previous = null)
}
}
private fun PairingUiState.candidate(): Candidate? = when (this) {
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
is PairingUiState.CertRequired -> Candidate(endpoint, name, tier)
is PairingUiState.TokenRequired -> Candidate(endpoint, name, tier)
is PairingUiState.TokenSubmitting -> Candidate(endpoint, name, tier)
is PairingUiState.Failed -> Candidate(endpoint, name, tier)
is PairingUiState.Entry, is PairingUiState.Paired -> null
}
/** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */
internal object PairingCopy {
/**
* Inert, app-authored copy for each [TokenError]. Never echoes the token itself.
*
* A wrong token, an unreachable host and a rate-limit read differently on purpose — collapsing them
* would tell the user to re-type a token that was fine — and the rate-limit line tells them to WAIT,
* because the app never retries by itself.
*/
fun describeToken(error: TokenError): String = when (error) {
TokenError.REQUIRED ->
"该主机启用了访问令牌WEBTERM_TOKEN。请填写访问令牌后再连接。"
TokenError.INVALID ->
"访问令牌不正确。请核对主机上的 WEBTERM_TOKEN 后重试(区分大小写)。"
TokenError.RATE_LIMITED ->
"尝试次数过多,主机已暂时限流(每分钟最多 10 次。请等待约一分钟再试——App 不会自动重试。"
TokenError.MALFORMED ->
"访问令牌格式不合法:需 16512 个字符,且只能包含 AZ az 09 . _ ~ + / = -。"
TokenError.UNVERIFIABLE ->
"无法验证访问令牌:服务器返回了意外响应。请确认地址和端口指向 web-terminal。"
TokenError.UNREACHABLE ->
"提交访问令牌时主机没有正常响应。请检查网络与地址后重试。"
TokenError.UNAVAILABLE ->
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
TokenError.STORE_FAILED ->
"无法在本机安全保存访问令牌(加密存储写入失败)。请重试。"
}
fun describe(error: PairingError, tier: WarningTier): String = when (error) {
is PairingError.HostUnreachable ->
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
@@ -315,12 +748,27 @@ internal object PairingCopy {
"这个端口在运行别的服务,不是 web-terminal。端口对吗"
is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived
is PairingError.CleartextBlocked ->
"系统阻止了到 ${error.host} 的明文连接。仅局域网 / Tailscale 地址允许 ws:// 明文。"
// Two distinct refusals collapse into this one error case (both surface as
// UnknownServiceException, which is what `PairingError.classify` keys on):
// 1. the PLATFORM's network_security_config, which pins the tunnel apex to TLS because it
// always serves a real LE cert (LAN cleartext itself is permitted — the config format has
// no CIDR syntax, so it could not be scoped to RFC1918);
// 2. the app's OWN transport-level cleartext guard (:transport-okhttp
// CleartextNotPermittedException), which is where the CIDR scoping actually happens: a
// plaintext dial to a PUBLIC (or unclassifiable) host is refused before it leaves the
// device. The tier tells the two apart, so each gets copy the user can act on.
if (tier == WarningTier.TUNNEL) {
"${error.host} 必须使用 https / wss —— 该主机由设备证书保护,不允许降级为明文。"
} else {
"应用拒绝以明文连接 ${error.host}http:// 与 ws:// 只允许用于本机、局域网和 Tailscale 地址。" +
"公网主机请改用 https:// / wss://(隧道或 Tailscale"
}
PairingError.TlsFailure ->
// Tunnel hosts present a real LE server cert, so a TLS failure there means OUR client cert
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。"
else "TLS 握手失败:无法验证服务器证书。"
PairingError.AccessTokenRequired -> describeToken(TokenError.REQUIRED)
PairingError.Timeout ->
"连接超时。主机可能不可达,或响应过慢。"
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package wang.yaojia.webterm.wiring
import dagger.Lazy
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpTransport
import javax.inject.Inject
@@ -23,6 +24,7 @@ import javax.inject.Singleton
@Singleton
public class ApiClientFactory @Inject constructor(
private val http: Lazy<HttpTransport>,
private val tokens: AccessTokenSource,
) {
/**
* Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs
@@ -32,6 +34,14 @@ public class ApiClientFactory @Inject constructor(
http.get()
}
/** Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. */
public fun create(endpoint: HostEndpoint): ApiClient = ApiClient(endpoint = endpoint, http = http.get())
/**
* Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read.
*
* Every client is handed the shared [AccessTokenSource], so EVERY REST route — including the
* read-only GETs and the push-decision POSTs — presents this host's access token when one is stored
* (B5 / ios-completion §1.1). The token is read per request, so pairing a host mid-run takes effect
* without rebuilding anything.
*/
public fun create(endpoint: HostEndpoint): ApiClient =
ApiClient(endpoint = endpoint, http = http.get(), tokens = tokens)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
B5 / ios-completion §1.1 — the app holds SECRET material on device: the per-host access token
(`WEBTERM_TOKEN`, Tink-encrypted in webterm_access_token_prefs) and the mTLS device identity
(AndroidKeyStore key + Tink-encrypted cert blob). None of it may leave the device.
`android:allowBackup="false"` already opts out of cloud backup / adb backup on every API level; this
file additionally covers API 31+ cloud-backup and device-to-device TRANSFER, where a plain
allowBackup=false does NOT by itself block transfer. Both sections exclude everything.
(The AndroidKeyStore private key is non-exportable regardless, so a transferred blob would be
undecryptable anyway — this is defence in depth, and it keeps the intent explicit.)
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>

View File

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

View File

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

View File

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

View File

@@ -109,4 +109,27 @@ class ReconnectBannerTest {
val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting))
assertEquals(connecting, bannerModel(connecting, Output("some bytes")))
}
// ── 5. B5 · a 401 upgrade is terminal AND offers no new session ───────────────
@Test
fun `an unauthorized failure is terminal with no spinner and no new-session action`() {
val model = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
val failed = model as BannerModel.Failed
assertEquals(FailureReason.UNAUTHORIZED, failed.reason)
assertFalse(failed.showsSpinner, "a 401 is permanent — never show a retry spinner")
assertFalse(
failed.offersNewSession,
"a new session would 401 too; the fix is the access token, not another session",
)
}
@Test
fun `an unauthorized failure outranks later transient events`() {
val failed = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Connecting)))
assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Reconnecting(1, 2.seconds))))
}
}

View File

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

View File

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

View File

@@ -14,6 +14,9 @@ import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint
/**
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
@@ -291,4 +294,35 @@ class DiffViewModelTest {
assertTrue(DiffUiState(canWrite = true).writeEnabled)
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
}
// ── B5 · the hand-built diff route carries the access-token cookie ───────────────────────────
@Test
fun `the real diff fetcher stamps the auth cookie and never an Origin`() = runTest {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
val http = FakeHttpTransport()
val url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0"
http.queueSuccess(url = url, body = """{"files":[]}""".toByteArray())
val fetcher = HttpDiffFetcher(endpoint, http, AccessTokenSource { "0123456789abcdefTOKEN" })
fetcher.fetch("/repo", staged = false, base = null)
val request = http.recordedRequests.single()
assertEquals("webterm_auth=0123456789abcdefTOKEN", request.headers["Cookie"])
assertFalse(request.headers.containsKey("Origin"), "the diff route is read-only")
}
@Test
fun `the real diff fetcher sends no cookie for a host without a token`() = runTest {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
val http = FakeHttpTransport()
http.queueSuccess(
url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0",
body = """{"files":[]}""".toByteArray(),
)
HttpDiffFetcher(endpoint, http).fetch("/repo", staged = false, base = null)
assertFalse(http.recordedRequests.single().headers.containsKey("Cookie"))
}
}

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