diff --git a/android/PROGRESS_ANDROID.md b/android/PROGRESS_ANDROID.md index 4a3a23c..8abad86 100644 --- a/android/PROGRESS_ANDROID.md +++ b/android/PROGRESS_ANDROID.md @@ -372,6 +372,80 @@ 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. + +**Still not done — do not read the section below as saying otherwise:** + - **A34 / A35 have no code.** There is no `:app` androidTest source set and no benchmark module; the + AW6 entry marking them `[x]` reflects them being converted into checklist bullets, not implemented. + - **Device QA remains ~0%.** One emulator smoke screenshot of the pairing screen is the only on-device + evidence in the repo. Every fix above is JVM-verified only. + - Release signing, versioning and minification; the S2 FCM real-handset spike; copy-out via selection. + +--- + ## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1–A36 + S1) landed Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt b/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt index b1f6a3f..76edfa1 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt @@ -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), ) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt b/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt index 1350cbb..396620f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt @@ -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(), diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt b/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt index 0f19099..3a397e3 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt @@ -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 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 = 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) }) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt b/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt index 7be9f9a..e67d4f0 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt @@ -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>(emptyList()) + + /** The palette in display order; empty until [load] (and after a user "delete all"). */ + public val chips: StateFlow> = _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, + 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 = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/AuthCookieModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/AuthCookieModule.kt new file mode 100644 index 0000000..b1545d6 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/AuthCookieModule.kt @@ -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.`) — but the VALUE here is Tink-AEAD ciphertext, never plaintext. + */ + @Provides + @Singleton + public fun provideAuthCookieStore( + dataStore: DataStore, + @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" +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt index ab0165e..ea4cbd6 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt @@ -5,6 +5,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.ConnectionPool +import okhttp3.CookieJar import okhttp3.OkHttpClient import wang.yaojia.webterm.transport.ClientIdentity import wang.yaojia.webterm.transport.ClientIdentityProvider @@ -27,6 +28,11 @@ import javax.inject.Singleton * re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no * client rebuild (R4). The provider is read ONCE, when the client is built. * + * ### The WEBTERM_TOKEN cookie jar + * The shared client also carries the ONE [CookieJar] provided by [AuthCookieModule], so the optional + * access-token gate applies uniformly to REST and to the WS upgrade. See that module for the at-rest + * (Tink AEAD) custody of the cookie. + * * ### Breaking the construction cycle (A11's frozen constructor) * `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the * client needs the repository's SSL material — a cycle. It is broken with an explicit shared @@ -53,14 +59,24 @@ public object NetworkModule { ClientIdentity(material.sslSocketFactory, material.trustManager) } - /** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */ + /** + * The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool] and + * carrying the ONE [CookieJar] ([AuthCookieModule]). + * + * The cookie jar is what makes the optional `WEBTERM_TOKEN` gate work: a gated host answers pairing + * with `Set-Cookie: webterm_auth=…` and then requires that cookie on every remote route AND on the WS + * upgrade. Because both transports share this client, installing the jar HERE covers both at once + * (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Leaving it at `CookieJar.NO_COOKIES` — as + * this provider did before — makes the whole token feature inert. + */ @Provides @Singleton public fun provideOkHttpClient( identityProvider: ClientIdentityProvider, connectionPool: ConnectionPool, + cookieJar: CookieJar, ): OkHttpClient = - OkHttpClientFactory.create(identityProvider) + OkHttpClientFactory.create(identityProvider, cookieJar) .newBuilder() .connectionPool(connectionPool) .build() diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/ThumbnailModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/ThumbnailModule.kt new file mode 100644 index 0000000..f48f6a5 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/ThumbnailModule.kt @@ -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() +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt index 3899926..ba4b69e 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt @@ -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,38 @@ 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(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 — a thumbnail must never outlive the session it depicts. + LaunchedEffect(pipeline, state.rows) { + pipeline?.retainOnly(state.rows.map { it.info }) + } AdaptiveHome( listPane = { @@ -89,6 +142,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, ) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt index 2eb3faa..32069cd 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -38,6 +39,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat @@ -50,8 +53,10 @@ import com.google.mlkit.vision.common.InputImage import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.viewmodels.EntryError +import wang.yaojia.webterm.viewmodels.PairingCopy import wang.yaojia.webterm.viewmodels.PairingUiState import wang.yaojia.webterm.viewmodels.PairingViewModel +import wang.yaojia.webterm.viewmodels.TokenError import wang.yaojia.webterm.viewmodels.WarningTier import wang.yaojia.webterm.viewmodels.needsExplicitAck @@ -71,10 +76,14 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck * - **POST_NOTIFICATIONS** is NOT requested here — push registration owns that prompt (A31); pairing * only establishes the host. * - * All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is - * JVM-tested in `PairingViewModelTest`. + * A host behind the optional `WEBTERM_TOKEN` gate cannot answer the probe at all until it is + * authenticated, so the flow has one more step: the masked access-token prompt (see [TokenRequiredStep]). * - * @param viewModel the pairing state machine (nav layer builds it with the real [pairingProber]). + * All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is + * JVM-tested in `PairingViewModelTest` + `PairingTokenFlowTest`. + * + * @param viewModel the pairing state machine (the nav layer builds it with the real prober from + * `AppEnvironment.buildPairingProber()`). * @param onPaired invoked once with the persisted [Host] after a successful pair. * @param onImportCert opens the device-cert screen (A27) when a tunnel host is cert-gated. */ @@ -114,6 +123,13 @@ public fun PairingScreen( onRetry = { viewModel.retry() }, onCancel = viewModel::reset, ) + is PairingUiState.TokenRequired -> TokenRequiredStep( + host = s.name, + error = s.error, + onSubmit = viewModel::submitAccessToken, + onCancel = viewModel::reset, + ) + is PairingUiState.TokenSubmitting -> BusyStep(message = "正在验证访问令牌 …") is PairingUiState.Failed -> FailedStep( message = s.message, needsAck = s.tier.needsExplicitAck, @@ -295,14 +311,82 @@ private fun tierCopy(tier: WarningTier): Pair = 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, diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt index 094abc2..53ac19c 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt @@ -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 = 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, +) { val scope = rememberCoroutineScope() val phase by worktree.phase.collectAsStateWithLifecycle() var branch by remember { mutableStateOf("") } var base by remember { mutableStateOf("") } var removeTarget by remember { mutableStateOf(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/` 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, +): 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), + ), ) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt index cc9e03c..14ed4ec 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt @@ -43,10 +43,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle 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.SessionListUiState import wang.yaojia.webterm.viewmodels.SessionListViewModel @@ -81,6 +84,12 @@ 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. */ @Composable public fun SessionListScreen( @@ -92,6 +101,8 @@ public fun SessionListScreen( onImportCert: () -> Unit, modifier: Modifier = Modifier, thumbnails: SessionThumbnails? = null, + layoutMode: LayoutMode = LayoutMode.STACK, + onNewSessionInCwd: ((String) -> Unit)? = null, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() @@ -129,6 +140,8 @@ public fun SessionListScreen( onNewSession = onNewSession, onPairHost = onPairHost, thumbnails = thumbnails, + layoutMode = layoutMode, + onNewSessionInCwd = onNewSessionInCwd, ) } } @@ -208,6 +221,8 @@ private fun SessionListBody( onNewSession: () -> Unit, onPairHost: () -> Unit, thumbnails: SessionThumbnails?, + layoutMode: LayoutMode, + onNewSessionInCwd: ((String) -> Unit)?, ) { androidx.compose.material3.pulltorefresh.PullToRefreshBox( isRefreshing = state.isRefreshing, @@ -223,7 +238,14 @@ private fun SessionListBody( actionLabel = "开新会话", onAction = onNewSession, ) - else -> SessionList(state = state, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails) + else -> SessionList( + state = state, + onOpen = onOpen, + onKill = onKill, + thumbnails = thumbnails, + layoutMode = layoutMode, + onNewSessionInCwd = onNewSessionInCwd, + ) } } } @@ -234,6 +256,8 @@ private fun SessionList( onOpen: (UUID) -> Unit, onKill: (UUID) -> Unit, thumbnails: SessionThumbnails?, + layoutMode: LayoutMode, + onNewSessionInCwd: ((String) -> Unit)?, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -244,7 +268,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 +284,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 +296,8 @@ private fun SwipeToKillRow( onOpen: (UUID) -> Unit, onKill: (UUID) -> Unit, thumbnails: SessionThumbnails?, + layoutMode: LayoutMode, + onNewSessionInCwd: ((String) -> Unit)?, ) { val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> @@ -276,7 +314,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) + } } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt index cef28ca..918b359 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt @@ -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,13 +41,24 @@ 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.delay +import kotlinx.coroutines.launch 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.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 @@ -80,10 +93,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 +120,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 +138,10 @@ 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` singleton every other store uses, so + * the palette really persists); a test/preview passes an + * [InMemoryQuickReplyStore][wang.yaojia.webterm.components.InMemoryQuickReplyStore]. */ @Composable public fun TerminalScreen( @@ -127,6 +153,7 @@ public fun TerminalScreen( modifier: Modifier = Modifier, onWarmUp: suspend () -> Unit = {}, onDigestExpand: () -> Unit = {}, + quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(), ) { val context = LocalContext.current val activity = remember(context) { context.findActivity() } @@ -192,13 +219,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 +239,17 @@ 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) } } + // 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). @@ -225,7 +261,11 @@ public fun TerminalScreen( } Column(modifier = modifier.fillMaxSize()) { - TerminalToolbar(title = title, onNewSessionInCwd = requestNewSession) + TerminalToolbar( + title = title, + onNewSessionInCwd = requestNewSession, + onEditQuickReplies = { showPaletteEditor = true }, + ) 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). @@ -246,12 +286,23 @@ 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) + } + } 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). @@ -260,10 +311,31 @@ public fun TerminalScreen( } 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) } + // 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. Dismissing (scrim/back) resolves NOTHING — the // gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in // GateViewModel.decide. @@ -275,9 +347,18 @@ public fun TerminalScreen( /** 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, +) { Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -292,6 +373,7 @@ private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + TextButton(onClick = onEditQuickReplies) { Text("快捷回复") } TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") } } } @@ -340,17 +422,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` 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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt index 9029880..3faf88f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt @@ -17,6 +17,7 @@ import wang.yaojia.webterm.session.Gate import wang.yaojia.webterm.session.GateState 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 @@ -106,13 +107,15 @@ public class GateViewModel( 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) + is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null) else -> Unit // Output / Connection / Adopted are not cockpit concerns. } } 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) { @@ -152,10 +155,29 @@ 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. */ diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt index cb7e232..5fedf1b 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt @@ -8,14 +8,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingProbeResult -import wang.yaojia.webterm.api.pairing.runPairingProbe import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.wire.HostClassifier import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostNetworkTier -import wang.yaojia.webterm.wire.HttpTransport -import wang.yaojia.webterm.wire.TermTransport import java.util.UUID /** @@ -35,13 +32,23 @@ import java.util.UUID * 3. **Public host needs an explicit acknowledge.** A [WarningTier.PUBLIC] host cannot be probed until * the user ticks the risk acknowledge ([confirm]`(acknowledgedPublicRisk = true)`); otherwise the * confirm is a no-op that re-arms the reminder. - * 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** Both [confirm] and [retry] - * funnel through the ONE private [runProbe]; for a [WarningTier.TUNNEL] host with no installed - * device cert, [runProbe] refuses BEFORE any network I/O and re-refuses on every retry (plan §8 - * "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed"). + * 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** [confirm], [retry] and the + * post-token resume all funnel through the ONE private `performProbe`; for a [WarningTier.TUNNEL] + * host with no installed device cert it refuses BEFORE any network I/O and re-refuses on every + * retry (plan §8 "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is + * installed"). * * On probe success the validated host is persisted via [HostStore.upsert]. * + * ### The `WEBTERM_TOKEN` gate (server `src/http/auth.ts`) + * A token-gated host answers the probe's unauthenticated `GET /live-sessions` with **401**, which the + * frozen probe taxonomy can only report as [PairingError.HttpOkButNotWebTerminal] ("端口对吗?") — so + * without this flow such a host is impossible to pair AND the copy is misleading. On that ambiguous + * failure the ViewModel asks [PairingProber.requiresAccessToken]; a gated host routes to + * [PairingUiState.TokenRequired], and [submitAccessToken] posts the token (`POST /auth`) so the shared + * cookie jar picks up the `webterm_auth` cookie, then resumes pairing through the same choke point. + * The token is a SHELL credential: it is a parameter, never a field and never part of any UI state. + * * @param hostStore where a successfully-paired [Host] is saved. * @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests. * @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO). @@ -126,38 +133,102 @@ public class PairingViewModel( runProbe(candidate, acknowledgedPublicRisk) } + /** + * Submit the host's shared access token (`WEBTERM_TOKEN`). Only meaningful from + * [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never post a + * credential to a host the user has moved on from. + * + * [token] is passed straight through to the prober and is NEVER stored: no field, no UI state, no + * log. On acceptance the shared cookie jar holds `webterm_auth` and pairing resumes through the same + * choke point as [confirm]/[retry] (so RULE 4's cert-gate still applies); every other outcome + * re-arms the prompt with a distinguishable reason and NEVER auto-retries (429 included). + */ + public fun submitAccessToken(token: String) { + val state = _uiState.value + if (state !is PairingUiState.TokenRequired) return + if (token.isBlank()) return + val candidate = Candidate(state.endpoint, state.name, state.tier) + val scope = scope ?: return + probeJob?.cancel() + probeJob = scope.launch { + _uiState.value = PairingUiState.TokenSubmitting(candidate.endpoint, candidate.name, candidate.tier) + when (prober.submitAccessToken(candidate.endpoint, token)) { + // Straight into the probe body: RULE 3 is already satisfied by construction (reaching the + // prompt required a probe, which for a public host required the explicit ack — and an ack + // cannot be un-given), while RULE 4's cert-gate lives INSIDE performProbe and re-applies. + TokenSubmission.Accepted -> performProbe(candidate) + TokenSubmission.Rejected -> promptForToken(candidate, TokenError.INVALID) + TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED) + is TokenSubmission.Unreachable -> promptForToken(candidate, TokenError.UNREACHABLE) + TokenSubmission.Unavailable -> promptForToken(candidate, TokenError.UNAVAILABLE) + } + } + } + private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) { - val tier = candidate.tier - // RULE 3 — public host: no probe until the risk is explicitly acknowledged. - if (tier.needsExplicitAck && !acknowledgedPublicRisk) { + // RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the + // pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe. + if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) { _uiState.value = PairingUiState.Confirming( endpoint = candidate.endpoint, name = candidate.name, - tier = tier, + tier = candidate.tier, awaitingAck = true, ) return } val scope = scope ?: return probeJob?.cancel() - probeJob = scope.launch { - // RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry re-hits this same guard. - if (tier.isCertGated && !hasDeviceCert()) { - _uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier) - return@launch - } - _uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier) - when (val result = prober.probe(candidate.endpoint)) { - is PairingProbeResult.Success -> onProbeSuccess(candidate) - is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed( - endpoint = candidate.endpoint, - name = candidate.name, - tier = tier, - error = result.error, - message = PairingCopy.describe(result.error, tier), - ) - } + probeJob = scope.launch { performProbe(candidate) } + } + + /** + * The ONE probe body — reached from [confirm]/[retry] via [runProbe] and from an accepted access + * token. Holds RULE 4 (the cert-gate) so BOTH entry points hit it; RULE 3 (the public-risk ack) is a + * pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns + * [probeJob], which would cancel the coroutine it is called from). + */ + private suspend fun performProbe(candidate: Candidate) { + val tier = candidate.tier + // RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both + // re-hit this same guard. + if (tier.isCertGated && !hasDeviceCert()) { + _uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier) + return } + _uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier) + when (val result = prober.probe(candidate.endpoint)) { + is PairingProbeResult.Success -> onProbeSuccess(candidate) + is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error) + } + } + + /** + * A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a + * `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token + * gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim. + */ + private suspend fun onProbeFailure(candidate: Candidate, error: PairingError) { + if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) { + promptForToken(candidate, error = null) + return + } + _uiState.value = PairingUiState.Failed( + endpoint = candidate.endpoint, + name = candidate.name, + tier = candidate.tier, + error = error, + message = PairingCopy.describe(error, candidate.tier), + ) + } + + private fun promptForToken(candidate: Candidate, error: TokenError?) { + _uiState.value = PairingUiState.TokenRequired( + endpoint = candidate.endpoint, + name = candidate.name, + tier = candidate.tier, + error = error, + ) } private suspend fun onProbeSuccess(candidate: Candidate) { @@ -181,20 +252,80 @@ public class PairingViewModel( HostClassifier.hostOf(endpoint).ifEmpty { endpoint.baseUrl } } -/** The two-step pairing probe seam ([wang.yaojia.webterm.api.pairing.runPairingProbe]); faked in tests. */ -public fun interface PairingProber { +/** + * The pairing NETWORK seam: the two-step probe ([wang.yaojia.webterm.api.pairing.runPairingProbe]) plus + * the `WEBTERM_TOKEN` gate's two operations. Faked in tests; production is built by + * `AppEnvironment.buildPairingProber()` over the ONE shared `OkHttpClient` (so the token cookie the + * submit obtains is the same jar every later request and the WS upgrade read from). + * + * The two token operations are DEFAULTED so a probe-only double still satisfies the interface. Their + * defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and + * [TokenSubmission.Unavailable] — never a silent success. + */ +public interface PairingProber { public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult + + /** + * Does [endpoint] answer an unauthenticated read with the server's `401 authentication required` + * (`src/server.ts` `authGate`)? Only consulted to disambiguate + * [PairingError.HttpOkButNotWebTerminal]. + * + * Implementations MUST NOT throw for a network failure — an unreachable host is `false` ("not known + * to be gated"), so the app never prompts for a credential because a host is merely down. + */ + public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = false + + /** + * `POST /auth` with [token]. On [TokenSubmission.Accepted] the shared cookie jar holds the host's + * `webterm_auth` cookie, so the very next probe/attach is authenticated. + * + * [token] is a SHELL CREDENTIAL: implementations must put it in the request BODY only — never a URL, + * never a log, never an exception message — and must not retain it. + */ + public suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission = + TokenSubmission.Unavailable } /** - * Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared - * `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav - * layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel — - * `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink - * I/O on a UI thread. + * Outcome of `POST /auth` (server `src/http/auth.ts` + the route in `src/server.ts`). A wrong token + * ([Rejected]) and a host that did not answer ([Unreachable]) are DISTINCT cases on purpose — collapsing + * them would tell the user to re-type a token that was fine. */ -public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber = - PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) } +public sealed interface TokenSubmission { + /** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */ + public data object Accepted : TokenSubmission + + /** `401` — the token does not match the host's `WEBTERM_TOKEN`. */ + public data object Rejected : TokenSubmission + + /** `429` — over the host's 10-per-minute auth limit. Surfaced as-is; NEVER auto-retried. */ + public data object RateLimited : TokenSubmission + + /** + * The submit never got an answer it could interpret: a transport failure or an unexpected status. + * [reason] is a short, credential-free diagnostic (an error TYPE or `"HTTP "`) — it is + * shown to nobody and logged nowhere; the UI copy is app-authored. + */ + public data class Unreachable(val reason: String) : TokenSubmission + + /** This prober has no way to submit a token (a probe-only seam). Reported, never silently ignored. */ + public data object Unavailable : TokenSubmission +} + +/** Why the access-token prompt is showing an error. Maps to inert, app-authored copy in [PairingCopy]. */ +public enum class TokenError { + /** The submitted token was rejected by the host (`401`). */ + INVALID, + + /** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */ + RATE_LIMITED, + + /** The host did not answer the submit (or answered unintelligibly). */ + UNREACHABLE, + + /** The app cannot submit a token at all on this seam. */ + UNAVAILABLE, +} // ── Warning tiers (plan §5.4) ─────────────────────────────────────────────────────────────────────── @@ -276,6 +407,27 @@ public sealed interface PairingUiState { */ public data class CertRequired(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState + /** + * The host is behind the optional `WEBTERM_TOKEN` gate and needs its shared access token before + * pairing can continue. [error] is null on the first prompt and set after a failed submit. + * + * This state deliberately carries NO token field — the credential is a + * [PairingViewModel.submitAccessToken] parameter and must not survive the call (plan §8). + */ + public data class TokenRequired( + val endpoint: HostEndpoint, + val name: String, + val tier: WarningTier, + val error: TokenError? = null, + ) : PairingUiState + + /** A submitted access token is in flight (`POST /auth`). Carries no token, for the same reason. */ + public data class TokenSubmitting( + val endpoint: HostEndpoint, + val name: String, + val tier: WarningTier, + ) : PairingUiState + /** The probe failed; [message] is inert, app-authored copy for [error]. Retryable. */ public data class Failed( val endpoint: HostEndpoint, @@ -302,6 +454,8 @@ private fun PairingUiState.candidate(): Candidate? = when (this) { is PairingUiState.Confirming -> Candidate(endpoint, name, tier) is PairingUiState.Probing -> Candidate(endpoint, name, tier) is PairingUiState.CertRequired -> Candidate(endpoint, name, tier) + is PairingUiState.TokenRequired -> Candidate(endpoint, name, tier) + is PairingUiState.TokenSubmitting -> Candidate(endpoint, name, tier) is PairingUiState.Failed -> Candidate(endpoint, name, tier) is PairingUiState.Entry, is PairingUiState.Paired -> null } @@ -315,11 +469,21 @@ internal object PairingCopy { "这个端口在运行别的服务,不是 web-terminal。端口对吗?" is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived is PairingError.CleartextBlocked -> - // Inverted by the A19 cleartext fix: LAN cleartext is now PERMITTED (the platform has no - // CIDR syntax, so it could not be scoped to RFC1918 — see network_security_config.xml). - // The only host that can still produce this error is the tunnel domain, which - // network_security_config pins to TLS because it always serves a real LE cert. - "${error.host} 必须使用 https / wss —— 该主机由设备证书保护,不允许降级为明文。" + // 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"). @@ -328,4 +492,19 @@ internal object PairingCopy { PairingError.Timeout -> "连接超时。主机可能不可达,或响应过慢。" } + + /** + * Copy for the access-token prompt. A wrong token and an unreachable host read differently on + * purpose, and the rate-limit line tells the user to WAIT — the app never retries by itself. + */ + fun describeToken(error: TokenError): String = when (error) { + TokenError.INVALID -> + "访问令牌不正确。请核对主机上 WEBTERM_TOKEN 的值(区分大小写)后重试。" + TokenError.RATE_LIMITED -> + "尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。" + TokenError.UNREACHABLE -> + "提交访问令牌时主机没有正常响应。请检查网络与地址后重试。" + TokenError.UNAVAILABLE -> + "当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。" + } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt index 2ac6b27..8bc94cc 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt @@ -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)? = 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.Loading) private val _prChip = MutableStateFlow(PrChip.Hidden) private val _recentCommits = MutableStateFlow(RecentCommits.Hidden) + private val _fetchPhase = MutableStateFlow(FetchPhase.Idle) + private val _worktreeStates = MutableStateFlow>(emptyMap()) /** The main detail snapshot `ProjectDetailScreen` renders from. */ public val phase: StateFlow = _phase.asStateFlow() @@ -70,6 +100,15 @@ public class ProjectDetailViewModel( /** The recent-commits snapshot. */ public val recentCommits: StateFlow = _recentCommits.asStateFlow() + /** The Fetch action's snapshot (spinner / result banner). */ + public val fetchPhase: StateFlow = _fetchPhase.asStateFlow() + + /** Per-worktree git state, keyed by worktree path. Absent ⇒ unprobed (renders no sync chip). */ + public val worktreeStates: StateFlow> = _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 { + val chips = ArrayList(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 条提交。" +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt index ef6105e..2bc5fef 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt @@ -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 + // ── W5: guarded worktree write ───────────────────────────────────────────────────────── public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome @@ -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 = api.gitFetch(path) override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome = api.createWorktree(path, branch, base) diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt index 7ef05a7..b2ffeb5 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt @@ -1,15 +1,27 @@ package wang.yaojia.webterm.wiring import dagger.Lazy +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import wang.yaojia.webterm.api.pairing.PairingProbeResult import wang.yaojia.webterm.api.pairing.runPairingProbe +import wang.yaojia.webterm.hostregistry.AuthCookieRecord +import wang.yaojia.webterm.hostregistry.AuthCookieStore import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.LastSessionStore import wang.yaojia.webterm.tlsandroid.IdentityRepository +import wang.yaojia.webterm.transport.AuthCookieJar +import wang.yaojia.webterm.transport.AuthCookieSnapshot import wang.yaojia.webterm.viewmodels.PairingProber +import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest import wang.yaojia.webterm.wire.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 +40,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 +52,7 @@ 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. * Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its * [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder]. */ @@ -57,7 +71,23 @@ public class AppEnvironment @Inject constructor( private val identityRepositoryLazy: Lazy, private val httpTransportLazy: Lazy, private val termTransportLazy: Lazy, + /** The SAME jar instance installed on the one shared `OkHttpClient` (`di/AuthCookieModule`). */ + private val authCookieJarLazy: Lazy, + /** Durable, Tink-AEAD-encrypted home of the `webterm_auth` cookie (`di/AuthCookieModule`). */ + private val authCookieStoreLazy: Lazy, + /** + * 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, ) { + /** 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 +104,254 @@ public class AppEnvironment @Inject constructor( public val httpTransport: HttpTransport get() = httpTransportLazy.get() /** - * Build the two-step pairing prober (A19) over the ONE shared transports. The transports are - * resolved LAZILY inside `probe()` (not at build time) so constructing the prober on the UI thread - * never triggers the mTLS/OkHttp build — the actual resolve happens in the probe's own coroutine, - * after [warmUp]. + * Build the pairing network seam (A19) over the ONE shared transports: the two-step probe plus the + * `WEBTERM_TOKEN` gate's detect + submit. The transports are resolved LAZILY inside each call (not at + * build time) so constructing the prober on the UI thread never triggers the mTLS/OkHttp build — the + * actual resolve happens in the caller's own coroutine, after [warmUp]. + * + * The submit rides the SAME client as everything else, which is the whole point: the `webterm_auth` + * cookie it earns lands in the shared [AuthCookieJar] and is therefore replayed on every later REST + * call AND on the WS upgrade. */ - public fun buildPairingProber(): PairingProber = - PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) } + public fun buildPairingProber(): PairingProber = object : PairingProber { + private val gateway = AccessTokenGateway { httpTransportLazy.get() } + + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult = + runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) + + override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = + gateway.requiresAccessToken(endpoint) + + override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission = + gateway.submit(endpoint, token) + } /** - * Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving - * either transport builds the one shared client (which reads the mTLS material); touching the - * identity forces its first AndroidKeyStore/Tink read. Idempotent at the singleton level — the - * client/identity are built once and cached. Safe to call from any coroutine; hops to - * `Dispatchers.IO` internally. + * Build the session-list / manage-page thumbnail pipeline for one host (§6.7). Per-host because its + * preview fetch is bound to that host's [ApiClient][wang.yaojia.webterm.api.routes.ApiClient] + * (`Origin` is stamped per endpoint), so it cannot be an app singleton. + * + * The CALLER owns the returned pipeline's lifetime: keep it in a `remember(env, hostId)` and call + * `close()` from a `DisposableEffect` so its in-flight renders are cancelled with the screen. + * Resolving the rasterizer measures a `Paint` — cheap, but `android.graphics`, so call this from the + * composition, never from DI construction. + */ + public fun buildThumbnailPipeline(endpoint: HostEndpoint): ThumbnailPipeline = + ThumbnailPipeline( + previewSource = ApiPreviewSource { apiClientFactory.create(endpoint) }, + rasterizer = thumbnailRasterizerLazy.get(), + ) + + /** + * 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 } } } + +// ── The WEBTERM_TOKEN gate (server src/http/auth.ts) ───────────────────────────────────────────────── + +/** + * The client half of the server's optional shared-access-token gate. + * + * Server contract (`src/http/auth.ts` + the `/auth` route in `src/server.ts`): + * - with `WEBTERM_TOKEN` set, EVERY remote route (and the WS upgrade) needs the `webterm_auth` cookie; + * an unauthed non-HTML request gets **401** with an `authentication required` JSON body; + * - `POST /auth` is always reachable and rate-limited to 10/min/IP. A non-HTML request answers **204** + * with `Set-Cookie: webterm_auth=…` on a match, **401** on a mismatch, **429** over the limit; + * - `GET /?token=` exists too and is deliberately NOT used here — it would put a shell credential in a + * URL (access logs, `Referer`, history). + * + * The cookie itself is never handled here: the shared client's [AuthCookieJar] captures `Set-Cookie` and + * replays it, so "the submit succeeded" and "the credential is held" are the same event. + * + * @param http resolves the shared [HttpTransport] LAZILY (resolving it builds the mTLS `OkHttpClient`, so + * it must happen inside the calling coroutine, not when this gateway is constructed). + */ +public class AccessTokenGateway(private val http: () -> HttpTransport) { + + /** + * Is [endpoint] behind the token gate? Probes the unauthenticated read and looks for exactly 401. + * + * Never throws for a network problem: an unreachable host answers `false`, so a host that is merely + * down can never make the app ask the user for a credential. Cancellation still propagates. + */ + public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean { + val response = try { + http().send(HttpRequest(method = HttpMethod.GET, url = endpoint.hostBaseUrl() + LIVE_SESSIONS_PATH)) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + return false + } + return response.status == HTTP_UNAUTHORIZED + } + + /** + * `POST /auth` with [token] in an urlencoded form body. + * + * [token] appears in the BODY only — never the URL, never a header, never the returned outcome, never + * a log line — and nothing here retains it. + */ + public suspend fun submit(endpoint: HostEndpoint, token: String): TokenSubmission { + val request = HttpRequest( + method = HttpMethod.POST, + url = endpoint.hostBaseUrl() + AUTH_PATH, + // No `Accept: text/html`: the server answers an HTML request with 302 redirects instead of + // the 204/401 status pair this mapping depends on. No `Origin` either — /auth is registered + // before the gate and is not an Origin-guarded route (plan §4.3: Origin only where required). + headers = mapOf(CONTENT_TYPE_HEADER to FORM_CONTENT_TYPE), + body = "$TOKEN_FIELD=${formEncode(token)}".toByteArray(Charsets.UTF_8), + ) + val response = try { + http().send(request) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + // The TYPE only: an exception message is host-influenced and could echo the request we sent. + return TokenSubmission.Unreachable(reason = error::class.simpleName ?: UNKNOWN_ERROR) + } + return when { + response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES -> TokenSubmission.Accepted + response.status == HTTP_UNAUTHORIZED -> TokenSubmission.Rejected + response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited + else -> TokenSubmission.Unreachable(reason = "HTTP ${response.status}") + } + } + + private companion object { + const val LIVE_SESSIONS_PATH = "/live-sessions" + const val AUTH_PATH = "/auth" + const val TOKEN_FIELD = "token" + const val CONTENT_TYPE_HEADER = "Content-Type" + const val FORM_CONTENT_TYPE = "application/x-www-form-urlencoded" + const val HTTP_OK = 200 + const val HTTP_MULTIPLE_CHOICES = 300 + const val HTTP_UNAUTHORIZED = 401 + const val HTTP_TOO_MANY_REQUESTS = 429 + const val UNKNOWN_ERROR = "unknown" + } +} + +/** + * `scheme://host[:port]` from the dialed URL — path/query/fragment/credentials dropped. Mirrors + * `:api-client`'s probe derivation (private there, hence re-derived rather than shared); the dialed port + * is kept verbatim and IPv6 literals arrive already bracketed from [java.net.URI]. + */ +private fun HostEndpoint.hostBaseUrl(): String { + val uri = URI(baseUrl) + val scheme = (uri.scheme ?: "http").lowercase() + val host = uri.host ?: "" + val portPart = if (uri.port != -1) ":${uri.port}" else "" + return "$scheme://$host$portPart" +} + +/** + * Percent-encode a form-field value, allowing ONLY RFC 3986 unreserved characters through. + * + * Stricter than necessary on purpose: the server's token charset (`[A-Za-z0-9._~+/=-]`) includes `+`, + * `/` and `=`, and in an `application/x-www-form-urlencoded` body a raw `+` decodes to a SPACE while `=` + * would split the field — so a correct token would be rejected. `java.net.URLEncoder` is avoided for the + * same reason (it emits `+` for spaces). + */ +private fun formEncode(value: String): String { + val out = StringBuilder(value.length) + for (byte in value.toByteArray(Charsets.UTF_8)) { + val octet = byte.toInt() and BYTE_MASK + val char = octet.toChar() + if (char.isUnreservedUrlChar()) { + out.append(char) + } else { + // Hand-rolled hex, NOT String.format: `%02X` is locale-sensitive and would emit non-ASCII + // digits under some locales, corrupting the credential. + out.append('%').append(HEX_DIGITS[octet shr HEX_SHIFT]).append(HEX_DIGITS[octet and LOW_NIBBLE]) + } + } + return out.toString() +} + +private fun Char.isUnreservedUrlChar(): Boolean = + this in 'A'..'Z' || this in 'a'..'z' || this in '0'..'9' || this in "-._~" + +private const val HEX_DIGITS: String = "0123456789ABCDEF" +private const val BYTE_MASK: Int = 0xFF +private const val LOW_NIBBLE: Int = 0x0F +private const val HEX_SHIFT: Int = 4 + +// ── Auth-cookie durability bridge (:transport-okhttp ⇄ :host-registry) ─────────────────────────────── + +/** + * Cold-start restore of the durable `webterm_auth` cookie into the live jar, EXACTLY ONCE. + * + * Once, because the durable write is asynchronous ([AuthCookieJar]'s persister hands off): a second + * hydration could overwrite a freshly re-authenticated in-memory cookie with the stale persisted one and + * silently put the app back on a dead credential. + * + * A failure is swallowed (warm-up must never fail on this) but does NOT consume the one shot, so the next + * warm-up retries. Worst case the user re-enters the token — never a crash, never a plaintext fallback. + */ +internal class AuthCookieHydrator( + private val store: () -> AuthCookieStore, + private val jar: () -> AuthCookieJar, +) { + private val hydrated = AtomicBoolean(false) + + /** Reads the store and restores each host's cookies under that host's own key. Call off `Main`. */ + internal suspend fun hydrateOnce() { + if (!hydrated.compareAndSet(false, true)) return + val records = try { + store().loadAll() + } catch (error: Throwable) { + hydrated.set(false) // not consumed — a later warm-up may succeed + if (error is CancellationException) throw error + return + } + val cookieJar = jar() + records.groupBy { it.hostKey }.forEach { (hostKey, forHost) -> + cookieJar.restore(hostKey, forHost.map { it.toSnapshot() }) + } + } +} + +/** + * `:transport-okhttp`'s jar DTO → `:host-registry`'s at-rest record, stamped with the isolation + * [hostKey]. The two modules must not depend on each other (`android/README.md`: dependencies only flow + * down), so `:app` owns this field-for-field mapping. `expiresAtEpochMillis` stays ABSOLUTE — persisting + * a `Max-Age` duration would restart the credential's lifetime on every restore. + */ +internal fun AuthCookieSnapshot.toRecord(hostKey: String): AuthCookieRecord = AuthCookieRecord( + hostKey = hostKey, + name = name, + value = value, + domain = domain, + path = path, + expiresAtEpochMillis = expiresAtEpochMillis, + secure = secure, + httpOnly = httpOnly, + hostOnly = hostOnly, +) + +/** The reverse mapping (cold-start hydration). `hostKey` is dropped — it is the jar's partition key. */ +internal fun AuthCookieRecord.toSnapshot(): AuthCookieSnapshot = AuthCookieSnapshot( + name = name, + value = value, + domain = domain, + path = path, + expiresAtEpochMillis = expiresAtEpochMillis, + secure = secure, + httpOnly = httpOnly, + hostOnly = hostOnly, +) diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt index a227af3..5b369f6 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt @@ -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)) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt index 56aa575..c8f5bfd 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt @@ -31,17 +31,25 @@ import kotlin.math.ceil * JVM-tested via the [ThumbnailRasterizer] seam. */ public class CanvasThumbnailRasterizer( - private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX, + fontSizePx: Float = DEFAULT_FONT_SIZE_PX, typeface: Typeface = Typeface.MONOSPACE, + private val maxWidthPx: Int = ThumbnailBudget.MAX_WIDTH_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 + private val cellWidth: Int = ceil(metricsPaint.measureText("X").toDouble()).toInt().coerceAtLeast(1) + private val cellHeight: Int = + (metricsPaint.fontMetricsInt.descent - metricsPaint.fontMetricsInt.ascent).coerceAtLeast(1) + private val baselineOffset: Int = -metricsPaint.fontMetricsInt.ascent override fun rasterize(preview: SessionPreview): Bitmap { val cols = preview.cols.coerceIn(1, MAX_COLS) @@ -50,15 +58,16 @@ public class CanvasThumbnailRasterizer( 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) + val paint = Paint(metricsPaint) // per-render Paint — see [metricsPaint] + val full = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, 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) + drawRow(canvas, paint, emulator, row, cols, palette, defaultBg) } - return bitmap + return downscaleToBudget(full) } override fun placeholder(): Bitmap { @@ -67,7 +76,29 @@ public class CanvasThumbnailRasterizer( return bitmap } - private fun drawRow(canvas: Canvas, emulator: TerminalEmulator, row: Int, cols: Int, palette: IntArray, defaultBg: Int) { + /** + * Shrink a full-resolution raster to the thumbnail budget. A 161×50 session rasterises to ~1127×700 = + * **3.1 MiB** ARGB_8888 — three of those alone exceed the pipeline's cache budget and would thrash the + * LRU on a list of sessions, re-rendering on every scroll. The card is ~96dp wide, so the full raster + * is downscaled once, here, and only the small bitmap is ever cached; the oversized source is recycled + * immediately. + */ + private fun downscaleToBudget(full: Bitmap): Bitmap { + val target = ThumbnailBudget.scaledSize(full.width, full.height, maxWidthPx) ?: return full + val scaled = Bitmap.createScaledBitmap(full, target.width, target.height, true) + if (scaled !== full) full.recycle() + return scaled + } + + private fun drawRow( + canvas: Canvas, + paint: Paint, + emulator: TerminalEmulator, + row: Int, + cols: Int, + palette: IntArray, + defaultBg: Int, + ) { val screen = emulator.screen val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)) val text = line.mText @@ -126,6 +157,33 @@ public class CanvasThumbnailRasterizer( } } +/** + * The pure raster-size budget for a preview thumbnail: how large a rasterised terminal screen may be kept. + * 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. + */ +public object ThumbnailBudget { + /** + * Max cached 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 while capping one cached bitmap at a few + * hundred KiB instead of the multi-MiB full-resolution raster. + */ + public const val MAX_WIDTH_PX: Int = 480 + + /** A raster target size in px. */ + public data class RasterSize(val width: Int, val height: Int) + + /** + * The size [width]×[height] should be scaled to, or `null` when it already fits [maxWidth] (draw as + * rendered — no extra allocation). Aspect ratio is preserved and the height never collapses to 0. + */ + public fun scaledSize(width: Int, height: Int, maxWidth: Int = MAX_WIDTH_PX): RasterSize? { + if (width <= 0 || height <= 0 || width <= maxWidth) return null + val scaledHeight = (height.toLong() * maxWidth / width).toInt().coerceAtLeast(1) + return RasterSize(width = maxWidth, height = scaledHeight) + } +} + /** 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 diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt index cd81fa5..c6ed37f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt @@ -6,13 +6,17 @@ 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.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 wang.yaojia.webterm.api.models.LiveSessionInfo import wang.yaojia.webterm.api.models.SessionPreview import wang.yaojia.webterm.api.routes.ApiClient @@ -56,16 +60,21 @@ 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>() + private val inFlight = HashMap>() 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 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 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] also surfaces as `null` (the caller is still alive — it just has no thumbnail). */ - public suspend fun thumbnail(session: LiveSessionInfo): Bitmap { + 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 } @@ -76,7 +85,23 @@ public class ThumbnailPipeline( cache.get(key)?.let { return it } inFlight[key] ?: startRender(key).also { inFlight[key] = it } } - return deferred.await() + return try { + deferred.await() + } catch (cancel: CancellationException) { + // Distinguish "the pipeline was closed 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. + */ + public fun retainOnly(live: Collection) { + cache.retainOnly(live.mapTo(HashSet()) { ThumbnailKey(it.id, it.lastOutputAt) }) } /** Cancel all in-flight renders and release the pipeline scope (call from the owner's teardown). */ @@ -84,18 +109,28 @@ public class ThumbnailPipeline( scope.cancel() } - private fun startRender(key: ThumbnailKey): Deferred = 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) + private fun startRender(key: ThumbnailKey): Deferred = scope.async { + var rendered: Bitmap? = null + try { + rendered = renderGuarded(key.sessionId) + rendered + } finally { + // ALWAYS release the in-flight slot — including on cancellation (scope closed) and on an + // unexpected throw. 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. + 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. + rendered?.let { cache.put(key, it) } + inFlight.remove(key) + } + } } - bitmap } - private suspend fun renderGuarded(sessionId: UUID): Bitmap = + private suspend fun renderGuarded(sessionId: UUID): Bitmap? = try { renderGate.withPermit { val preview = previewSource.fetch(sessionId) @@ -105,8 +140,9 @@ public class ThumbnailPipeline( throw e // never swallow cooperative cancellation } 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. + runCatching { rasterizer.placeholder() }.getOrNull() } public companion object { @@ -118,6 +154,25 @@ public class ThumbnailPipeline( } } +/** + * 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 +199,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) } /** @@ -164,14 +226,29 @@ 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) { + 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. Re-caps the body at 256 KiB client-side — + * defence-in-depth over the server's own cap (plan §6.7 / §4.2). + * + * [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)) } diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt index 525168b..ef9c7c6 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt @@ -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 diff --git a/android/app/src/test/java/wang/yaojia/webterm/nav/SessionRowMenuTest.kt b/android/app/src/test/java/wang/yaojia/webterm/nav/SessionRowMenuTest.kt new file mode 100644 index 0000000..15c9002 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/nav/SessionRowMenuTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/screens/TerminalReclaimPolicyTest.kt b/android/app/src/test/java/wang/yaojia/webterm/screens/TerminalReclaimPolicyTest.kt new file mode 100644 index 0000000..46f808a --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/screens/TerminalReclaimPolicyTest.kt @@ -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"))) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt index 85716bf..5afb2d3 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt @@ -1,6 +1,7 @@ package wang.yaojia.webterm.viewmodels import wang.yaojia.webterm.api.models.CreateWorktreeResult +import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.GitLogResult import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PrStatus @@ -9,6 +10,7 @@ import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.PruneWorktreesResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult import wang.yaojia.webterm.api.models.UiPrefs +import wang.yaojia.webterm.api.models.WorktreeState /** * A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel, @@ -18,6 +20,8 @@ import wang.yaojia.webterm.api.models.UiPrefs */ class FakeWorktreeGateway( private val detail: ProjectDetail? = null, + private val worktreeStates: Map = emptyMap(), + private val fetchOutcome: GitWriteOutcome = GitWriteOutcome.Ok(FetchResult()), private val prResult: PrStatus? = null, private val prThrows: Boolean = false, private val logResult: GitLogResult? = null, @@ -29,6 +33,8 @@ class FakeWorktreeGateway( val createCalls = mutableListOf>() val removeCalls = mutableListOf>() val pruneCalls = mutableListOf() + val worktreeStateCalls = mutableListOf() + val fetchCalls = mutableListOf() var detailCalls = 0 private set @@ -51,6 +57,16 @@ class FakeWorktreeGateway( return logResult ?: throw NotImplementedError("no log configured") } + override suspend fun worktreeState(worktreePath: String): WorktreeState { + worktreeStateCalls += worktreePath + return worktreeStates[worktreePath] ?: throw RuntimeException("probe failed for $worktreePath") + } + + override suspend fun gitFetch(path: String): GitWriteOutcome { + fetchCalls += path + return fetchOutcome + } + override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome { createCalls += Triple(path, branch, base) return createOutcome diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GatePreviewTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GatePreviewTest.kt new file mode 100644 index 0000000..d9f7e7e --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GatePreviewTest.kt @@ -0,0 +1,192 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.session.Gate +import wang.yaojia.webterm.session.GateState +import wang.yaojia.webterm.session.SessionEvent +import wang.yaojia.webterm.wire.ApprovalPreview +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.GateKind +import wang.yaojia.webterm.wire.PreviewDiffFile +import wang.yaojia.webterm.wire.PreviewDiffHunk +import wang.yaojia.webterm.wire.PreviewDiffLine +import wang.yaojia.webterm.wiring.TerminalSessionController + +/** + * W1 approval preview — a remote one-tap approval must show WHAT it approves (the command or the + * diff), not just a tool name. The content is derived server-side from the hook `tool_input`, i.e. it + * is **attacker-influenced**: it renders strictly inert, and this test pins the client-side + * defence-in-depth sanitizer plus the rule that an ABSENT preview is a NORMAL state (the name-only + * gate stays fully decidable — a missing preview never blocks or breaks a gate). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GatePreviewTest { + + private class FakeController : TerminalSessionController { + private val channel = Channel(Channel.UNLIMITED) + val decisions = mutableListOf>() + override fun controlEvents(): Flow = channel.receiveAsFlow() + override val output: Flow = emptyFlow() + override fun start() = Unit + override fun sendInput(data: String) = Unit + override fun resize(cols: Int, rows: Int) = Unit + override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit + override fun decideGate(epoch: Int, message: ClientMessage) { decisions += epoch to message } + override fun close() = Unit + fun emit(event: SessionEvent) { channel.trySend(event) } + } + + // ── Absent preview is a NORMAL state ───────────────────────────────────────────────────────── + + @Test + fun `a gate with no preview stays fully decidable and shows no preview block`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit(Gate(GateState(kind = GateKind.TOOL, detail = "Bash", epoch = 1))) + + assertNotNull(vm.uiState.value.gate, "the gate itself is unaffected") + assertNull(vm.uiState.value.preview, "no preview → the name-only bar, not an error") + vm.decide(GateDecision.APPROVE, 1) + assertEquals(1, fake.decisions.size, "a preview-less gate is still approvable") + } + + @Test + fun `a held gate carries its preview onto the ui state`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit( + Gate( + GateState( + kind = GateKind.TOOL, + detail = "Bash", + epoch = 1, + preview = ApprovalPreview.Command("rm -rf build/"), + ), + ), + ) + + val preview = vm.uiState.value.preview + assertTrue(preview is ApprovalPreview.Command, "expected a command preview, got $preview") + assertEquals("rm -rf build/", (preview as ApprovalPreview.Command).text) + } + + @Test + fun `a lifted gate clears any preview with it`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit( + Gate( + GateState( + kind = GateKind.TOOL, + detail = "Bash", + epoch = 1, + preview = ApprovalPreview.Command("rm -rf build/"), + ), + ), + ) + assertNotNull(vm.uiState.value.preview) + fake.emit(Gate(null)) + + assertNull(vm.uiState.value.gate) + assertNull(vm.uiState.value.preview, "a stale preview must never outlive its gate") + } + + @Test + fun `a new gate replaces the previous gate's preview`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit(Gate(gateWithCommand(epoch = 1))) + fake.emit(Gate(gateWithCommand(epoch = 2))) + + assertEquals("cmd for epoch 2", (vm.uiState.value.preview as ApprovalPreview.Command).text) + } + + private fun gateWithCommand(epoch: Int) = GateState( + kind = GateKind.TOOL, + detail = "Bash", + epoch = epoch, + preview = ApprovalPreview.Command("cmd for epoch $epoch"), + ) + + // ── Inert rendering (§8) ──────────────────────────────────────────────────────────────────── + + @Test + fun `command preview text keeps newlines and strips control and ANSI bytes`() { + val raw = "rm -rf build/\n\u001B[31mecho\u0007 boom\u0000" + + val safe = inertPreviewText(raw) + + assertTrue(safe.contains("\n"), "a shell command's line structure is meaningful") + assertFalse(safe.contains("\u001B"), "no ESC \u2014 preview text must never drive the terminal") + assertFalse(safe.contains("\u0007"), "no BEL") + assertFalse(safe.contains("\u0000"), "no NUL") + assertEquals("rm -rf build/\n[31mecho boom", safe) + } + + @Test + fun `command preview leaves ordinary text and markup characters literal`() { + val raw = "grep -n '' *.ts && echo \"a&b\"" + + assertEquals(raw, inertPreviewText(raw), "no escaping, no linkify — the UI renders it as text") + } + + @Test + fun `tabs survive but carriage returns do not`() { + assertEquals("a\tb", inertPreviewText("a\tb")) + assertEquals("ab", inertPreviewText("a\rb"), "a bare CR would rewrite the rendered line") + } + + @Test + fun `the display text of a command preview is the sanitized one`() { + val preview = ApprovalPreview.Command("echo \u001Bx", truncated = true) + + assertEquals("echo x", preview.inertText(), "what the UI paints") + assertTrue(preview.truncated) + } + + @Test + fun `a command preview with nothing legible left is not a preview at all`() { + assertNull(ApprovalPreview.Command(" \u0000 ").inertText()) + assertEquals("ls", ApprovalPreview.Command("ls").inertText()) + } + + @Test + fun `a diff preview carries its one synthetic file and truncation flag`() { + val file = PreviewDiffFile( + oldPath = "a.ts", + newPath = "a.ts", + status = "modified", + added = 1, + removed = 0, + binary = false, + hunks = listOf(PreviewDiffHunk("@@ -1 +1 @@", listOf(PreviewDiffLine("added", "+x")))), + ) + + val preview = ApprovalPreview.Diff(file, truncated = true) + + assertEquals("a.ts", preview.file.newPath) + assertEquals("+x", preview.file.hunks.single().lines.single().text) + assertTrue(preview.truncated) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GitSyncPanelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GitSyncPanelTest.kt new file mode 100644 index 0000000..e22498b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GitSyncPanelTest.kt @@ -0,0 +1,275 @@ +package wang.yaojia.webterm.viewmodels + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.CommitLogEntry +import wang.yaojia.webterm.api.models.GitLogResult +import wang.yaojia.webterm.api.models.SyncState +import wang.yaojia.webterm.api.models.WorktreeInfo + +/** + * w6 git panel — the pure presentation core (`syncBandOf` / `boundaryIndex` / `worktreeChips` / + * `headerDirtyChip`), ported from the shipped web behaviour in `public/projects.ts` + + * `public/git-log.ts` and the design in `docs/mockups/project-detail-git.html`. + * + * The load-bearing rule these tests exist for: **exactly one state may read as the green + * "all clear"** — `ahead == 0 && behind == 0` AND a fetch inside SyncState.FETCH_FRESH_WINDOW_MS. "No upstream" + * leaves ahead/behind undefined and MUST fall through to an explicit `no upstream`, never to green + * (docs/plans/w6-project-git-panel.md: "the single easiest bug to ship"). + */ +class GitSyncPanelTest { + + private val now = 1_800_000_000_000L + private val fresh = now - 60_000L // 1 min ago → inside the 1 h staleness window + private val ancient = now - (19L * 24 * 60 * 60 * 1000) // 19 days ago + + // ── The green path: exactly one state ──────────────────────────────────────────────────────── + + @Test + fun `ahead zero behind zero with a fresh fetch is the one green all-clear`() { + val band = syncBandOf( + SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = fresh), + nowMs = now, + ) + + val tracking = assertTracking(band) + assertFalse(tracking.isStale, "a 1-minute-old fetch is fresh") + assertTrue(tracking.isAllClear, "↑0 ↓0 + fresh fetch is the single green state") + } + + @Test + fun `ahead zero behind zero with a stale fetch is never green and marks the behind number`() { + val band = syncBandOf( + SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = ancient), + nowMs = now, + ) + + val tracking = assertTracking(band) + assertTrue(tracking.isStale, "a 19-day-old FETCH_HEAD is stale") + assertFalse(tracking.isAllClear, "a stale ↓0 is a guess, not a fact — never green") + } + + @Test + fun `never fetched is stale, not fresh`() { + val band = syncBandOf( + SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = null), + nowMs = now, + ) + + val tracking = assertTracking(band) + assertTrue(tracking.isStale) + assertFalse(tracking.isAllClear) + } + + /** THE fall-through this feature is most likely to get wrong (plan §"Design rule"). */ + @Test + fun `no upstream renders an explicit no-upstream state and never the green path`() { + val band = syncBandOf( + SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh), + nowMs = now, + ) + + assertEquals(SyncBand.NoUpstream, band, "a branch that tracks nothing has no ↑↓ to report") + assertFalse(band!!.isAllClear, "absent numbers must never read as 'in sync'") + } + + @Test + fun `a blank upstream string is treated as no upstream`() { + val band = syncBandOf(SyncState(upstream = " ", lastFetchMs = fresh), nowMs = now) + + assertEquals(SyncBand.NoUpstream, band) + } + + @Test + fun `only the tracking-in-sync state is ever all clear`() { + val allClear = listOf( + syncBandOf(SyncState(detached = true), nowMs = now), + syncBandOf(SyncState(upstream = null), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 1, behind = 0, lastFetchMs = fresh), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 3, lastFetchMs = fresh), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = ancient), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = fresh), nowMs = now), + ).map { it?.isAllClear } + + assertEquals(listOf(false, false, false, false, false, true), allClear) + } + + // ── Degraded shapes ───────────────────────────────────────────────────────────────────────── + + @Test + fun `a detached HEAD shows the short sha, no branch, and disables fetch`() { + val band = syncBandOf(SyncState(detached = true, lastFetchMs = fresh), head = "1dbed54", nowMs = now) + + assertEquals(SyncBand.Detached("1dbed54"), band) + assertFalse(band!!.canFetch, "fetch is not available on a detached HEAD") + } + + @Test + fun `fetch stays available for tracking and no-upstream states`() { + assertTrue(syncBandOf(SyncState(upstream = null), nowMs = now)!!.canFetch) + assertTrue(syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now)!!.canFetch) + } + + @Test + fun `absent sync facts render no band at all (non-git project)`() { + assertNull(syncBandOf(null, nowMs = now)) + } + + @Test + fun `absent ahead and behind on a tracking branch count as zero`() { + val tracking = assertTracking( + syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now), + ) + + assertEquals(0, tracking.ahead) + assertEquals(0, tracking.behind) + assertTrue(tracking.isAllClear, "a tracking branch with no counts and a fresh fetch IS in sync") + } + + // ── Header dirty chip (● 3, not a bare dot) ───────────────────────────────────────────────── + + @Test + fun `a dirty count renders the number, not a bare dot`() { + assertEquals(GitPanelCopy.dirtyChip(3), headerDirtyChip(dirtyCount = 3, dirty = true)) + assertTrue(headerDirtyChip(dirtyCount = 3, dirty = true)!!.contains("3")) + } + + @Test + fun `dirty without a count falls back to the countless chip, and clean renders nothing`() { + assertEquals(GitPanelCopy.DIRTY_NO_COUNT, headerDirtyChip(dirtyCount = null, dirty = true)) + assertNull(headerDirtyChip(dirtyCount = 0, dirty = true), "0 changed files is not dirty") + assertNull(headerDirtyChip(dirtyCount = null, dirty = false)) + assertNull(headerDirtyChip(dirtyCount = null, dirty = null)) + } + + // ── Commit list: unpushed marking + ONE boundary ───────────────────────────────────────────── + + private fun log(vararg unpushed: Boolean, upstream: String? = null, truncated: Boolean = false) = + GitLogResult( + commits = unpushed.mapIndexed { i, flag -> + CommitLogEntry(hash = "hash$i", at = now - i * 1000L, subject = "s$i", unpushed = flag) + }, + truncated = truncated, + upstream = upstream, + ) + + @Test + fun `the boundary is drawn once, after the last unpushed commit`() { + val log = log(true, true, false, false, upstream = "origin/develop") + + assertEquals(1, log.boundaryIndex(), "one boundary, right after the last unpushed row") + assertEquals("origin/develop", log.boundaryLabel()) + assertEquals(2, log.unpushedCount) + } + + /** + * A merge of an older branch interleaves an unpushed commit BELOW a pushed one. The per-commit flag + * is authoritative — never the row index — and the single boundary sits after the LAST unpushed row + * (plan §TDD step 19). + */ + @Test + fun `an unpushed commit sorting below a pushed one still moves the boundary`() { + val log = log(true, false, true, false, upstream = "origin/develop") + + assertEquals(2, log.boundaryIndex()) + assertEquals(listOf(true, false, true, false), log.commits.map { it.isUnpushed() }) + } + + @Test + fun `no unpushed commits means no boundary`() { + val log = log(false, false, upstream = "origin/develop") + + assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex()) + assertEquals(0, log.unpushedCount) + } + + @Test + fun `without an upstream no boundary is drawn even when rows are marked`() { + val log = log(true, true, upstream = null) + + assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex(), "nothing to draw the boundary against") + assertNull(log.boundaryLabel()) + } + + @Test + fun `a blank upstream draws no boundary and no label`() { + val log = log(true, upstream = " ") + + assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex()) + assertNull(log.boundaryLabel(), "an empty label would claim a position it cannot name") + } + + @Test + fun `an absent unpushed flag reads as pushed, never the other way round`() { + val commit = CommitLogEntry(hash = "h", at = now, subject = "s", unpushed = null) + + assertFalse(commit.isUnpushed(), "unknown must not be marked; only an explicit true counts") + } + + // ── Worktree section ──────────────────────────────────────────────────────────────────────── + + @Test + fun `the worktree section always reads Worktrees with its count, even at one`() { + assertEquals("Worktrees (1)", GitPanelCopy.worktreesTitle(1)) + assertEquals("Worktrees (2)", GitPanelCopy.worktreesTitle(2)) + assertEquals("Worktrees (0)", GitPanelCopy.worktreesTitle(0)) + } + + private val mainWorktree = WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true) + + @Test + fun `a worktree with no upstream shows no-upstream and never a green chip`() { + val chips = worktreeChips( + WorktreeInfo(path = "/repo/.claude/worktrees/x", branch = "worktree-x"), + WorktreeRowState(sync = SyncState(upstream = null, lastFetchMs = fresh)), + ) + + assertTrue(chips.any { it.label == GitPanelCopy.NO_UPSTREAM_CHIP }) + assertFalse(chips.any { it.tone == GitChipTone.OK }, "a worktree row has no green all-clear chip") + } + + @Test + fun `worktree chips report main current locked prunable then sync then dirty`() { + val chips = worktreeChips( + mainWorktree.copy(locked = true, prunable = true), + WorktreeRowState(sync = SyncState(upstream = "origin/develop", ahead = 9, lastFetchMs = fresh), dirtyCount = 3), + ) + + assertEquals( + listOf( + GitPanelCopy.MAIN_CHIP, + GitPanelCopy.CURRENT_CHIP, + GitPanelCopy.LOCKED_CHIP, + GitPanelCopy.PRUNABLE_CHIP, + GitPanelCopy.aheadChip(9), + GitPanelCopy.dirtyCountChip(3), + ), + chips.map { it.label }, + ) + } + + @Test + fun `an unprobed worktree row shows no sync chip at all rather than a guess`() { + val chips = worktreeChips(mainWorktree, WorktreeRowState()) + + assertEquals(listOf(GitPanelCopy.MAIN_CHIP, GitPanelCopy.CURRENT_CHIP), chips.map { it.label }) + } + + @Test + fun `a detached worktree row says detached`() { + val chips = worktreeChips( + WorktreeInfo(path = "/repo/wt", branch = null, head = "1dbed54"), + WorktreeRowState(sync = SyncState(detached = true)), + ) + + assertEquals(listOf(GitPanelCopy.DETACHED_CHIP_SHORT), chips.map { it.label }) + } + + private fun assertTracking(band: SyncBand?): SyncBand.Tracking { + assertTrue(band is SyncBand.Tracking, "expected a tracking band, got $band") + return band as SyncBand.Tracking + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailFetchTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailFetchTest.kt new file mode 100644 index 0000000..c449946 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailFetchTest.kt @@ -0,0 +1,177 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.FetchResult +import wang.yaojia.webterm.api.models.GitWriteOutcome +import wang.yaojia.webterm.api.models.GitLogResult +import wang.yaojia.webterm.api.models.PrAvailability +import wang.yaojia.webterm.api.models.PrStatus +import wang.yaojia.webterm.api.models.ProjectDetail +import wang.yaojia.webterm.api.models.WorktreeInfo +import wang.yaojia.webterm.api.models.WorktreeState + +/** + * w6/G2+G3 — the Fetch action on the project-detail sync band. + * + * `git fetch` only moves remote-tracking refs (no pull, no merge), so it is the ONE write the panel + * offers. The rules pinned here: one in-flight fetch at a time (a second tap is dropped, never + * queued), a success re-loads the detail so `behind`/`lastFetchMs` actually move, and a FAILURE + * changes nothing — `lastFetchMs` stays where it was so the `stale` flag stays ON (never silently + * mark the data fresh). + */ +class ProjectDetailFetchTest { + + private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "develop") + + private fun okFetch(lastFetchMs: Long?): GitWriteOutcome = + GitWriteOutcome.Ok(FetchResult(lastFetchMs)) + + private fun vmWith( + fetchRemote: (suspend () -> GitWriteOutcome)?, + ): Pair { + val detailCalls = intArrayOf(0) + val vm = ProjectDetailViewModel( + path = "/repo", + fetch = { detailCalls[0]++; detail }, + fetchRemote = fetchRemote, + ) + return vm to detailCalls + } + + @Test + fun `no fetch seam wired means the button is not offered`() { + val (vm, _) = vmWith(null) + + assertFalse(vm.canFetch, "without a fetch gateway the band must not offer the action") + assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value) + } + + @Test + fun `a successful fetch reports the new lastFetchMs and re-loads the detail`() = runTest { + val (vm, detailCalls) = vmWith { okFetch(42L) } + vm.load() + val callsAfterLoad = detailCalls[0] + + vm.fetchUpstream() + + assertTrue(vm.canFetch) + assertEquals(ProjectDetailViewModel.FetchPhase.Done(42L), vm.fetchPhase.value) + assertEquals(callsAfterLoad + 1, detailCalls[0], "a fetch refreshes ahead/behind") + } + + @Test + fun `a failing fetch surfaces a short message and does NOT re-load or mark data fresh`() = runTest { + val (vm, detailCalls) = vmWith { throw RuntimeException("offline") } + vm.load() + val callsAfterLoad = detailCalls[0] + + vm.fetchUpstream() + + val phase = vm.fetchPhase.value + assertTrue(phase is ProjectDetailViewModel.FetchPhase.Failed, "expected Failed, got $phase") + assertEquals(GitPanelCopy.FETCH_FAILED, (phase as ProjectDetailViewModel.FetchPhase.Failed).message) + assertEquals(callsAfterLoad, detailCalls[0], "a failed fetch must not claim fresh data") + } + + @Test + fun `a second fetch while one is in flight is dropped, not queued`() = runTest { + val gate = CompletableDeferred>() + var invocations = 0 + val vm = ProjectDetailViewModel( + path = "/repo", + fetch = { detail }, + fetchRemote = { invocations++; gate.await() }, + ) + + val first = launch { vm.fetchUpstream() } + // Let the first call reach the suspension point, then tap again. + kotlinx.coroutines.yield() + assertEquals(ProjectDetailViewModel.FetchPhase.Working, vm.fetchPhase.value) + vm.fetchUpstream() + gate.complete(okFetch(7L)) + first.join() + + assertEquals(1, invocations, "the in-flight fetch is not re-entered") + assertEquals(ProjectDetailViewModel.FetchPhase.Done(7L), vm.fetchPhase.value) + } + + @Test + fun `the fetch banner is dismissable back to idle`() = runTest { + val (vm, _) = vmWith { okFetch(1L) } + vm.fetchUpstream() + + vm.clearFetchBanner() + + assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value) + } + + @Test + fun `a rejected fetch surfaces the server's own safe message verbatim`() = runTest { + val (vm, detailCalls) = vmWith { GitWriteOutcome.Rejected(status = 400, message = "两个 remote,无法确定目标") } + vm.load() + val callsAfterLoad = detailCalls[0] + + vm.fetchUpstream() + + assertEquals( + ProjectDetailViewModel.FetchPhase.Failed("两个 remote,无法确定目标"), + vm.fetchPhase.value, + ) + assertEquals(callsAfterLoad, detailCalls[0], "a rejected fetch must not claim fresh data") + } + + @Test + fun `a rate-limited fetch says so and does not retry`() = runTest { + val (vm, _) = vmWith { GitWriteOutcome.RateLimited } + + vm.fetchUpstream() + + assertEquals( + ProjectDetailViewModel.FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED), + vm.fetchPhase.value, + ) + } + + @Test + fun `a fetch failure never fails the detail load`() = runTest { + val (vm, _) = vmWith { throw RuntimeException("offline") } + + vm.load() + vm.fetchUpstream() + + assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "isolation: detail stays loaded") + } + + // ── w6/G7: the per-worktree probe ──────────────────────────────────────────────────────────── + + @Test + fun `probing skips the current worktree and isolates a failing row`() = runTest { + val worktrees = listOf( + WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true), + WorktreeInfo(path = "/repo/wt-a", branch = "worktree-a"), + WorktreeInfo(path = "/repo/wt-b", branch = "worktree-b"), + ) + val gateway = FakeWorktreeGateway( + detail = detail.copy(worktrees = worktrees), + prResult = PrStatus(availability = PrAvailability.NO_PR), + logResult = GitLogResult(), + // wt-b is missing on purpose: its probe throws, and must cost only its own row. + worktreeStates = mapOf("/repo/wt-a" to WorktreeState(path = "/repo/wt-a", dirtyCount = 2)), + ) + val vm = ProjectDetailViewModel.forGateway(gateway, "/repo") + vm.load() + + vm.probeWorktrees() + + assertEquals(listOf("/repo/wt-a", "/repo/wt-b"), gateway.worktreeStateCalls, "the current row is free") + assertEquals(setOf("/repo/wt-a"), vm.worktreeStates.value.keys) + assertEquals(2, vm.worktreeStates.value.getValue("/repo/wt-a").dirtyCount) + assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "a failed probe never fails the panel") + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt index 67d7f90..0f43bf8 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt @@ -204,6 +204,8 @@ class ProjectsViewModelTest { override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError() override suspend fun projectPr(path: String) = throw NotImplementedError() override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError() + override suspend fun worktreeState(worktreePath: String) = throw NotImplementedError() + override suspend fun gitFetch(path: String) = throw NotImplementedError() override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError() override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError() override suspend fun pruneWorktrees(path: String) = throw NotImplementedError() diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt new file mode 100644 index 0000000..91ac004 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt @@ -0,0 +1,183 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import wang.yaojia.webterm.wire.HttpResponse +import wang.yaojia.webterm.wire.HttpTransport +import java.io.IOException + +/** + * [AccessTokenGateway] — the `WEBTERM_TOKEN` half of pairing, verified against the server contract in + * `src/http/auth.ts` + `src/server.ts` (`POST /auth`: urlencoded `token` field; XHR ⇒ 204 valid / 401 + * invalid / 429 over the 10-per-minute limit; the global gate answers an unauthed read with 401). + * + * The credential-hygiene assertions are as load-bearing as the status mapping: the token is a SHELL + * credential, so it must appear ONLY in the request body and never in a URL, a header, a returned + * outcome, or any `toString()` that could reach a log or a crash report. + */ +@DisplayName("AccessTokenGateway — POST /auth + 401 gate detection") +class AccessTokenGatewayTest { + + private companion object { + /** A token using the full server-side charset (`[A-Za-z0-9._~+/=-]`) — `+`, `/`, `=` are the + * form-encoding traps: a raw `+` in an urlencoded body decodes to a SPACE server-side. */ + const val TOKEN = "ab+cd/ef=gh-ij._~" + const val BASE = "http://10.0.0.5:3000" + } + + private class RecordingTransport( + private val handler: (HttpRequest) -> HttpResponse, + ) : HttpTransport { + val requests = mutableListOf() + + override suspend fun send(request: HttpRequest): HttpResponse { + requests += request + return handler(request) + } + } + + private class ThrowingTransport(private val error: Throwable) : HttpTransport { + override suspend fun send(request: HttpRequest): HttpResponse = throw error + } + + private fun endpoint(url: String = BASE): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" } + + private fun status(code: Int): (HttpRequest) -> HttpResponse = + { HttpResponse(status = code, body = ByteArray(0)) } + + private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport } + + // ── Gate detection: is this host token-gated, or is it just not a web-terminal? ────────────────── + + @Test + fun `a 401 on the unauthenticated read means the host is token-gated`() = runTest { + val transport = RecordingTransport(status(401)) + + assertTrue(gateway(transport).requiresAccessToken(endpoint())) + assertEquals("$BASE/live-sessions", transport.requests.single().url) + assertEquals(HttpMethod.GET, transport.requests.single().method) + // The read is Origin-free (plan §4.3: Origin only on guarded routes). + assertTrue(transport.requests.single().headers.keys.none { it.equals("Origin", ignoreCase = true) }) + } + + @Test + fun `a 200 host is not token-gated`() = runTest { + assertFalse(gateway(RecordingTransport(status(200))).requiresAccessToken(endpoint())) + } + + @Test + fun `some other 4xx or 5xx is not read as the token gate`() = runTest { + for (code in listOf(403, 404, 418, 500, 503)) { + assertFalse( + gateway(RecordingTransport(status(code))).requiresAccessToken(endpoint()), + "HTTP $code must not be mistaken for the WEBTERM_TOKEN gate", + ) + } + } + + @Test + fun `an unreachable host is not reported as token-gated`() = runTest { + // "Nothing answered" must stay distinguishable from "answered 401" — never prompt for a + // credential because the host is down. + assertFalse(gateway(ThrowingTransport(IOException("connect refused"))).requiresAccessToken(endpoint())) + } + + // ── POST /auth status mapping ──────────────────────────────────────────────────────────────────── + + @Test + fun `204 accepts the token`() = runTest { + assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN)) + } + + @Test + fun `401 is a wrong token, distinct from an unreachable host`() = runTest { + assertEquals(TokenSubmission.Rejected, gateway(RecordingTransport(status(401))).submit(endpoint(), TOKEN)) + + val unreachable = gateway(ThrowingTransport(IOException("no route"))).submit(endpoint(), TOKEN) + assertInstanceOf(TokenSubmission.Unreachable::class.java, unreachable) + } + + @Test + fun `429 is surfaced as rate-limited and never auto-retried`() = runTest { + val transport = RecordingTransport(status(429)) + + assertEquals(TokenSubmission.RateLimited, gateway(transport).submit(endpoint(), TOKEN)) + assertEquals(1, transport.requests.size, "a 429 must not be retried inside the gateway") + } + + @Test + fun `an unexpected status is reported as unreachable, not as a wrong token`() = runTest { + val outcome = gateway(RecordingTransport(status(500))).submit(endpoint(), TOKEN) + + assertInstanceOf(TokenSubmission.Unreachable::class.java, outcome) + assertEquals("HTTP 500", (outcome as TokenSubmission.Unreachable).reason) + } + + // ── Request shape (the server contract) ────────────────────────────────────────────────────────── + + @Test + fun `the token is posted as an urlencoded form field with every reserved byte escaped`() = runTest { + val transport = RecordingTransport(status(204)) + + gateway(transport).submit(endpoint(), TOKEN) + + val request = transport.requests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/auth", request.url) + assertEquals( + "application/x-www-form-urlencoded", + request.headers.entries.single { it.key.equals("Content-Type", ignoreCase = true) }.value, + ) + // `+` MUST NOT survive raw (Express's qs decodes it to a space) and `/`/`=` would split the field. + assertEquals("token=ab%2Bcd%2Fef%3Dgh-ij._~", request.body?.decodeToString()) + } + + @Test + fun `no Accept text-html header is sent, so the server answers 204 or 401 instead of redirecting`() = + runTest { + val transport = RecordingTransport(status(204)) + + gateway(transport).submit(endpoint(), TOKEN) + + val accept = transport.requests.single().headers.entries + .firstOrNull { it.key.equals("Accept", ignoreCase = true) }?.value.orEmpty() + assertFalse(accept.contains("text/html"), "an HTML Accept makes /auth answer 302, not 204/401") + } + + // ── Credential hygiene ────────────────────────────────────────────────────────────────────────── + + @Test + fun `the token never reaches the URL, a header, the outcome or any toString`() = runTest { + val transport = RecordingTransport(status(401)) + + val outcome = gateway(transport).submit(endpoint(), TOKEN) + + val request = transport.requests.single() + assertFalse(request.url.contains(TOKEN), "never put the credential in a URL (logs / Referer)") + assertFalse(request.headers.toString().contains(TOKEN)) + assertFalse(outcome.toString().contains(TOKEN)) + // The data class renders `body=[B@…` — the bytes are never stringified. + assertFalse(request.toString().contains(TOKEN)) + } + + @Test + fun `a transport failure reports only the error type, never the message or the token`() = runTest { + // An exception message is attacker/host-influenced and could echo a request; carry the type only. + val error = IOException("failed posting token=$TOKEN") + val outcome = gateway(ThrowingTransport(error)).submit(endpoint(), TOKEN) + + val reason = (outcome as TokenSubmission.Unreachable).reason + assertEquals("IOException", reason) + assertFalse(outcome.toString().contains(TOKEN)) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieHydratorTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieHydratorTest.kt new file mode 100644 index 0000000..7d1fe7b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieHydratorTest.kt @@ -0,0 +1,174 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.hostregistry.AuthCookieId +import wang.yaojia.webterm.hostregistry.AuthCookieRecord +import wang.yaojia.webterm.hostregistry.AuthCookieStore +import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore +import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME +import wang.yaojia.webterm.transport.AuthCookieJar + +/** + * [AuthCookieHydrator] — the cold-start half of the `WEBTERM_TOKEN` gate: without it the durable + * `webterm_auth` cookie never reaches the live [AuthCookieJar], so a paired token-gated host would ask + * for the token again on every launch. + * + * Two properties are security-load-bearing and asserted directly: + * - **per-host isolation** — a record filed under host A must never be presented to host B; + * - **hydrate exactly once** — a second hydration could overwrite a FRESHER in-memory cookie with the + * stale persisted one (the durable write is asynchronous), silently reverting to a dead credential. + */ +@DisplayName("AuthCookieHydrator — cold-start restore of the webterm_auth cookie") +class AuthCookieHydratorTest { + + private companion object { + const val HOST_A = "http://10.0.0.5:3000" + const val HOST_B = "http://10.0.0.9:3000" + const val TOKEN = "persisted-shell-credential" + const val FAR_FUTURE = 4_000_000_000_000L // ~2096 + } + + private fun record( + hostKey: String, + host: String, + value: String = TOKEN, + expiresAt: Long = FAR_FUTURE, + ) = AuthCookieRecord( + hostKey = hostKey, + name = AUTH_COOKIE_NAME, + value = value, + domain = host, + path = "/", + expiresAtEpochMillis = expiresAt, + secure = false, + httpOnly = true, + hostOnly = true, + ) + + private fun hydrator(store: AuthCookieStore, jar: AuthCookieJar) = + AuthCookieHydrator(store = { store }, jar = { jar }) + + private fun AuthCookieJar.valuesFor(baseUrl: String): List = + loadForRequest("$baseUrl/live-sessions".toHttpUrl()).map { it.value } + + @Test + fun `a persisted cookie is restored onto its own host and nowhere else`() = runTest { + val store = InMemoryAuthCookieStore( + listOf(record(HOST_A, "10.0.0.5"), record(HOST_B, "10.0.0.9", value = "other-host-token")), + ) + val jar = AuthCookieJar() + + hydrator(store, jar).hydrateOnce() + + assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A)) + assertEquals(listOf("other-host-token"), jar.valuesFor(HOST_B)) + } + + @Test + fun `an expired record is not resurrected`() = runTest { + val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5", expiresAt = 1L))) + val jar = AuthCookieJar() + + hydrator(store, jar).hydrateOnce() + + assertTrue(jar.valuesFor(HOST_A).isEmpty()) + } + + @Test + fun `hydration happens exactly once, so a fresher in-memory cookie is never clobbered`() = runTest { + val store = CountingStore(listOf(record(HOST_A, "10.0.0.5", value = "stale"))) + val jar = AuthCookieJar() + val hydrator = hydrator(store, jar) + + hydrator.hydrateOnce() + assertEquals(listOf("stale"), jar.valuesFor(HOST_A)) + + // A live re-auth replaces the credential in memory (its durable write is async and may lag). + jar.saveFromResponse( + "$HOST_A/auth".toHttpUrl(), + listOf( + okhttp3.Cookie.Builder() + .name(AUTH_COOKIE_NAME) + .value("fresh") + .expiresAt(FAR_FUTURE) + .path("/") + .hostOnlyDomain("10.0.0.5") + .build(), + ), + ) + + hydrator.hydrateOnce() + + assertEquals(1, store.loads, "a second warm-up must not re-read the store") + assertEquals(listOf("fresh"), jar.valuesFor(HOST_A)) + } + + @Test + fun `a failing store never breaks warm-up and is retried on the next one`() = runTest { + val store = FailingThenWorkingStore(listOf(record(HOST_A, "10.0.0.5"))) + val jar = AuthCookieJar() + val hydrator = hydrator(store, jar) + + hydrator.hydrateOnce() // throws inside → swallowed + assertTrue(jar.valuesFor(HOST_A).isEmpty()) + + hydrator.hydrateOnce() // the failure did not consume the one-shot + assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A)) + } + + @Test + fun `the restored credential is not exposed by the jar or record toString`() = runTest { + val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5"))) + val jar = AuthCookieJar() + + hydrator(store, jar).hydrateOnce() + + assertFalse(jar.toString().contains(TOKEN)) + assertFalse(store.loadAll().toString().contains(TOKEN)) + } + + // ── Doubles ───────────────────────────────────────────────────────────────────────────────────── + + private class CountingStore(initial: List) : AuthCookieStore { + private val delegate = InMemoryAuthCookieStore(initial) + var loads: Int = 0 + + override suspend fun loadAll(): List { + loads++ + return delegate.loadAll() + } + + override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey) + override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record) + override suspend fun remove(id: AuthCookieId) = delegate.remove(id) + override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey) + override suspend fun replaceHost(hostKey: String, records: List) = + delegate.replaceHost(hostKey, records) + } + + private class FailingThenWorkingStore(initial: List) : AuthCookieStore { + private val delegate = InMemoryAuthCookieStore(initial) + private var failed = false + + override suspend fun loadAll(): List { + if (!failed) { + failed = true + error("keystore unavailable") + } + return delegate.loadAll() + } + + override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey) + override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record) + override suspend fun remove(id: AuthCookieId) = delegate.remove(id) + override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey) + override suspend fun replaceHost(hostKey: String, records: List) = + delegate.replaceHost(hostKey, records) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieMappingTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieMappingTest.kt new file mode 100644 index 0000000..6d7310f --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieMappingTest.kt @@ -0,0 +1,62 @@ +package wang.yaojia.webterm.wiring + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME +import wang.yaojia.webterm.transport.AuthCookieSnapshot + +/** + * The `:app`-owned bridge between `:transport-okhttp`'s [AuthCookieSnapshot] (what the live cookie jar + * publishes) and `:host-registry`'s `AuthCookieRecord` (what is encrypted at rest). Neither module may + * depend on the other (`android/README.md`: dependencies only flow down), so this 2-function mapping is + * the seam — and a field dropped here is a silently broken credential, hence the field-for-field test. + */ +@DisplayName("AuthCookie snapshot ⇄ record mapping") +class AuthCookieMappingTest { + + private companion object { + const val HOST_KEY = "https://star.terminal.yaojia.wang:443" + const val TOKEN = "shell-credential" + } + + private fun snapshot() = AuthCookieSnapshot( + name = AUTH_COOKIE_NAME, + value = TOKEN, + domain = "star.terminal.yaojia.wang", + path = "/", + expiresAtEpochMillis = 4_000_000_000_000L, + secure = true, + httpOnly = true, + hostOnly = true, + ) + + @Test + fun `every field survives the round trip, with the host key stamped on the record`() { + val original = snapshot() + + val record = original.toRecord(HOST_KEY) + + assertEquals(HOST_KEY, record.hostKey) + assertEquals(original.name, record.name) + assertEquals(original.value, record.value) + assertEquals(original.domain, record.domain) + assertEquals(original.path, record.path) + // ABSOLUTE wall-clock expiry, never a Max-Age duration (which would restart on every restore). + assertEquals(original.expiresAtEpochMillis, record.expiresAtEpochMillis) + assertEquals(original.secure, record.secure) + assertEquals(original.httpOnly, record.httpOnly) + assertEquals(original.hostOnly, record.hostOnly) + + assertEquals(original, record.toSnapshot()) + } + + @Test + fun `neither side renders the credential in toString`() { + val record = snapshot().toRecord(HOST_KEY) + + assertFalse(record.toString().contains(TOKEN)) + assertFalse(record.toSnapshot().toString().contains(TOKEN)) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt new file mode 100644 index 0000000..7efb297 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt @@ -0,0 +1,303 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.pairing.PairingError +import wang.yaojia.webterm.api.pairing.PairingProbeResult +import wang.yaojia.webterm.hostregistry.InMemoryHostStore +import wang.yaojia.webterm.viewmodels.PairingProber +import wang.yaojia.webterm.viewmodels.PairingUiState +import wang.yaojia.webterm.viewmodels.PairingViewModel +import wang.yaojia.webterm.viewmodels.TokenError +import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * The `WEBTERM_TOKEN` acquisition flow in [PairingViewModel]: prompt → submit → success / wrong / 429. + * + * Lives in the `wiring` test package rather than next to `PairingViewModelTest` because the + * `app/src/test` `viewmodels` package belongs to another agent's file-ownership lane in this run; the + * subject under test is the (public) pairing state machine either way. + * + * Why the flow exists: when a host runs with `WEBTERM_TOKEN` set, `GET /live-sessions` answers **401** + * before anything else can happen (`src/server.ts` `authGate`). The frozen probe taxonomy can only + * report that as `HttpOkButNotWebTerminal` ("wrong port?"), which is actively misleading and leaves a + * token-gated host impossible to pair. So a failed probe asks the prober whether the host is gated, and + * the answer routes to a token prompt instead of the dead-end failure card. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@DisplayName("Pairing — WEBTERM_TOKEN acquisition") +class PairingTokenFlowTest { + + private companion object { + const val TOKEN = "s3cret-shell-token" + const val LAN = "http://10.0.0.5:3000" + const val TUNNEL = "https://star.terminal.yaojia.wang" + } + + /** Records every probe AND every token submission so "no retry / no re-probe" is directly assertable. */ + private class FakeProber( + var probeResult: (HostEndpoint) -> PairingProbeResult = { PairingProbeResult.Success(it) }, + var gated: Boolean = false, + var submission: (String) -> TokenSubmission = { TokenSubmission.Accepted }, + ) : PairingProber { + val probes = mutableListOf() + val submitted = mutableListOf() + + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult { + probes += endpoint + return probeResult(endpoint) + } + + override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = gated + + override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission { + submitted += token + return submission(token) + } + } + + private fun endpoint(url: String): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" } + + private fun gatedThenOpen(prober: FakeProber): (HostEndpoint) -> PairingProbeResult = { ep -> + // Before the token: the gate 401s the reachability read, which the probe can only classify as + // "not a web-terminal". After the token is accepted the same probe succeeds. + if (prober.gated) PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) + else PairingProbeResult.Success(ep) + } + + private fun newVm( + prober: PairingProber, + hasCert: () -> Boolean = { false }, + store: InMemoryHostStore = InMemoryHostStore(), + ) = PairingViewModel( + hostStore = store, + prober = prober, + hasDeviceCert = { hasCert() }, + newId = { "fixed-id" }, + ) + + // ── Prompt ────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a token-gated host asks for the token instead of showing the wrong-port failure`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + val state = vm.uiState.value + assertInstanceOf(PairingUiState.TokenRequired::class.java, state) + assertEquals(endpoint(LAN), (state as PairingUiState.TokenRequired).endpoint) + assertEquals(null, state.error, "the first prompt is not an error") + } + + @Test + fun `an ungated host still shows the wrong-port failure`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber( + probeResult = { PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) }, + gated = false, + ) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value) + } + + @Test + fun `a non-gate failure is never re-interpreted as a token prompt`() = runTest(UnconfinedTestDispatcher()) { + // `gated` is true here, but the failure is a timeout — only the ambiguous + // HttpOkButNotWebTerminal case is allowed to become a token prompt. + val prober = FakeProber(probeResult = { PairingProbeResult.Failure(PairingError.Timeout) }, gated = true) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + val state = vm.uiState.value + assertInstanceOf(PairingUiState.Failed::class.java, state) + assertEquals(PairingError.Timeout, (state as PairingUiState.Failed).error) + } + + // ── Submit ────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `submitting the right token continues pairing and persists the host`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + // Accepting the token means the cookie jar now holds the credential → the gate opens. + prober.submission = { prober.gated = false; TokenSubmission.Accepted } + val store = InMemoryHostStore() + val vm = newVm(prober, store = store) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertEquals(listOf(TOKEN), prober.submitted) + assertEquals(2, prober.probes.size, "an accepted token re-runs the probe") + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + assertEquals(LAN, store.loadAll().single().endpoint.baseUrl) + } + + @Test + fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.Rejected } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken("wrong") + + val state = vm.uiState.value + assertInstanceOf(PairingUiState.TokenRequired::class.java, state) + assertEquals(TokenError.INVALID, (state as PairingUiState.TokenRequired).error) + assertEquals(1, prober.probes.size, "a rejected token must not re-probe") + } + + @Test + fun `a 429 is surfaced honestly and never auto-retried`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.RateLimited } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertEquals(TokenError.RATE_LIMITED, (vm.uiState.value as PairingUiState.TokenRequired).error) + assertEquals(1, prober.submitted.size, "rate-limited must not resubmit by itself") + assertEquals(1, prober.probes.size) + } + + @Test + fun `an unreachable host during submit is distinguishable from a wrong token`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.Unreachable("IOException") } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertEquals(TokenError.UNREACHABLE, (vm.uiState.value as PairingUiState.TokenRequired).error) + } + + @Test + fun `a prober with no token support reports the capability honestly`() = + runTest(UnconfinedTestDispatcher()) { + // The interface defaults exist so a probe-only seam (a test double) still compiles; they must + // surface as an explicit "cannot submit", never as a silent success. + val probeOnly = object : PairingProber { + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult = + PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) + } + val vm = newVm(probeOnly) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + // Not gated by default → the plain failure card, exactly as before this feature existed. + assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value) + } + + @Test + fun `a blank token is never submitted`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(" ") + + assertTrue(prober.submitted.isEmpty()) + assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value) + } + + @Test + fun `a token submitted outside the prompt is a no-op`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber() + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) // still on the confirm gate — nothing has asked for a token + vm.submitAccessToken(TOKEN) + + assertTrue(prober.submitted.isEmpty()) + assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + } + + // ── The token must not outlive the submit ──────────────────────────────────────────────────────── + + @Test + fun `the token is never held in the UI state or any of its toStrings`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.Rejected } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + val promptState = vm.uiState.value + vm.submitAccessToken(TOKEN) + val afterState = vm.uiState.value + + assertFalse(promptState.toString().contains(TOKEN)) + assertFalse(afterState.toString().contains(TOKEN)) + } + + // ── The token path cannot bypass the tunnel cert-gate (RULE 4) ────────────────────────────────── + + @Test + fun `an accepted token still funnels through the device-cert gate`() = + runTest(UnconfinedTestDispatcher()) { + var certInstalled = true + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { prober.gated = false; TokenSubmission.Accepted } + val vm = newVm(prober, hasCert = { certInstalled }) + vm.bind(backgroundScope) + + vm.onManualEntry(TUNNEL) + vm.confirm() + assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value) + + // The cert is removed while the token prompt is open; the post-token probe must re-refuse. + certInstalled = false + vm.submitAccessToken(TOKEN) + + assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value) + assertEquals(1, prober.probes.size, "no network probe without a device cert") + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt index b885e71..59eaaea 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt @@ -10,12 +10,18 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import wang.yaojia.webterm.api.models.LiveSessionInfo import wang.yaojia.webterm.api.models.SessionPreview +import wang.yaojia.webterm.api.routes.ApiClient +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod import java.util.UUID /** @@ -59,7 +65,7 @@ class ThumbnailPipelineTest { } /** Hands back a fresh mock bitmap per raster (so identity distinguishes renders) + a fixed placeholder. */ - private class FakeRasterizer : ThumbnailRasterizer { + private class FakeRasterizer(private val placeholderFailure: Throwable? = null) : ThumbnailRasterizer { var rasterizeCount = 0 val placeholderBitmap: Bitmap = mockk() @@ -68,7 +74,10 @@ class ThumbnailPipelineTest { return mockk() } - override fun placeholder(): Bitmap = placeholderBitmap + override fun placeholder(): Bitmap { + placeholderFailure?.let { throw it } + return placeholderBitmap + } } /** Map-backed cache seam — keeps LRU KEYING testable without touching the stubbed android.util.LruCache. */ @@ -78,6 +87,10 @@ class ThumbnailPipelineTest { override fun put(key: ThumbnailKey, bitmap: Bitmap) { map[key] = bitmap } + + override fun retainOnly(keys: Set) { + map.keys.retainAll(keys) + } } private fun session(id: UUID, lastOutputAt: Long?): LiveSessionInfo = LiveSessionInfo( @@ -201,6 +214,158 @@ class ThumbnailPipelineTest { assertEquals(1, source.fetchStarts) } + /** + * A rasterizer failure that ALSO breaks the placeholder (a `Bitmap` OOM is the realistic case) must + * surface as `null`, NOT as an exception — the row's `LaunchedEffect` collects this directly, so a + * throw would take down the composition. It must also leave no poisoned in-flight entry: the next + * request retries instead of re-awaiting a permanently-failed `Deferred`. + */ + @Test + fun `an unrenderable thumbnail returns null and is retried rather than poisoning the key`() = runTest { + val source = FakePreviewSource(failure = RuntimeException("boom")) + val raster = FakeRasterizer(placeholderFailure = OutOfMemoryError("bitmap")) + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val s = session(UUID.randomUUID(), lastOutputAt = 9L) + + val first = async { pipeline.thumbnail(s) } + advanceUntilIdle() + assertNull(first.await()) + assertEquals(1, source.fetchStarts) + + // Nothing cacheable ⇒ the key is free again (no stale in-flight Deferred to re-await forever). + val second = async { pipeline.thumbnail(s) } + advanceUntilIdle() + assertNull(second.await()) + assertEquals(2, source.fetchStarts) + } + + /** + * The render lives in the PIPELINE's scope, not the caller's: a card scrolled off-screen mid-render + * (its collector cancelled) must not cancel the shared render another card is awaiting. + */ + @Test + fun `a cancelled caller does not cancel the shared render`() = runTest { + val gate = CompletableDeferred() + val source = FakePreviewSource(block = gate) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val s = session(UUID.randomUUID(), lastOutputAt = 3L) + + val scrolledAway = launch { pipeline.thumbnail(s) } + advanceUntilIdle() + scrolledAway.cancel() + advanceUntilIdle() + + val stillVisible = async { pipeline.thumbnail(s) } + advanceUntilIdle() + gate.complete(Unit) + advanceUntilIdle() + + assertNotNull(stillVisible.await()) + assertEquals(1, source.fetchStarts) // the first render survived the cancelled caller + assertEquals(1, raster.rasterizeCount) + } + + /** + * Bitmaps must not be retained for sessions that are gone (killed/exited) or for superseded + * `lastOutputAt` keys: [ThumbnailPipeline.retainOnly] prunes everything outside the current live list, + * so a still-live session keeps its cache hit while a dead one's bitmap is released. + */ + @Test + fun `retainOnly releases bitmaps for sessions no longer live`() = runTest { + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = FakePreviewSource(), + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val live = session(UUID.randomUUID(), lastOutputAt = 1L) + val dead = session(UUID.randomUUID(), lastOutputAt = 1L) + + val warm = launch { + pipeline.thumbnail(live) + pipeline.thumbnail(dead) + } + advanceUntilIdle() + assertTrue(warm.isCompleted) + assertEquals(2, raster.rasterizeCount) + + pipeline.retainOnly(listOf(live)) + + // The live session is still a cache hit … + val hit = async { pipeline.thumbnail(live) } + advanceUntilIdle() + hit.await() + assertEquals(2, raster.rasterizeCount) + + // … the dead one's bitmap was released, so asking again re-renders (it is no longer retained). + val miss = async { pipeline.thumbnail(dead) } + advanceUntilIdle() + miss.await() + assertEquals(3, raster.rasterizeCount) + } + + @Test + fun `raster budget downscales an oversized terminal raster and leaves small ones alone`() { + // A 161x50 session at 12px monospace rasterises to ~1127x700 = 3.1 MiB ARGB_8888 — three of those + // blow an 8 MiB cache. The budget scales it to the card's resolution, preserving aspect ratio. + val scaled = ThumbnailBudget.scaledSize(width = 1127, height = 700, maxWidth = 480) + assertNotNull(scaled) + assertEquals(480, scaled!!.width) + assertEquals(298, scaled.height) // 700 * 480 / 1127, floored + + // Already inside the budget → no scale step at all (null = draw as rendered). + assertNull(ThumbnailBudget.scaledSize(width = 320, height = 200, maxWidth = 480)) + + // A degenerate ratio never collapses to a zero-height bitmap. + val sliver = ThumbnailBudget.scaledSize(width = 4000, height = 3, maxWidth = 480) + assertNotNull(sliver) + assertEquals(1, sliver!!.height) + } + + /** + * **A thumbnail fetch must NEVER attach.** Looking at the home chooser reads the ring-buffer tail over + * the read-only `GET /live-sessions/:id/preview` route ONLY — it must not open a WS, must not register + * a client, and must not touch the session's idle clock, or merely displaying the grid would keep every + * session alive forever and defeat the idle-reclaim design (plan §5.2 / §6.7). + * + * Asserted at the transport: exactly ONE request, `GET`, on the preview path, carrying **no `Origin` + * header** (which is the marker of a guarded/state-changing route — plan §4.2/§4.3). + */ + @Test + fun `the production preview source issues only the read-only preview GET and never attaches`() = runTest { + val transport = FakeHttpTransport() + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.7:3000")) + val id = UUID.fromString("11111111-2222-4333-8444-555555555555") + val previewUrl = "http://10.0.0.7:3000/live-sessions/$id/preview" + transport.queueSuccess( + method = HttpMethod.GET, + url = previewUrl, + body = """{"id":"$id","cols":80,"rows":24,"data":"hello"}""".toByteArray(Charsets.UTF_8), + ) + + val fetched = ApiPreviewSource { ApiClient(endpoint = endpoint, http = transport) }.fetch(id) + + assertEquals("hello", fetched.data) + assertEquals(1, transport.recordedRequests.size) + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.GET, request.method) + assertEquals(previewUrl, request.url) + assertNull(request.body) + assertNull(request.headers["Origin"], "the RO preview route must not carry Origin: ${request.headers}") + } + @Test fun `preview cap keeps the tail when over 256 KiB and is a no-op under the cap`() { val big = "A".repeat(PreviewCap.MAX_PREVIEW_BYTES + 100) + "TAIL" diff --git a/docs/ANDROID_CLIENT_PLAN.md b/docs/ANDROID_CLIENT_PLAN.md index 5768ecc..d9aaabc 100644 --- a/docs/ANDROID_CLIENT_PLAN.md +++ b/docs/ANDROID_CLIENT_PLAN.md @@ -74,7 +74,7 @@ Reach functional parity with the iOS client's shipped feature set (P0 + P1). Eve - **`DELETE /live-sessions` kill-all / a "manage" page** — **iOS does NOT consume this route**; its swipe-to-kill and pointer-menu kill both use per-session `DELETE /live-sessions/:id`. Out of parity; do not build a kill-all surface iOS lacks. - **On-device WebView/xterm.js fidelity fallback** — only if Termux emulation fails QA (§6.8). Not built by default. - **System-`KeyChain` MDM cert path** — documented fallback only; wrong trust model for a per-device pinned identity. -- **Self-signed-LAN `wss://` custom trust / pinning** — bare LAN uses `ws://` cleartext (allowlisted CIDRs); `wss` is only for the tunnel/Tailscale, which present real CA-signed certs. A `CertificatePinner`/custom `X509TrustManager` path is documented but not built (§6.9, §8). +- **Self-signed-LAN `wss://` custom trust / pinning** — bare LAN uses `ws://` cleartext (permitted in `base-config`; see §6.9 — the platform has no CIDR syntax, so it CANNOT be scoped to RFC1918); `wss` is only for the tunnel/Tailscale, which present real CA-signed certs. A `CertificatePinner`/custom `X509TrustManager` path is documented but not built (§6.9, §8). - **Cross-device palette sync** — matches current design (iOS/web/Android stores are independent). - **Voice commands / editor-open / worktree-create** — server has routes (`/open-in-editor`, `/projects/worktree`) the iOS client does not consume; out of parity scope. @@ -96,7 +96,7 @@ Reach functional parity with the iOS client's shipped feature set (P0 + P1). Eve | Persistence (secret / device cert) | **Google Tink AEAD + AndroidKeystore master key**; **the private key is imported non-exportable into `AndroidKeyStore`** (the runtime home). Tink AEAD encrypts only the **cert chain + metadata** blob at rest. **No `.p12` blob or passphrase is persisted.** | Non-exportable, device-bound = the `…AfterFirstUnlockThisDeviceOnly` analogue. **Not `EncryptedFile`/`security-crypto`** (that Jetpack Security API is deprecated — draft inconsistency resolved). | | Push | **Firebase Cloud Messaging (FCM) v1**, data-only high-priority | Only way to reproduce the lock-screen two-tap Allow/Deny loop (§4.5, §9 R1). | | mTLS | **`AndroidKeyStore`-imported non-exportable key + a custom re-reading `X509KeyManager`** (returns the AndroidKeyStore `PrivateKey`/chain per handshake) → `SSLContext` → OkHttp `sslSocketFactory`. **`KeyStore("PKCS12")` is used transiently only to *parse the import file*, never as the runtime key home.** | App-private identity (NOT system `KeyChain`); re-reading KeyManager + `connectionPool.evictAll()` gives the iOS "no-relaunch cert rotation" behavior. One key home, no contradiction (R4/mustFix). | -| Server-cert trust | **Default system trust** (tunnel `*.terminal.yaojia.wang` + Tailscale MagicDNS present real LE certs). Bare LAN uses **`ws://` cleartext** via a `network_security_config` CIDR allowlist. | Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. | +| Server-cert trust | **Default system trust** (tunnel `*.terminal.yaojia.wang` + Tailscale MagicDNS present real LE certs). Bare LAN uses **`ws://` cleartext**, permitted in `network_security_config`'s `base-config` — the format has NO address-range syntax, so it cannot be narrowed to LAN literals (§6.9). | Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. | | QR scan | **CameraX + ML Kit Barcode** (`com.google.mlkit:barcode-scanning`) | On-device, one validator shared with manual entry. | | Biometric | **`androidx.biometric:BiometricPrompt`** hosted in a `FragmentActivity` (the Allow trampoline) | Gates the Allow action. | | Testing | **JUnit5 + kotlinx-coroutines-test (virtual time) + Turbine + MockK** (unit); **Espresso + Compose UI Test** (instrumented; Robolectric only where it faithfully emulates); **Kover** (coverage) | Mirrors iOS's injected-clock/fake-transport discipline. `AndroidKeyStore`/Tink tests are **instrumented on a real emulator/device** — Robolectric does not provide the AndroidKeyStore provider (platform review). | @@ -429,7 +429,20 @@ Two tiers, chosen by decision gate. **Note the blast radius:** the tier-2 WebVie OkHttp requires an `(SSLSocketFactory, X509TrustManager)` pair. Decision: - **Tunnel (`*.terminal.yaojia.wang`) + Tailscale MagicDNS:** real CA-signed (LE) certs → **default system trust**. No custom trust manager. -- **Bare LAN:** use **`ws://` cleartext**, permitted only for private LAN/Tailscale CIDRs via `network_security_config` (global `usesCleartextTraffic=false`). +- **Bare LAN:** use **`ws://` cleartext**, permitted in `network_security_config`'s `base-config`. + **CORRECTED (was wrong in this plan until the A19 fix):** the original text said cleartext would be + "permitted only for private LAN/Tailscale CIDRs via `network_security_config`". That is not + expressible. The network-security-config format has NO netmask/prefix/CIDR attribute anywhere — a + `` holds one hostname or one IP literal, and `includeSubdomains` walks DNS labels, so it cannot + generalise `192.168.0.0/16`. Enumerating ~18.9M addresses is not an option either. Since a LAN address + is user-typed at runtime and this file is a build-time resource, it also cannot be scoped to "the IP the + user actually entered". So: cleartext is ON in `base-config`, `*.terminal.yaojia.wang` keeps an explicit + `cleartextTrafficPermitted="false"` block (the one hostname known at build time), trust anchors are + pinned to `system` only, and the real guard moves into the app — the §5.4 warning tiers plus + confirm-before-network. The scoping the platform cannot express is additionally enforced at + transport time in `OkHttpClientFactory`, which refuses a plaintext dial to a non-private host. + Honest tradeoff: on bare `ws://` the terminal bytes and the `WEBTERM_TOKEN` cookie are readable and + replayable by anyone on the path. This is a LAN/Tailscale posture, never an internet-exposure one. - **Self-signed LAN `wss`:** DEFERRED — a documented `CertificatePinner`/custom `X509TrustManager` path, not built by default. --- @@ -468,7 +481,7 @@ Gesture/IME/CJK composition, camera-QR, haptics, lock-screen Allow/Deny, FCM end - [ ] **mTLS trust model (one key home)** — private key **imported non-exportable into `AndroidKeyStore`**; a **custom re-reading `X509KeyManager`** returns the AndroidKeyStore `PrivateKey`/chain per handshake; `KeyStore("PKCS12")` is import-parse-only (transient), never the runtime home; **NOT** system `KeyChain`. Client cert presented on both WS and REST via one shared `OkHttpClient`. On rotation, `connectionPool.evictAll()` so pooled/resumed connections drop the old identity. - [ ] **Cert storage** — private key non-exportable/device-bound; only the **cert chain + metadata** blob is stored, encrypted with **Tink AEAD + AndroidKeystore master key**; app-private, uninstall-wiped, never in backups/cloud. **No `.p12` blob or passphrase persisted.** Import validates before persisting so a bad passphrase can't clobber a prior identity. - [ ] **No system-wide credential leakage** — no ephemeral/disk HTTP cache (`OkHttpClient.cache(null)`) — preview/diff bodies can contain terminal secrets. -- [ ] **Cleartext posture** — `usesCleartextTraffic=false` globally; a `network_security_config` allowlist permits `ws://` only for LAN/Tailscale CIDRs; `wss` (tunnel/Tailscale) uses default system trust; §5.4 warning tiers reproduced in-app (public-host blocking needs explicit acknowledge; tunnel host = cert-gated, warning softened, TLS failure re-mapped to "client cert invalid/revoked"). +- [x] **Cleartext posture** — cleartext permitted in `base-config` (NO CIDR syntax exists — see §6.9), `*.terminal.yaojia.wang` explicitly denied, trust anchors `system`-only, no `debug-overrides`, and a transport-time private-host check in `OkHttpClientFactory`; locked by `NetworkSecurityConfigTest`; `wss` (tunnel/Tailscale) uses default system trust; §5.4 warning tiers reproduced in-app (public-host blocking needs explicit acknowledge; tunnel host = cert-gated, warning softened, TLS failure re-mapped to "client cert invalid/revoked"). - [ ] **Tunnel-host cert gate** — `runProbe` refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed; both confirm and retry funnel through the one choke point (retry can't bypass). - [ ] **Push token discipline** — single-use `/hook/decision` capability token validated as v4 UUID, passed only to the POST, **never persisted, never logged, never in fallback copy**; payload minimized (`sessionId`/`cls`/`token` only). - [ ] **Notification-action trust split** — **Deny → `BroadcastReceiver`** (`goAsync()`, no UI, expedited POST). **Allow → a translucent, `excludeFromRecents` trampoline `Activity`** that hosts `BiometricPrompt` then POSTs — a `BroadcastReceiver`/`Service` cannot present `BiometricPrompt` and `goAsync()`'s ~10s budget would blow. (Corrects the draft's "no app open" claim for Allow.) All `PendingIntent`s `FLAG_IMMUTABLE` (API 31+). Deny is auth-free (fail-safe). The decision POST is **expedited** (not deferrable `WorkManager`); the single-use token makes a retried-after-success POST return 403 → idempotent retry-safety. diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 1f036d1..9eb9779 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,32 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### 🤖 [~] Android 客户端"能装但不能用"修复 — blocker 已清,设备验证未做(2026-07-30,worktree `android-blocker-fixes`) + +- **起因**: 例行问"android 客户端完成状况如何"。`android/PROGRESS_ANDROID.md` 写着 **"✅ ANDROID CLIENT COMPLETE — all 36 plan tasks landed"**,617 个 JVM 测试全绿,APK 也出得来。**这个"完成"只在编译层面成立** —— 从来没在任何真机或模拟器上跑过,而一旦跑,第一个动作就崩。审计(45 agent,含逐条对抗验证)+ 修复(多波 agent)已把 blocker 清掉。 +- **为什么 JVM 测试全绿也漏掉了**: 一个结构性原因 —— **没有任何 JVM 测试会实例化 Android `View`**。`:terminal-view` 那 17 个测试测的是纯逻辑,渲染/输入/触摸整条路径无人触碰。 +- **三个让 App 在真机上不可用的缺陷**(每一条都反编译 `terminal-view-v0.118.0.aar` 逐偏移确认): + 1. **按任何键必崩**。`RemoteTerminalView` 只绑了 emulator,从没调用 `setTerminalViewClient` —— KDoc 写着"A17 会装",A17 实际只交付了 `HardwareKeyRouter` 和 KeyBar(而 KeyBar 之所以能用,恰恰因为它绕过了 view)。stock `TerminalView` 在 `onKeyDown` offset 82、`onCreateInputConnection` offset 0、`onKeyUp`、`onKeyPreIme` 无判空解引用 `mClient`。**装个 client 还不够**:返回 false 会继续走到 offset 150 的 `mTermSession.write()`,而 `mTermSession` 永远是 null(`TerminalSession` 是 final,而 fork 进程正是本 App 绝不能做的事,§6.1)。修法:client 自己消费按键,且 `KeyRouting` **刻意没有"交还 stock"这个分支** —— 崩溃变成不可表达,而不只是被绕开。键表仍由 Termux `KeyHandler` 决定,DECCKM 照旧发 `ESC O A`。 + 2. **resize 从来不发**。`RemoteTerminalSession.updateSize` 零调用点,stock 兜底也是死的(`TerminalView.updateSize` 无 session 时 offset 18-25 直接 return),PTY 一直停在服务端的 80×24,所有全屏 TUI 都排版进错误的框。`TerminalResizeDriver` 现在从真实字距驱动 —— 字距走**公开** accessor `TerminalRenderer.getFontWidth()`/`getFontLineSpacing()`(agent 自己 `javap` 发现它们是 public),**不用反射**(R8 下会静默失效)也**不重测 Paint**(会与渲染器实际所用漂移,正是 §R5 风险)。 + 3. **局域网连不上**。`network_security_config.xml` 是自己标着 STUB 的全局禁明文,而 `HostEndpoint` 接受 `http` 并派生 `ws://`。它注释里承诺的 CIDR 白名单**平台根本不支持**(NSC 格式没有任何 netmask/prefix 属性,`` 只装一个主机名或一个 IP 字面量)。改为 base-config 放开 + `*.terminal.yaojia.wang` 显式禁明文 + 信任锚钉死 system + `OkHttpClientFactory` 在传输层拒绝向非私有地址发起明文 —— 平台表达不了的收窄,放到 HTTP 客户端里做。plan §6.9/§8 的错误描述已一并更正。 +- **对抗评审又挖出三个,其中一条评审自己判错了**: + - **在 alternate-screen TUI 里滑动滚屏仍会崩**。触摸路径完全绕过 `TerminalViewClient`:`doScroll` 在 alternate buffer 激活时把滚动转成 `handleKeyCode`,而那里解引用 `mTermSession`。**Claude Code 本身就是 alternate-screen TUI**,所以这是主界面正常使用中的崩溃。`TerminalScrollGesture` 抢占纵向拖动并复现 `doScroll` 的三个分支,连 fling runnable 和 `onGenericMotionEvent` 一并封死。 + - **`autofill()` 无判空解引用 `mTermSession`**,而该 view 自称可自动填充 → 任何密码管理器一填就崩。整个子树已排除出 autofill 结构。 + - 评审要求**恢复** stock 文字选择(此前被 focus 改造弄成不可达)。**这条它错了**:ActionMode 自己的 Copy/Paste 处理器也解引用空 session(`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136),恢复可达性只会把崩溃搬到用户点 Copy 那一刻 —— 比没有选择功能更糟。**长按保持被消费,复制功能如实记为功能缺口**(真正的修法是基于 `TerminalBuffer` + `ClipboardManager` 自建选择层)。 +- **被静默丢弃的服务端数据,现已解码**: W2 `queue` 帧在 `ServerMessageType` 里没有成员,`decodeServer` 返回 null,`SessionEngine` 当"无法解码"丢掉 —— "N queued"徽标在 Android 上**从来不可能出现**;W1 `status.preview` 完全没建模,导致远程审批是**盲批**(只看到工具名,看不到要执行的命令/diff)。两者均已解码。一条规矩用测试钉住:**preview 坏掉绝不能连帧一起丢** —— `pending` 才是让 gate 出现的信号。 +- **当作功能发布的死代码**: `ThumbnailPipeline`(会话列表预览)、`QuickReply`(芯片+调色板)、`PointerContextMenu` 三者实现完整、单测齐全、**生产代码零调用点**。均已接线。`ThumbnailPipeline` 当年被**明确跳过**的交叉评审(AW3 条目自己承认)这次补做了。 +- **凭据处理**: `WEBTERM_TOKEN` cookie 现在在 REST 和 WS upgrade 上都携带,用 Tink AEAD + AndroidKeyStore 封装,**fail-closed**(封装失败就不持久化,绝不退回明文);`allowBackup=false` 阻止这个 30 天 shell 凭据经 Google Drive / D2D 外泄。 +- **v0.6 git 面板数据层**: 服务端 8fe1f52 之后的字段 Android 全没解码。已补 `SyncState`/`dirtyCount`/`cwd`/`unpushed`/`upstream` + `GET /projects/worktree/state` + `POST /projects/git/fetch`。两条易错语义**编码成行为**而非靠调用点自觉:`SyncState.isFullySynced(now)` 是唯一被认可的"能否显示绿色"判断(任何未知即 false,**无 upstream ≠ 0**);`GitLogResult.upstreamBoundaryIndex` 找**最后一个**未推送提交(取前 N 行会朝危险方向出错)。 +- **验证**: `./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify` —— blocker 修复提交时 **757 个 JVM 测试全绿**(基线 617);`:wire-protocol`/`:session-core`/`:api-client` 三处 koverVerify 均过。commit `3e5cfdc`、`3fe719f`、`35d32f4`。 +- **方法与其代价(如实记录)**: 分波多 agent(TDD builder → 对抗评审 → 修复)。**API 端持续 529 过载,13 个 agent 里 8 个、11 个里 9 个被打掉**;协议层和 REST 层两轮全灭后由 orchestrator 亲自补写。这也是为什么会话成本冲到 ~$360。对抗评审确实值 —— 滑动滚屏那个 CRITICAL 崩溃是 builder 漏掉、reviewer 抓到的。 +- **仍未完成(不要把上面读成已完成)**: + - **A34 / A35 完全没有代码** —— `:app` 连 androidTest 源集都没有,也没有 benchmark 模块。AW6 条目把它们标成 `[x]` 是因为它们被转成了清单条目,不是实现。 + - **设备 QA 仍约 0%** —— 仓库里唯一的真机证据是一张配对页的模拟器截图。上面每一条修复都只有 JVM 级验证。 + - release 签名/版本号/minify;S2 FCM 真机投递;复制功能。 + - `android/PROGRESS_ANDROID.md` 顶部已加"CORRECTION"段落说明上述全部。 +- **下一步**: 模拟器(AVD `webterm`,已启动)上跑 instrumented 回归 —— 尤其是这次修掉的三个崩溃,那正是 JVM 测试抓不到、也正是本次修复存在的理由。 + + ### 🌿 [x] w6 项目详情 Git 面板 — G1–G7 全部完成(2026-07-29,worktree `project-detail-git-mockup`) - **动机**: 详情页只有一个光秃秃的 `●`,回答不了"有没有 commit 没 push / 现在在哪个 worktree"。设计稿 `docs/mockups/project-detail-git.html`,计划 `docs/plans/w6-project-git-panel.md`。