feat(android): wire the dead code, render the git panel, make the token gate real
Four agents' worth of :app wiring. Everything here existed as tested code with
zero production call sites, or as a screen rendering a shape the server stopped
sending months ago.
Dead code that shipped as features. All three were fully implemented and
unit-tested while being reachable from nothing:
- Session-list preview thumbnails. SessionsHome called SessionListScreen
without the `thumbnails` argument, so the `= null` default won and every
row rendered a placeholder forever — on the home session chooser, whose
entire purpose is seeing what each session is doing.
- Quick-reply chips and their palette editor.
- PointerContextMenu (large-screen secondary click).
ThumbnailPipeline's formal cross-review had been explicitly skipped when it was
written — PROGRESS_ANDROID.md said so in as many words, and it is concurrency
code, so nothing had ever reviewed OR executed it. It has now been reviewed and
kept: the LruCache key, the fair Semaphore(2), the Mutex-guarded in-flight
dedup and the NonCancellable in-flight release are all sound, and a retained
map entry would have poisoned a key forever.
The v0.6 project-detail git panel. Android rendered the pre-w6 shape: one bare
dirty dot, a bare hash+subject commit row, and a worktree heading that renamed
itself to "Branch" at n=1 — the exact behaviour G5 removed. Now the sync band,
unpushed marking with a single upstream boundary, and "Worktrees (n)". The rule
that drives it is enforced, not documented: exactly one state may render green,
and no-upstream is not that state.
Remote approvals are no longer blind. status.preview reaches GateBanner and
PlanGateSheet, so the user can see the command or diff they are approving
instead of a tool name. Absent preview stays an ordinary gate, not an error.
The WEBTERM_TOKEN gate is no longer inert. NetworkModule was calling
OkHttpClientFactory.create(identityProvider) with no cookieJar, so the shared
client ran on CookieJar.NO_COOKIES and none of the cookie plumbing did
anything. It now installs the real AuthCookieJar — one client, so the cookie
rides the WS upgrade too — backed by the Tink-sealed store, and binds
InMemoryAuthCookieStore if a cipher cannot be constructed rather than ever
persisting a shell credential in the clear. The persister runs on an IO scope
because OkHttp calls a CookieJar synchronously and must never block on
DataStore or Tink.
And the user-facing half that made the gate unusable: a token-gated host
answers the pairing probe's unauthenticated GET with 401, which the frozen
taxonomy could only report as "this port is running something else" — so such
a host was impossible to pair AND the copy was misleading. Pairing now routes
to a token prompt and resumes through the same cert-gate choke point. The
token is a parameter, never a field and never part of any UI state.
Docs corrected rather than left aspirational: the plan asserted a cleartext
allowlist "permitted only for private LAN/Tailscale CIDRs", which the platform
cannot express at all — the network-security-config format has no netmask or
prefix syntax. PROGRESS_ANDROID.md now opens with what "COMPLETE" actually
meant, and PROGRESS_LOG.md records the whole pass including what is still
undone: A34/A35 have no code, and device QA remains ~0%.
Verified: ./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify
-> BUILD SUCCESSFUL, 893 JVM tests, 0 failures (baseline was 617). The :app
run was re-measured with outputs deleted and --no-build-cache, and the APK
re-packaged, so Hilt graph validity is an observation rather than an
up-to-date claim.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -17,6 +17,8 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import wang.yaojia.webterm.nav.LayoutMode
|
||||
import wang.yaojia.webterm.nav.PointerMenuPolicy
|
||||
import wang.yaojia.webterm.viewmodels.SessionRow
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
|
||||
@@ -59,7 +61,12 @@ public fun PointerContextMenu(
|
||||
val density = LocalDensity.current
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(actions) {
|
||||
// Keyed on Unit, NOT on [actions]: the gesture body only records the anchor and opens the menu —
|
||||
// it never reads [actions] (the DropdownMenu below reads the current list on every composition).
|
||||
// Keying on the list would restart the pointer handler on every recomposition, because the
|
||||
// per-entry lambdas in a freshly-built List<ContextMenuAction> are never equal to the previous
|
||||
// ones — a right-click landing during that restart window would be dropped.
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
@@ -95,3 +102,41 @@ public fun PointerContextMenu(
|
||||
|
||||
/** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */
|
||||
public data class ContextMenuAction(val label: String, val onClick: () -> Unit)
|
||||
|
||||
// ── The session-row action set (A26 — the one surface the pointer menu is attached to) ───────────────
|
||||
|
||||
/** 打开会话 — the same target a row tap has. */
|
||||
public const val MENU_LABEL_OPEN: String = "打开会话"
|
||||
|
||||
/** 在当前目录开新会话 — `attach(null, cwd)` in the row's own cwd (plan §1 new-session-in-cwd). */
|
||||
public const val MENU_LABEL_NEW_IN_CWD: String = "在当前目录开新会话"
|
||||
|
||||
/** 终止会话 — `DELETE /live-sessions/:id`, the pointer equivalent of swipe-to-kill. */
|
||||
public const val MENU_LABEL_KILL: String = "终止会话"
|
||||
|
||||
/**
|
||||
* The [PointerContextMenu] entries for one session row — the pure half of the A26 pointer menu (the
|
||||
* gesture and the placement are device-QA; [PointerMenuPolicy] gates whether the menu exists at all).
|
||||
*
|
||||
* Mirrors iOS's pointer menu with ONE deliberate omission: **copy is absent**. Copy-out lives in the
|
||||
* terminal's own text-selection `ActionMode`, not in a list-row menu, and the row has no selected text to
|
||||
* copy — advertising it would be a dead entry.
|
||||
*
|
||||
* The new-session entry appears only when BOTH a usable cwd is known AND a spawn handler is wired: the
|
||||
* server may omit `cwd` (or send blank), and `attach(null, cwd)` with no directory is not the same action.
|
||||
* Every label here is a fixed app string — the server-provided cwd is carried in the CALLBACK only and is
|
||||
* never rendered into a menu entry (plan §8: untrusted server strings stay out of chrome).
|
||||
*/
|
||||
public fun sessionRowMenuActions(
|
||||
row: SessionRow,
|
||||
onOpen: (UUID) -> Unit,
|
||||
onNewSessionInCwd: ((String) -> Unit)?,
|
||||
onKill: (UUID) -> Unit,
|
||||
): List<ContextMenuAction> = buildList {
|
||||
add(ContextMenuAction(MENU_LABEL_OPEN) { onOpen(row.id) })
|
||||
val cwd = row.info.cwd?.takeIf { it.isNotBlank() }
|
||||
if (cwd != null && onNewSessionInCwd != null) {
|
||||
add(ContextMenuAction(MENU_LABEL_NEW_IN_CWD) { onNewSessionInCwd(cwd) })
|
||||
}
|
||||
add(ContextMenuAction(MENU_LABEL_KILL) { onKill(row.id) })
|
||||
}
|
||||
|
||||
@@ -2,20 +2,35 @@ package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.SuggestionChipDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import wang.yaojia.webterm.designsystem.Radius
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
@@ -96,6 +111,241 @@ internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) {
|
||||
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
|
||||
gateHeld && hasChips
|
||||
|
||||
// ── The palette presenter (production state holder) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* The state holder the terminal screen renders the chips from: one published, immutable
|
||||
* [chips] snapshot over a [QuickReplyStore].
|
||||
*
|
||||
* Every mutation delegates to the store — which is the CRUD authority and returns the new
|
||||
* list — and republishes exactly what came back, so the UI can never drift from what was
|
||||
* persisted (no optimistic local copy to reconcile, and a guarded no-op like a blank `add`
|
||||
* simply republishes the unchanged list). Nothing is published before [load]: an unloaded
|
||||
* palette shows no chips rather than flashing the defaults over the user's real list.
|
||||
*
|
||||
* Plain (not an `androidx.lifecycle.ViewModel`) so it drives under `runTest` virtual time;
|
||||
* the screen `remember`s one and loads it in a `LaunchedEffect`. Cross-device sync is out of
|
||||
* scope (plan §1) — this is one device's palette.
|
||||
*/
|
||||
public class QuickReplyPalette(private val store: QuickReplyStore) {
|
||||
private val _chips = MutableStateFlow<List<QuickReplyChip>>(emptyList())
|
||||
|
||||
/** The palette in display order; empty until [load] (and after a user "delete all"). */
|
||||
public val chips: StateFlow<List<QuickReplyChip>> = _chips.asStateFlow()
|
||||
|
||||
/** Read the persisted palette (defaults on first ever read) and publish it. */
|
||||
public suspend fun load() {
|
||||
_chips.value = store.loadAll()
|
||||
}
|
||||
|
||||
/** Append [text] (blank is a store-level no-op) and republish. */
|
||||
public suspend fun add(text: String) {
|
||||
_chips.value = store.add(text)
|
||||
}
|
||||
|
||||
/** Replace the text of [id] and republish (unknown id / blank text → no-op). */
|
||||
public suspend fun edit(id: String, text: String) {
|
||||
_chips.value = store.edit(id, text)
|
||||
}
|
||||
|
||||
/** Remove [id] and republish (unknown id → no-op). */
|
||||
public suspend fun remove(id: String) {
|
||||
_chips.value = store.remove(id)
|
||||
}
|
||||
|
||||
/** Reorder [from]→[to] and republish (out-of-range → no-op). */
|
||||
public suspend fun move(from: Int, to: Int) {
|
||||
_chips.value = store.move(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The editor's select / commit reducer (pure, JVM-tested) ─────────────────────
|
||||
|
||||
/**
|
||||
* The editor's whole mutable state: ONE text field that either appends a new chip
|
||||
* ([selectedId] null) or rewrites the selected one. Immutable — every transition returns a
|
||||
* fresh snapshot.
|
||||
*
|
||||
* A single field (rather than an inline field per row) is deliberate: a per-row bound field
|
||||
* would write to the store on every keystroke, i.e. one DataStore round-trip per character.
|
||||
*/
|
||||
internal data class QuickReplyEditorState(
|
||||
/** The chip being rewritten, or null while composing a new one. */
|
||||
val selectedId: String? = null,
|
||||
/** The field's current text, carried VERBATIM into the commit (whitespace preserved). */
|
||||
val draft: String = "",
|
||||
)
|
||||
|
||||
/** What committing the current [QuickReplyEditorState] does. */
|
||||
internal enum class QuickReplyCommit { ADD, EDIT, NONE }
|
||||
|
||||
/** Load [chip] into the field for rewriting (its id becomes the commit target). */
|
||||
internal fun QuickReplyEditorState.selecting(chip: QuickReplyChip): QuickReplyEditorState =
|
||||
copy(selectedId = chip.id, draft = chip.text)
|
||||
|
||||
/** Back to "composing a new chip" with an empty field. */
|
||||
internal fun QuickReplyEditorState.cleared(): QuickReplyEditorState =
|
||||
QuickReplyEditorState()
|
||||
|
||||
/**
|
||||
* A blank draft commits NOTHING (mirrors the store's blank guard, so the button can be
|
||||
* disabled instead of silently no-op-ing); otherwise ADD when nothing is selected, EDIT when
|
||||
* a chip is.
|
||||
*/
|
||||
internal fun QuickReplyEditorState.commitKind(): QuickReplyCommit = when {
|
||||
draft.isBlank() -> QuickReplyCommit.NONE
|
||||
selectedId == null -> QuickReplyCommit.ADD
|
||||
else -> QuickReplyCommit.EDIT
|
||||
}
|
||||
|
||||
// ── The palette editor ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The editable-palette surface (plan §1 "Quick-reply chips + editable palette"): an
|
||||
* [AlertDialog] over the store's CRUD. One field composes/rewrites; each row can be selected
|
||||
* (tap 编辑), reordered (▲/▼) or deleted (✕).
|
||||
*
|
||||
* The chip texts are the user's OWN strings — never server data — but they are still rendered
|
||||
* as plain inert [Text] (no Markdown/autolink), consistent with §8.
|
||||
*
|
||||
* Layout/scroll behaviour is device-QA (§7); the select/commit decision is the JVM-tested
|
||||
* [QuickReplyEditorState] reducer.
|
||||
*
|
||||
* @param chips the current palette snapshot (from [QuickReplyPalette.chips]).
|
||||
* @param onAdd / [onEdit] / [onRemove] / [onMove] the store-backed CRUD callbacks.
|
||||
* @param onDismiss close the editor.
|
||||
*/
|
||||
@Composable
|
||||
public fun QuickReplyPaletteEditor(
|
||||
chips: List<QuickReplyChip>,
|
||||
onAdd: (String) -> Unit,
|
||||
onEdit: (id: String, text: String) -> Unit,
|
||||
onRemove: (id: String) -> Unit,
|
||||
onMove: (from: Int, to: Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var state by remember { mutableStateOf(QuickReplyEditorState()) }
|
||||
// A chip removed while selected must not leave a dangling edit target pointing at a gone id.
|
||||
// DERIVED, not written back during composition (a composition-time state write is a recomposition
|
||||
// hazard): the field simply falls back to "adding" and keeps whatever was typed.
|
||||
val effective = if (state.selectedId != null && chips.none { it.id == state.selectedId }) {
|
||||
state.copy(selectedId = null)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("快捷回复") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = EDITOR_MAX_HEIGHT_DP.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
QuickReplyEditorField(
|
||||
state = effective,
|
||||
onDraftChange = { state = effective.copy(draft = it) },
|
||||
onCommit = {
|
||||
when (effective.commitKind()) {
|
||||
QuickReplyCommit.ADD -> onAdd(effective.draft)
|
||||
QuickReplyCommit.EDIT -> onEdit(requireNotNull(effective.selectedId), effective.draft)
|
||||
QuickReplyCommit.NONE -> Unit
|
||||
}
|
||||
state = effective.cleared()
|
||||
},
|
||||
onCancelSelection = { state = effective.cleared() },
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
if (chips.isEmpty()) {
|
||||
Text("还没有快捷回复。", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
chips.forEachIndexed { index, chip ->
|
||||
QuickReplyEditorRow(
|
||||
chip = chip,
|
||||
isSelected = chip.id == effective.selectedId,
|
||||
canMoveUp = index > 0,
|
||||
canMoveDown = index < chips.lastIndex,
|
||||
onSelect = { state = effective.selecting(chip) },
|
||||
onMoveUp = { onMove(index, index - 1) },
|
||||
onMoveDown = { onMove(index, index + 1) },
|
||||
onRemove = { onRemove(chip.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("完成") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** The single compose/rewrite field + its commit action. */
|
||||
@Composable
|
||||
private fun QuickReplyEditorField(
|
||||
state: QuickReplyEditorState,
|
||||
onDraftChange: (String) -> Unit,
|
||||
onCommit: () -> Unit,
|
||||
onCancelSelection: () -> Unit,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.draft,
|
||||
onValueChange = onDraftChange,
|
||||
label = { Text(if (state.selectedId == null) "新增内容" else "修改内容") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
TextButton(
|
||||
onClick = onCommit,
|
||||
enabled = state.commitKind() != QuickReplyCommit.NONE,
|
||||
) { Text(if (state.selectedId == null) "添加" else "保存") }
|
||||
if (state.selectedId != null) {
|
||||
TextButton(onClick = onCancelSelection) { Text("取消") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One palette row: the (inert) text plus select / reorder / delete affordances. */
|
||||
@Composable
|
||||
private fun QuickReplyEditorRow(
|
||||
chip: QuickReplyChip,
|
||||
isSelected: Boolean,
|
||||
canMoveUp: Boolean,
|
||||
canMoveDown: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
onMoveUp: () -> Unit,
|
||||
onMoveDown: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = chip.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onSelect) { Text("编辑") }
|
||||
TextButton(onClick = onMoveUp, enabled = canMoveUp) { Text("▲") }
|
||||
TextButton(onClick = onMoveDown, enabled = canMoveDown) { Text("▼") }
|
||||
TextButton(onClick = onRemove) { Text("✕") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Cap on the editor body height so a long palette scrolls inside the dialog. */
|
||||
private const val EDITOR_MAX_HEIGHT_DP: Int = 320
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "QuickReply (gate held)")
|
||||
@@ -109,3 +359,18 @@ private fun QuickReplyPreview() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "QuickReply palette editor")
|
||||
@Composable
|
||||
private fun QuickReplyPaletteEditorPreview() {
|
||||
WebTermTheme {
|
||||
QuickReplyPaletteEditor(
|
||||
chips = QuickReplyDefaults.chips,
|
||||
onAdd = {},
|
||||
onEdit = { _, _ -> },
|
||||
onRemove = {},
|
||||
onMove = { _, _ -> },
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package wang.yaojia.webterm.di
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.CookieJar
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieCipher
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.DataStoreAuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
|
||||
import wang.yaojia.webterm.tlsandroid.TinkAuthCookieCipher
|
||||
import wang.yaojia.webterm.transport.AuthCookieJar
|
||||
import wang.yaojia.webterm.wiring.toRecord
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The optional `WEBTERM_TOKEN` gate's client boundary (server `src/http/auth.ts`).
|
||||
*
|
||||
* Three things are wired here, and all three are required for the gate to work at all:
|
||||
* 1. **[AuthCookieJar] on the ONE shared `OkHttpClient`** (via the [CookieJar] binding [NetworkModule]
|
||||
* consumes). Both transports share that client, so the `webterm_auth` cookie rides every REST call
|
||||
* AND the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls too). Without this the
|
||||
* client runs on `CookieJar.NO_COOKIES` and a token-gated host 401s everything.
|
||||
* 2. **[AuthCookieStore]** so the credential survives a process restart — encrypted at rest.
|
||||
* 3. **The [AuthCookieCipher] seam** bridging `:host-registry` (which must not depend on Tink) to
|
||||
* `:client-tls-android`'s [TinkAuthCookieCipher] (which must not depend on `:host-registry`) — the
|
||||
* same sibling-bridge pattern [NetworkModule] uses for the mTLS `ClientIdentityProvider`.
|
||||
*
|
||||
* ### Fail CLOSED
|
||||
* [DataStoreAuthCookieStore] takes the cipher as a REQUIRED constructor argument precisely so no wiring
|
||||
* can persist plaintext by omission. If a real cipher cannot be constructed, this module binds
|
||||
* [InMemoryAuthCookieStore] instead: memory-only persistence (the user re-authenticates after a restart)
|
||||
* is the correct answer, and writing a full-shell credential in the clear is not an option. A cipher that
|
||||
* constructs but later fails to seal is handled the same way one level down — the store persists NOTHING
|
||||
* and drops any previous blob.
|
||||
*
|
||||
* ### Threading
|
||||
* The graph builds nothing expensive: [TinkAuthCookieCipher] defers all AEAD/keystore work to first use,
|
||||
* and `AppEnvironment.warmUp()` does the first read off `Main`. The persister side must be non-blocking
|
||||
* because OkHttp calls a `CookieJar` synchronously on the call thread, so the durable write is handed to
|
||||
* an app-scoped IO scope.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
public object AuthCookieModule {
|
||||
|
||||
/**
|
||||
* The durable home of the `webterm_auth` cookie. Shares the ONE Preferences DataStore with the host
|
||||
* list / last-session pointer ([StorageModule]) — disjoint keys (`authCookiesSealed` vs `hosts` /
|
||||
* `lastSessionId.<hostId>`) — but the VALUE here is Tink-AEAD ciphertext, never plaintext.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideAuthCookieStore(
|
||||
dataStore: DataStore<Preferences>,
|
||||
@ApplicationContext context: Context,
|
||||
): AuthCookieStore {
|
||||
val cipher = buildAuthCookieCipher(context)
|
||||
if (cipher == null) {
|
||||
// Fail closed: memory-only rather than a plaintext credential at rest.
|
||||
Log.w(TAG, "auth-cookie cipher unavailable — the session cookie will not be persisted")
|
||||
return InMemoryAuthCookieStore()
|
||||
}
|
||||
return DataStoreAuthCookieStore(
|
||||
dataStore = dataStore,
|
||||
cipher = cipher,
|
||||
onCipherFailure = ::reportCipherFailure,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE jar. Its persister hands each host's changed cookie set to [AuthCookieStore] on an
|
||||
* app-scoped IO scope — never blocking OkHttp's call thread on DataStore/Tink I/O. The scope lives for
|
||||
* the process (there is no later moment at which a pending credential write should be abandoned).
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideAuthCookieJar(store: AuthCookieStore): AuthCookieJar {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
return AuthCookieJar(
|
||||
persister = { hostKey, cookies ->
|
||||
scope.launch {
|
||||
runCatching { store.replaceHost(hostKey, cookies.map { it.toRecord(hostKey) }) }
|
||||
.onFailure(::reportPersistFailure)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** What [NetworkModule] installs on the shared client — the same instance as [provideAuthCookieJar]. */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideCookieJar(jar: AuthCookieJar): CookieJar = jar
|
||||
|
||||
/**
|
||||
* The 4-line adapter [TinkAuthCookieCipher]'s KDoc prescribes: it matches [AuthCookieCipher]'s shape
|
||||
* exactly but cannot declare it (sibling modules), so `:app` bridges the two.
|
||||
*
|
||||
* Returns null if the cipher cannot even be CONSTRUCTED. Today's implementation defers all keystore
|
||||
* work to first use and so does not throw here; the guard exists because the alternative to "no
|
||||
* cipher" must always be "no persistence", never "plaintext persistence".
|
||||
*/
|
||||
private fun buildAuthCookieCipher(context: Context): AuthCookieCipher? =
|
||||
runCatching {
|
||||
val tink = TinkAuthCookieCipher(context)
|
||||
object : AuthCookieCipher {
|
||||
override fun seal(plaintext: String): String = tink.seal(plaintext)
|
||||
override fun open(sealed: String): String = tink.open(sealed)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* A seal/open failure means a credential was dropped instead of stored (or a stored one could not be
|
||||
* read). Reported by TYPE only: neither the plaintext nor the ciphertext may reach a log.
|
||||
*/
|
||||
private fun reportCipherFailure(error: Throwable) {
|
||||
Log.w(TAG, "auth-cookie at-rest crypto failed (${error::class.simpleName}) — nothing was persisted")
|
||||
}
|
||||
|
||||
/** A durable-write failure leaves the cookie in memory only; same logging discipline. */
|
||||
private fun reportPersistFailure(error: Throwable) {
|
||||
Log.w(TAG, "auth-cookie persist failed (${error::class.simpleName}) — cookie kept in memory only")
|
||||
}
|
||||
|
||||
private const val TAG: String = "WebTermAuthCookie"
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<Host?>(initialValue = null, key1 = activeHostId) {
|
||||
// Clear FIRST: produceState keeps the previous value across a key change, so without this a host
|
||||
// switch would leave the OLD host's endpoint in place and the pipeline would fetch host A's
|
||||
// session ids against host B until the resolve lands.
|
||||
value = null
|
||||
value = activeHostId?.let { id ->
|
||||
runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == id } }.getOrNull()
|
||||
}
|
||||
}
|
||||
// Constructing the pipeline is cheap and Main-safe: the ApiClient (→ shared OkHttpClient → mTLS
|
||||
// material) resolves lazily on the first fetch, which runs on the pipeline's own dispatcher.
|
||||
val pipeline: ThumbnailPipeline? = remember(activeHost) {
|
||||
activeHost?.let { host -> sessionThumbnailPipeline { env.apiClientFactory.create(host.endpoint) } }
|
||||
}
|
||||
DisposableEffect(pipeline) {
|
||||
onDispose { pipeline?.close() }
|
||||
}
|
||||
val thumbnails: SessionThumbnails? = remember(pipeline) {
|
||||
pipeline?.let { live -> SessionThumbnails { session -> live.thumbnail(session) } }
|
||||
}
|
||||
// Release bitmaps for sessions that are no longer live (killed/exited) and superseded lastOutputAt
|
||||
// keys — 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String> = when (tier) {
|
||||
|
||||
@Composable
|
||||
private fun ProbingStep(host: String) {
|
||||
BusyStep(message = "正在验证 $host …")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BusyStep(message: String) {
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
CircularProgressIndicator()
|
||||
Text("正在验证 $host …", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(message, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Access token (the optional WEBTERM_TOKEN gate) ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The access-token prompt for a `WEBTERM_TOKEN`-gated host.
|
||||
*
|
||||
* The token is a **shell credential**, so the field is masked ([PasswordVisualTransformation]) and typed
|
||||
* as [KeyboardType.Password] (which also keeps it out of the keyboard's suggestion/learning store), and
|
||||
* the composition drops it the instant it is handed to the ViewModel — nothing here, and nothing in the
|
||||
* resulting UI state, retains it. All copy is app-authored and inert (plan §8).
|
||||
*/
|
||||
@Composable
|
||||
private fun TokenRequiredStep(
|
||||
host: String,
|
||||
error: TokenError?,
|
||||
onSubmit: (String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var token by remember(host) { mutableStateOf("") }
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
shape = RoundedCornerShape(Spacing.md12),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(Spacing.md12), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
Text("需要访问令牌", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = "$host 启用了访问令牌(WEBTERM_TOKEN)。输入令牌后即可继续配对;令牌只用于本次验证,不会显示或记录。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text("访问令牌") },
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = PairingCopy.describeToken(error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
|
||||
Button(
|
||||
onClick = {
|
||||
// Hand it over and forget it in the same breath — never leave the credential in state.
|
||||
val submitted = token
|
||||
token = ""
|
||||
onSubmit(submitted)
|
||||
},
|
||||
enabled = token.isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("提交") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CertRequiredStep(
|
||||
host: String,
|
||||
|
||||
@@ -37,10 +37,12 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.api.models.CommitLogEntry
|
||||
import wang.yaojia.webterm.api.models.GitLogResult
|
||||
import wang.yaojia.webterm.api.models.PrAvailability
|
||||
import wang.yaojia.webterm.api.models.PrStatus
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.models.ProjectSessionRef
|
||||
import wang.yaojia.webterm.api.models.SyncState
|
||||
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.Stroke
|
||||
@@ -48,8 +50,18 @@ import wang.yaojia.webterm.designsystem.WebTermCard
|
||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
import wang.yaojia.webterm.viewmodels.GitChipTone
|
||||
import wang.yaojia.webterm.viewmodels.GitPanelCopy
|
||||
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
|
||||
import wang.yaojia.webterm.viewmodels.SyncBand
|
||||
import wang.yaojia.webterm.viewmodels.boundaryIndex
|
||||
import wang.yaojia.webterm.viewmodels.boundaryLabel
|
||||
import wang.yaojia.webterm.viewmodels.isUnpushed
|
||||
import wang.yaojia.webterm.viewmodels.WorktreeRowState
|
||||
import wang.yaojia.webterm.viewmodels.WorktreeViewModel
|
||||
import wang.yaojia.webterm.viewmodels.headerDirtyChip
|
||||
import wang.yaojia.webterm.viewmodels.syncBandOf
|
||||
import wang.yaojia.webterm.viewmodels.worktreeChips
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
@@ -74,8 +86,15 @@ public fun ProjectDetailScreen(
|
||||
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
||||
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
|
||||
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
|
||||
val fetch by viewModel.fetchPhase.collectAsStateWithLifecycle()
|
||||
val worktreeStates by viewModel.worktreeStates.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(viewModel) { viewModel.load() }
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.load()
|
||||
// w6/G7: probe the other worktrees once per opened project (never per tick) — each row's
|
||||
// failure is isolated, so a bad probe costs one chip, not the panel.
|
||||
viewModel.probeWorktrees()
|
||||
}
|
||||
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -91,7 +110,12 @@ public fun ProjectDetailScreen(
|
||||
detail = current.detail,
|
||||
prChip = prChip,
|
||||
recent = recent,
|
||||
fetch = fetch,
|
||||
canFetch = viewModel.canFetch,
|
||||
onFetch = { scope.launch { viewModel.fetchUpstream() } },
|
||||
onDismissFetchBanner = viewModel::clearFetchBanner,
|
||||
worktree = viewModel.worktree,
|
||||
worktreeStates = worktreeStates,
|
||||
onOpenClaude = onOpenClaude,
|
||||
onViewDiff = onViewDiff,
|
||||
)
|
||||
@@ -120,6 +144,11 @@ private fun DetailBody(
|
||||
worktree: WorktreeViewModel?,
|
||||
onOpenClaude: (String) -> Unit,
|
||||
onViewDiff: (String) -> Unit = {},
|
||||
fetch: ProjectDetailViewModel.FetchPhase = ProjectDetailViewModel.FetchPhase.Idle,
|
||||
canFetch: Boolean = false,
|
||||
onFetch: () -> Unit = {},
|
||||
onDismissFetchBanner: () -> Unit = {},
|
||||
worktreeStates: Map<String, WorktreeRowState> = emptyMap(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -139,19 +168,34 @@ private fun DetailBody(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
detail.branch?.let { Text(text = it, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
if (detail.dirty == true) Text(text = "●", color = WebTermColors.statusWaiting)
|
||||
// w6/G3: a count beats a bare dot — "how many uncommitted" is the question the dot dodged.
|
||||
headerDirtyChip(detail.dirtyCount, detail.dirty)?.let {
|
||||
Text(text = it, style = WebTermType.metaMono, color = WebTermColors.statusWaiting)
|
||||
}
|
||||
}
|
||||
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
SyncBandCard(
|
||||
// On a detached HEAD there is no branch to show, so the short sha stands in for it; the
|
||||
// current worktree row is where the server reports it.
|
||||
band = syncBandOf(detail.sync, head = detail.worktrees.firstOrNull { it.isCurrent }?.head),
|
||||
fetch = fetch,
|
||||
canFetch = canFetch,
|
||||
onFetch = onFetch,
|
||||
onDismissFetchBanner = onDismissFetchBanner,
|
||||
)
|
||||
|
||||
PrChipRow(prChip)
|
||||
|
||||
if (detail.isGit && worktree != null) {
|
||||
WorktreeSection(detail = detail, worktree = worktree)
|
||||
WorktreeSection(detail = detail, worktree = worktree, worktreeStates = worktreeStates)
|
||||
} else {
|
||||
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
||||
SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size))
|
||||
if (!detail.isGit) EmptyLine("不是 git 仓库。")
|
||||
else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
||||
else for (w in detail.worktrees) WorktreeRow(w, onRemove = null)
|
||||
else for (w in detail.worktrees) {
|
||||
WorktreeRow(w, onRemove = null, state = worktreeStateOf(w, detail, worktreeStates))
|
||||
}
|
||||
}
|
||||
|
||||
val running = detail.sessions.filter { !it.exited }
|
||||
@@ -173,6 +217,134 @@ private fun DetailBody(
|
||||
}
|
||||
}
|
||||
|
||||
// ── w6/G3: the sync band ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The header sync band: upstream · ↑ahead · ↓behind · Fetch. Stacked (not a 4-column row) because a
|
||||
* phone is narrow and the mock's desktop row collapses to a column at this width anyway.
|
||||
*
|
||||
* **The one design rule:** only [SyncBand.isAllClear] paints green. A stale `↓0` carries the
|
||||
* "存疑" chip on the NUMBER (↑ never does — it needs only local refs), and a branch with no upstream
|
||||
* says so in words. Absent facts render nothing at all.
|
||||
*/
|
||||
@Composable
|
||||
private fun SyncBandCard(
|
||||
band: SyncBand?,
|
||||
fetch: ProjectDetailViewModel.FetchPhase,
|
||||
canFetch: Boolean,
|
||||
onFetch: () -> Unit,
|
||||
onDismissFetchBanner: () -> Unit,
|
||||
) {
|
||||
if (band == null) return
|
||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
when (band) {
|
||||
is SyncBand.Detached -> SyncCell(GitPanelCopy.HEAD_CAPTION) {
|
||||
Text(
|
||||
text = band.head?.let { "${GitPanelCopy.DETACHED_CHIP} @ $it" } ?: GitPanelCopy.DETACHED_CHIP,
|
||||
style = WebTermType.metaMono,
|
||||
color = WebTermColors.statusStuck,
|
||||
)
|
||||
}
|
||||
|
||||
SyncBand.NoUpstream -> {
|
||||
SyncCell(GitPanelCopy.UPSTREAM_CAPTION) {
|
||||
Text(text = GitPanelCopy.NO_UPSTREAM_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusStuck)
|
||||
}
|
||||
EmptyLine(GitPanelCopy.NO_UPSTREAM_NOTE)
|
||||
}
|
||||
|
||||
is SyncBand.Tracking -> TrackingCells(band)
|
||||
}
|
||||
FetchRow(
|
||||
band = band,
|
||||
fetch = fetch,
|
||||
canFetch = canFetch,
|
||||
onFetch = onFetch,
|
||||
onDismissFetchBanner = onDismissFetchBanner,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackingCells(band: SyncBand.Tracking) {
|
||||
SyncCell(GitPanelCopy.UPSTREAM_CAPTION) {
|
||||
// Upstream is a server string (a remote-tracking ref name) → inert text.
|
||||
Text(text = band.upstream, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
SyncCell(GitPanelCopy.TO_PUSH_CAPTION) {
|
||||
Text(
|
||||
text = GitPanelCopy.aheadChip(band.ahead),
|
||||
style = WebTermType.metaMono,
|
||||
color = if (band.ahead > 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (band.isAllClear) {
|
||||
Text(text = GitPanelCopy.IN_SYNC_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusWorking)
|
||||
}
|
||||
}
|
||||
EmptyLine(if (band.ahead == 0) GitPanelCopy.NOTHING_TO_PUSH_NOTE else GitPanelCopy.aheadNote(band.ahead))
|
||||
SyncCell(GitPanelCopy.TO_PULL_CAPTION) {
|
||||
Text(
|
||||
text = GitPanelCopy.behindChip(band.behind),
|
||||
style = WebTermType.metaMono,
|
||||
// Behind is read off a CACHED remote ref: dim the digit itself when it cannot be trusted.
|
||||
color = if (band.isStale) WebTermColors.statusStuck else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (band.isStale) {
|
||||
Text(text = GitPanelCopy.STALE_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusStuck)
|
||||
}
|
||||
}
|
||||
EmptyLine(
|
||||
when {
|
||||
band.isStale && band.lastFetchMs == null -> GitPanelCopy.NEVER_FETCHED_NOTE
|
||||
band.isStale -> GitPanelCopy.STALE_NOTE
|
||||
band.behind == 0 -> GitPanelCopy.UP_TO_DATE_NOTE
|
||||
else -> GitPanelCopy.WAITING_ON_REMOTE_NOTE
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncCell(caption: String, value: @Composable () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
Text(
|
||||
text = caption,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
value()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FetchRow(
|
||||
band: SyncBand,
|
||||
fetch: ProjectDetailViewModel.FetchPhase,
|
||||
canFetch: Boolean,
|
||||
onFetch: () -> Unit,
|
||||
onDismissFetchBanner: () -> Unit,
|
||||
) {
|
||||
val working = fetch == ProjectDetailViewModel.FetchPhase.Working
|
||||
// Fetch only moves remote-tracking refs — never a pull, never a merge. Disabled on a detached
|
||||
// HEAD (nothing to compare) and while one is in flight (a second tap is dropped, not queued).
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(enabled = canFetch && band.canFetch && !working, onClick = onFetch) {
|
||||
Text(GitPanelCopy.FETCH)
|
||||
}
|
||||
when (fetch) {
|
||||
ProjectDetailViewModel.FetchPhase.Idle -> if (!band.canFetch) {
|
||||
EmptyLine(GitPanelCopy.FETCH_DISABLED_DETACHED)
|
||||
}
|
||||
ProjectDetailViewModel.FetchPhase.Working -> EmptyLine(GitPanelCopy.FETCH_WORKING)
|
||||
is ProjectDetailViewModel.FetchPhase.Done ->
|
||||
BannerLine(GitPanelCopy.FETCH_DONE, WebTermColors.statusWorking, onDismissFetchBanner)
|
||||
is ProjectDetailViewModel.FetchPhase.Failed ->
|
||||
BannerLine(fetch.message, WebTermColors.statusStuck, onDismissFetchBanner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PR + CI chip (link only when https) ──────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
@@ -230,18 +402,30 @@ private fun isHttpsUrl(url: String): Boolean =
|
||||
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
|
||||
private fun WorktreeSection(
|
||||
detail: ProjectDetail,
|
||||
worktree: WorktreeViewModel,
|
||||
worktreeStates: Map<String, WorktreeRowState>,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val phase by worktree.phase.collectAsStateWithLifecycle()
|
||||
var branch by remember { mutableStateOf("") }
|
||||
var base by remember { mutableStateOf("") }
|
||||
var removeTarget by remember { mutableStateOf<WorktreeInfo?>(null) }
|
||||
|
||||
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
||||
// w6/G5: ALWAYS "Worktrees (n)" — one worktree per session makes the count the point, and a
|
||||
// section that renames itself to "Branch" at n=1 hides the whole model.
|
||||
SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size))
|
||||
if (detail.worktrees.isEmpty()) {
|
||||
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
||||
} else {
|
||||
for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w })
|
||||
for (w in detail.worktrees) {
|
||||
WorktreeRow(
|
||||
worktree = w,
|
||||
state = worktreeStateOf(w, detail, worktreeStates),
|
||||
onRemove = { if (!w.isMain) removeTarget = w },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// New-worktree inline form.
|
||||
@@ -337,31 +521,77 @@ private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
|
||||
when (recent) {
|
||||
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
|
||||
ProjectDetailViewModel.RecentCommits.Loading -> {
|
||||
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
|
||||
SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("正在读取提交记录…")
|
||||
}
|
||||
ProjectDetailViewModel.RecentCommits.Unavailable -> {
|
||||
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
|
||||
SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("提交记录不可用。")
|
||||
}
|
||||
is ProjectDetailViewModel.RecentCommits.Loaded -> {
|
||||
SectionTitle("最近提交")
|
||||
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
|
||||
else for (commit in recent.result.commits) CommitRow(commit)
|
||||
is ProjectDetailViewModel.RecentCommits.Loaded -> CommitList(recent.result)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The commit list with w6/G4's unpushed marking: a marked row carries `↑` and the accent, and the
|
||||
* upstream boundary is drawn EXACTLY ONCE, right after the last marked row — it is the real position
|
||||
* of `origin/<branch>` on this timeline, so it is only drawn when there is an upstream to name.
|
||||
*/
|
||||
@Composable
|
||||
private fun CommitList(log: GitLogResult) {
|
||||
val boundaryLabel = log.boundaryLabel()
|
||||
val boundaryIndex = log.boundaryIndex()
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
SectionTitle(GitPanelCopy.RECENT_COMMITS)
|
||||
// The count belongs ON the heading: it qualifies "recent commits", it is not its own statement.
|
||||
if (log.unpushedCount > 0 && boundaryLabel != null) {
|
||||
Text(
|
||||
text = GitPanelCopy.unpushedBadge(log.unpushedCount),
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (log.commits.isEmpty()) {
|
||||
EmptyLine("暂无提交。")
|
||||
return
|
||||
}
|
||||
log.commits.forEachIndexed { index, commit ->
|
||||
CommitRow(commit)
|
||||
if (index == boundaryIndex && boundaryLabel != null) UpstreamBoundary(boundaryLabel)
|
||||
}
|
||||
if (log.truncated) EmptyLine(GitPanelCopy.truncatedNote(log.commits.size))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpstreamBoundary(upstream: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
// Inert: `upstream` is a server-supplied ref name.
|
||||
Text(text = upstream, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
thickness = Stroke.hairline,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitRow(commit: CommitLogEntry) {
|
||||
val unpushed = commit.isUnpushed()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = commit.hash.take(7),
|
||||
text = if (unpushed) GitPanelCopy.UNPUSHED_MARK else " ",
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = commit.hash.take(SHORT_HASH_LENGTH),
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = commit.subject,
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
color = if (unpushed) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -369,23 +599,57 @@ private fun CommitRow(commit: CommitLogEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `git log --format=%h` width — the same 7 the rest of the cockpit shows. */
|
||||
private const val SHORT_HASH_LENGTH = 7
|
||||
|
||||
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One worktree row: identity + state chips on top, path underneath (on one line the path and the
|
||||
* chips competed for width and whichever lost got truncated).
|
||||
*
|
||||
* The chips come from [worktreeChips], which deliberately has no green "clean" chip: only a fresh
|
||||
* `↑0 ↓0` earns green, and "no upstream" — the normal state of a fresh worktree branch — is a warning.
|
||||
*/
|
||||
@Composable
|
||||
private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) {
|
||||
private fun WorktreeRow(
|
||||
worktree: WorktreeInfo,
|
||||
onRemove: (() -> Unit)?,
|
||||
state: WorktreeRowState? = null,
|
||||
) {
|
||||
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
|
||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = label, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
if (worktree.isMain) Tag("main")
|
||||
if (worktree.isCurrent) Tag("current")
|
||||
if (worktree.locked == true) Tag("locked")
|
||||
for (chip in worktreeChips(worktree, state)) Tag(chip.label, chip.tone)
|
||||
if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") }
|
||||
}
|
||||
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (state != null && state.sessionCount > 0) {
|
||||
Text(
|
||||
text = GitPanelCopy.sessionCount(state.sessionCount),
|
||||
style = WebTermType.metaMono,
|
||||
color = WebTermColors.statusWorking,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-row git state. The CURRENT worktree's state is the project's own — free, already loaded. Other
|
||||
* rows need the w6/G7 per-worktree probe (`GET /projects/worktree/state`), which this client does not
|
||||
* have a route for yet, so they stay unprobed and show no sync chip rather than a guess.
|
||||
*/
|
||||
private fun worktreeStateOf(
|
||||
worktree: WorktreeInfo,
|
||||
detail: ProjectDetail,
|
||||
probed: Map<String, WorktreeRowState>,
|
||||
): WorktreeRowState? {
|
||||
if (!worktree.isCurrent) return probed[worktree.path]
|
||||
if (detail.sync == null && detail.dirtyCount == null) return null
|
||||
return WorktreeRowState(sync = detail.sync, dirtyCount = detail.dirtyCount)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
@@ -275,9 +313,19 @@ private fun SwipeToKillRow(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = false,
|
||||
backgroundContent = { KillBackground() },
|
||||
) {
|
||||
PointerContextMenu(
|
||||
mode = layoutMode,
|
||||
actions = sessionRowMenuActions(
|
||||
row = row,
|
||||
onOpen = onOpen,
|
||||
onNewSessionInCwd = onNewSessionInCwd,
|
||||
onKill = onKill,
|
||||
),
|
||||
) {
|
||||
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The red "删除" reveal behind a swiped row (end-aligned — the swipe is right→left). */
|
||||
|
||||
@@ -3,8 +3,9 @@ package wang.yaojia.webterm.screens
|
||||
import android.app.Activity
|
||||
import android.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<Preferences>` 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<Preferences>` singleton the host/last-session stores use (disjoint keys, one file — two
|
||||
* `DataStore` instances over one file would throw).
|
||||
*
|
||||
* `TerminalScreen` is a Composable, so it cannot take the store by constructor injection; the store is
|
||||
* pulled off the Hilt `SingletonComponent` through an `@EntryPoint` instead (the standard Hilt escape
|
||||
* hatch for exactly this case) and kept as a default parameter value so a test/preview can still pass an
|
||||
* in-memory store. Nothing here widens the DI graph — the provider already exists in `di/StorageModule`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberAppQuickReplyStore(): QuickReplyStore {
|
||||
val application = LocalContext.current.applicationContext
|
||||
return remember(application) {
|
||||
DataStoreQuickReplyStore(
|
||||
EntryPointAccessors
|
||||
.fromApplication(application, QuickReplyStoreEntryPoint::class.java)
|
||||
.preferencesDataStore(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the app-scoped Preferences `DataStore` provider for [rememberAppQuickReplyStore]. */
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface QuickReplyStoreEntryPoint {
|
||||
public fun preferencesDataStore(): DataStore<Preferences>
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
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@launch
|
||||
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 -> _uiState.value = PairingUiState.Failed(
|
||||
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a
|
||||
* `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token
|
||||
* gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim.
|
||||
*/
|
||||
private suspend fun onProbeFailure(candidate: Candidate, error: PairingError) {
|
||||
if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) {
|
||||
promptForToken(candidate, error = null)
|
||||
return
|
||||
}
|
||||
_uiState.value = PairingUiState.Failed(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = tier,
|
||||
error = result.error,
|
||||
message = PairingCopy.describe(result.error, tier),
|
||||
tier = candidate.tier,
|
||||
error = error,
|
||||
message = PairingCopy.describe(error, candidate.tier),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun promptForToken(candidate: Candidate, error: TokenError?) {
|
||||
_uiState.value = PairingUiState.TokenRequired(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = candidate.tier,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onProbeSuccess(candidate: Candidate) {
|
||||
@@ -181,20 +252,80 @@ public class PairingViewModel(
|
||||
HostClassifier.hostOf(endpoint).ifEmpty { endpoint.baseUrl }
|
||||
}
|
||||
|
||||
/** The two-step pairing probe seam ([wang.yaojia.webterm.api.pairing.runPairingProbe]); faked in tests. */
|
||||
public fun interface PairingProber {
|
||||
/**
|
||||
* The pairing NETWORK seam: the two-step probe ([wang.yaojia.webterm.api.pairing.runPairingProbe]) plus
|
||||
* the `WEBTERM_TOKEN` gate's two operations. Faked in tests; production is built by
|
||||
* `AppEnvironment.buildPairingProber()` over the ONE shared `OkHttpClient` (so the token cookie the
|
||||
* submit obtains is the same jar every later request and the WS upgrade read from).
|
||||
*
|
||||
* The two token operations are DEFAULTED so a probe-only double still satisfies the interface. Their
|
||||
* defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and
|
||||
* [TokenSubmission.Unavailable] — never a silent success.
|
||||
*/
|
||||
public interface PairingProber {
|
||||
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
|
||||
|
||||
/**
|
||||
* Does [endpoint] answer an unauthenticated read with the server's `401 authentication required`
|
||||
* (`src/server.ts` `authGate`)? Only consulted to disambiguate
|
||||
* [PairingError.HttpOkButNotWebTerminal].
|
||||
*
|
||||
* Implementations MUST NOT throw for a network failure — an unreachable host is `false` ("not known
|
||||
* to be gated"), so the app never prompts for a credential because a host is merely down.
|
||||
*/
|
||||
public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = false
|
||||
|
||||
/**
|
||||
* `POST /auth` with [token]. On [TokenSubmission.Accepted] the shared cookie jar holds the host's
|
||||
* `webterm_auth` cookie, so the very next probe/attach is authenticated.
|
||||
*
|
||||
* [token] is a SHELL CREDENTIAL: implementations must put it in the request BODY only — never a URL,
|
||||
* never a log, never an exception message — and must not retain it.
|
||||
*/
|
||||
public suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission =
|
||||
TokenSubmission.Unavailable
|
||||
}
|
||||
|
||||
/**
|
||||
* Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared
|
||||
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
|
||||
* layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel —
|
||||
* `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink
|
||||
* I/O on a UI thread.
|
||||
* Outcome of `POST /auth` (server `src/http/auth.ts` + the route in `src/server.ts`). A wrong token
|
||||
* ([Rejected]) and a host that did not answer ([Unreachable]) are DISTINCT cases on purpose — collapsing
|
||||
* them would tell the user to re-type a token that was fine.
|
||||
*/
|
||||
public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) }
|
||||
public sealed interface TokenSubmission {
|
||||
/** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */
|
||||
public data object Accepted : TokenSubmission
|
||||
|
||||
/** `401` — the token does not match the host's `WEBTERM_TOKEN`. */
|
||||
public data object Rejected : TokenSubmission
|
||||
|
||||
/** `429` — over the host's 10-per-minute auth limit. Surfaced as-is; NEVER auto-retried. */
|
||||
public data object RateLimited : TokenSubmission
|
||||
|
||||
/**
|
||||
* The submit never got an answer it could interpret: a transport failure or an unexpected status.
|
||||
* [reason] is a short, credential-free diagnostic (an error TYPE or `"HTTP <status>"`) — it is
|
||||
* shown to nobody and logged nowhere; the UI copy is app-authored.
|
||||
*/
|
||||
public data class Unreachable(val reason: String) : TokenSubmission
|
||||
|
||||
/** This prober has no way to submit a token (a probe-only seam). Reported, never silently ignored. */
|
||||
public data object Unavailable : TokenSubmission
|
||||
}
|
||||
|
||||
/** Why the access-token prompt is showing an error. Maps to inert, app-authored copy in [PairingCopy]. */
|
||||
public enum class TokenError {
|
||||
/** The submitted token was rejected by the host (`401`). */
|
||||
INVALID,
|
||||
|
||||
/** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */
|
||||
RATE_LIMITED,
|
||||
|
||||
/** The host did not answer the submit (or answered unintelligibly). */
|
||||
UNREACHABLE,
|
||||
|
||||
/** The app cannot submit a token at all on this seam. */
|
||||
UNAVAILABLE,
|
||||
}
|
||||
|
||||
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -276,6 +407,27 @@ public sealed interface PairingUiState {
|
||||
*/
|
||||
public data class CertRequired(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState
|
||||
|
||||
/**
|
||||
* The host is behind the optional `WEBTERM_TOKEN` gate and needs its shared access token before
|
||||
* pairing can continue. [error] is null on the first prompt and set after a failed submit.
|
||||
*
|
||||
* This state deliberately carries NO token field — the credential is a
|
||||
* [PairingViewModel.submitAccessToken] parameter and must not survive the call (plan §8).
|
||||
*/
|
||||
public data class TokenRequired(
|
||||
val endpoint: HostEndpoint,
|
||||
val name: String,
|
||||
val tier: WarningTier,
|
||||
val error: TokenError? = null,
|
||||
) : PairingUiState
|
||||
|
||||
/** A submitted access token is in flight (`POST /auth`). Carries no token, for the same reason. */
|
||||
public data class TokenSubmitting(
|
||||
val endpoint: HostEndpoint,
|
||||
val name: String,
|
||||
val tier: WarningTier,
|
||||
) : PairingUiState
|
||||
|
||||
/** The probe failed; [message] is inert, app-authored copy for [error]. Retryable. */
|
||||
public data class Failed(
|
||||
val endpoint: HostEndpoint,
|
||||
@@ -302,6 +454,8 @@ private fun PairingUiState.candidate(): Candidate? = when (this) {
|
||||
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.CertRequired -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.TokenRequired -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.TokenSubmitting -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.Failed -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.Entry, is PairingUiState.Paired -> null
|
||||
}
|
||||
@@ -315,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.
|
||||
// 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 ->
|
||||
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import wang.yaojia.webterm.api.models.CommitLogEntry
|
||||
import wang.yaojia.webterm.api.models.FetchResult
|
||||
import wang.yaojia.webterm.api.models.GitLogResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PrStatus
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.models.SyncState
|
||||
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||
import wang.yaojia.webterm.api.models.WorktreeState
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
|
||||
/**
|
||||
@@ -30,6 +36,16 @@ public class ProjectDetailViewModel(
|
||||
private val fetchLog: (suspend () -> GitLogResult)? = null,
|
||||
/** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */
|
||||
public val worktree: WorktreeViewModel? = null,
|
||||
/**
|
||||
* w6/G2 — `POST /projects/git/fetch` for THIS repo. `null` ⇒ the band hides the Fetch action
|
||||
* rather than offering a button that cannot work.
|
||||
*/
|
||||
private val fetchRemote: (suspend () -> GitWriteOutcome<FetchResult>)? = null,
|
||||
/**
|
||||
* w6/G7 — `GET /projects/worktree/state?path=` for ONE worktree. `null` ⇒ rows stay unprobed and
|
||||
* show no sync chip, which is the honest rendering of "not known".
|
||||
*/
|
||||
private val fetchWorktreeState: (suspend (String) -> WorktreeState)? = null,
|
||||
) {
|
||||
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
|
||||
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
|
||||
@@ -57,9 +73,23 @@ public class ProjectDetailViewModel(
|
||||
public data object Unavailable : RecentCommits
|
||||
}
|
||||
|
||||
/** w6/G2 — the Fetch button's own state. A failure NEVER touches [phase] (failure isolation). */
|
||||
public sealed interface FetchPhase {
|
||||
public data object Idle : FetchPhase
|
||||
public data object Working : FetchPhase
|
||||
|
||||
/** Succeeded; [lastFetchMs] is the server's post-fetch `FETCH_HEAD` mtime (may be null). */
|
||||
public data class Done(val lastFetchMs: Long?) : FetchPhase
|
||||
|
||||
/** Failed (offline / auth / ≥2 remotes). `lastFetchMs` is left untouched so `stale` stays on. */
|
||||
public data class Failed(val message: String) : FetchPhase
|
||||
}
|
||||
|
||||
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
|
||||
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
|
||||
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.Hidden)
|
||||
private val _fetchPhase = MutableStateFlow<FetchPhase>(FetchPhase.Idle)
|
||||
private val _worktreeStates = MutableStateFlow<Map<String, WorktreeRowState>>(emptyMap())
|
||||
|
||||
/** The main detail snapshot `ProjectDetailScreen` renders from. */
|
||||
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||
@@ -70,6 +100,15 @@ public class ProjectDetailViewModel(
|
||||
/** The recent-commits snapshot. */
|
||||
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
|
||||
|
||||
/** The Fetch action's snapshot (spinner / result banner). */
|
||||
public val fetchPhase: StateFlow<FetchPhase> = _fetchPhase.asStateFlow()
|
||||
|
||||
/** Per-worktree git state, keyed by worktree path. Absent ⇒ unprobed (renders no sync chip). */
|
||||
public val worktreeStates: StateFlow<Map<String, WorktreeRowState>> = _worktreeStates.asStateFlow()
|
||||
|
||||
/** Whether a Fetch seam is wired at all; a detached HEAD is refused by [SyncBand.canFetch]. */
|
||||
public val canFetch: Boolean get() = fetchRemote != null
|
||||
|
||||
/**
|
||||
* Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful
|
||||
* detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail
|
||||
@@ -93,6 +132,72 @@ public class ProjectDetailViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* w6/G2 — refresh the remote-tracking refs for this repo (`git fetch --no-tags`: no pull, no
|
||||
* merge, no working-tree change). Rules, all pinned by test:
|
||||
* - **one at a time** — a tap while [FetchPhase.Working] is DROPPED, never queued;
|
||||
* - **success re-loads the detail**, because `behind`/`lastFetchMs` only move server-side;
|
||||
* - **failure changes nothing** — no re-load, no invented `lastFetchMs`, so the band's `stale`
|
||||
* flag stays on. Marking stale data fresh is the one lie this panel exists to prevent.
|
||||
*/
|
||||
public suspend fun fetchUpstream() {
|
||||
val fetcher = fetchRemote ?: return
|
||||
if (_fetchPhase.value == FetchPhase.Working) return
|
||||
_fetchPhase.value = FetchPhase.Working
|
||||
val outcome = try {
|
||||
fetcher()
|
||||
} catch (cancel: CancellationException) {
|
||||
_fetchPhase.value = FetchPhase.Idle
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
_fetchPhase.value = FetchPhase.Failed(GitPanelCopy.FETCH_FAILED)
|
||||
return
|
||||
}
|
||||
when (outcome) {
|
||||
is GitWriteOutcome.Ok -> {
|
||||
_fetchPhase.value = FetchPhase.Done(outcome.payload.lastFetchMs)
|
||||
load() // the numbers this button exists for live server-side — re-read them.
|
||||
}
|
||||
// The server classifies and sanitizes its own failures (SEC-M10), so its message is shown
|
||||
// verbatim; a missing one falls back to local copy. Nothing else changes: `lastFetchMs`
|
||||
// stays where it was, so the band keeps reading "stale" instead of pretending it refreshed.
|
||||
is GitWriteOutcome.Rejected ->
|
||||
_fetchPhase.value = FetchPhase.Failed(outcome.message ?: GitPanelCopy.FETCH_FAILED)
|
||||
GitWriteOutcome.RateLimited ->
|
||||
_fetchPhase.value = FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* w6/G7 — probe the git state of every worktree OTHER than the current one (the current row reads
|
||||
* the project's own `sync`/`dirtyCount`, already loaded and free).
|
||||
*
|
||||
* Each probe is isolated to its own row: a failure leaves that row unprobed (no chip) and never
|
||||
* touches [phase] or another row. Probed once per opened project, never per refresh tick.
|
||||
*/
|
||||
public suspend fun probeWorktrees() {
|
||||
val probe = fetchWorktreeState ?: return
|
||||
val detail = (_phase.value as? Phase.Loaded)?.detail ?: return
|
||||
for (worktree in detail.worktrees) {
|
||||
if (worktree.isCurrent) continue
|
||||
val state = try {
|
||||
probe(worktree.path)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
continue // isolated: this row stays unprobed, the panel still renders
|
||||
}
|
||||
_worktreeStates.value = _worktreeStates.value + (
|
||||
worktree.path to WorktreeRowState(sync = state.sync, dirtyCount = state.dirtyCount)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the fetch result banner. */
|
||||
public fun clearFetchBanner() {
|
||||
_fetchPhase.value = FetchPhase.Idle
|
||||
}
|
||||
|
||||
private suspend fun loadPr() {
|
||||
val fetcher = fetchPr ?: return
|
||||
_prChip.value = PrChip.Loading
|
||||
@@ -137,9 +242,218 @@ public class ProjectDetailViewModel(
|
||||
fetchPr = { gateway.projectPr(path) },
|
||||
fetchLog = { gateway.projectLog(path, null) },
|
||||
worktree = worktree,
|
||||
fetchRemote = { gateway.gitFetch(path) },
|
||||
fetchWorktreeState = { gateway.worktreeState(it) },
|
||||
)
|
||||
self = vm
|
||||
return vm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// w6 — the git panel's pure presentation core (port of public/projects.ts `makeSyncBand` /
|
||||
// `makeWorktreeRow` + public/git-log.ts `renderGitLog`; design: docs/mockups/project-detail-git.html).
|
||||
// Pure and JVM-tested; the Compose layer only paints what these return.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** [GitLogResult.boundaryIndex] when there is no pushed/unpushed boundary to draw. */
|
||||
public const val NO_COMMIT_BOUNDARY: Int = -1
|
||||
|
||||
/**
|
||||
* The header sync band, as one of three mutually exclusive shapes.
|
||||
*
|
||||
* **The rule the type exists to enforce: exactly ONE shape may ever answer [isAllClear] true** —
|
||||
* [Tracking] with `ahead == 0 && behind == 0` and a fetch inside [SyncState.FETCH_FRESH_WINDOW_MS].
|
||||
* Green here means
|
||||
* "I checked, ignore this", so [Detached] and [NoUpstream] cannot reach it by construction: absent
|
||||
* numbers are not zeros.
|
||||
*/
|
||||
public sealed interface SyncBand {
|
||||
/** True for the single green "all clear" state and nothing else. */
|
||||
public val isAllClear: Boolean
|
||||
|
||||
/** `git fetch` is meaningless without a branch, so a detached HEAD disables the button. */
|
||||
public val canFetch: Boolean get() = this !is Detached
|
||||
|
||||
/** HEAD is detached: show the short sha, no branch, no ↑↓. */
|
||||
public data class Detached(val head: String?) : SyncBand {
|
||||
override val isAllClear: Boolean get() = false
|
||||
}
|
||||
|
||||
/** The branch tracks nothing — stated explicitly, in words as well as colour. */
|
||||
public data object NoUpstream : SyncBand {
|
||||
override val isAllClear: Boolean get() = false
|
||||
}
|
||||
|
||||
/** Tracking [upstream]: ↑[ahead] is fact, ↓[behind] is only as fresh as [lastFetchMs]. */
|
||||
public data class Tracking(
|
||||
val upstream: String,
|
||||
val ahead: Int,
|
||||
val behind: Int,
|
||||
/** `FETCH_HEAD` older than [SyncState.FETCH_FRESH_WINDOW_MS] (or never) ⇒ [behind] is a guess. */
|
||||
val isStale: Boolean,
|
||||
val lastFetchMs: Long?,
|
||||
) : SyncBand {
|
||||
override val isAllClear: Boolean get() = ahead == 0 && behind == 0 && !isStale
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the header band. `null` facts (a non-git project) ⇒ `null` — say nothing rather than guess.
|
||||
* [head] is the short HEAD sha shown on a detached HEAD.
|
||||
*/
|
||||
public fun syncBandOf(
|
||||
sync: SyncState?,
|
||||
head: String? = null,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): SyncBand? {
|
||||
if (sync == null) return null
|
||||
if (sync.detached) return SyncBand.Detached(head)
|
||||
// A blank upstream is treated as none: the server should never send one, and "" would otherwise
|
||||
// label the commit boundary with an empty string and unlock the green path.
|
||||
val upstream = sync.upstream?.trim()?.takeIf { it.isNotEmpty() } ?: return SyncBand.NoUpstream
|
||||
return SyncBand.Tracking(
|
||||
upstream = upstream,
|
||||
ahead = sync.ahead ?: 0,
|
||||
behind = sync.behind ?: 0,
|
||||
isStale = !sync.isFetchFresh(nowMs),
|
||||
lastFetchMs = sync.lastFetchMs,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's uncommitted-changes chip, or null when clean/unknown. A count is strictly better than
|
||||
* a bare dot (`● 3 未提交` answers "how much?"), so it wins whenever the server sent one; `dirty`
|
||||
* alone falls back to the countless chip (mirror of public/projects.ts:1254-1258).
|
||||
*/
|
||||
public fun headerDirtyChip(dirtyCount: Int?, dirty: Boolean?): String? = when {
|
||||
dirtyCount != null && dirtyCount > 0 -> GitPanelCopy.dirtyChip(dirtyCount)
|
||||
dirtyCount == null && dirty == true -> GitPanelCopy.DIRTY_NO_COUNT
|
||||
else -> null
|
||||
}
|
||||
|
||||
// ── Commit list: unpushed marking + ONE upstream boundary ────────────────────────────
|
||||
|
||||
/**
|
||||
* The label for the pushed/unpushed boundary, or null when NO boundary may be drawn.
|
||||
*
|
||||
* A blank upstream counts as absent: an empty label would be a boundary claiming a position on the
|
||||
* timeline without naming what it is the position OF.
|
||||
*/
|
||||
public fun GitLogResult.boundaryLabel(): String? = upstream?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* The row index the boundary is drawn AFTER — exactly once — or [NO_COMMIT_BOUNDARY].
|
||||
*
|
||||
* It is the LAST marked row, not "the first N rows": `git log` is date-ordered and a merge of an older
|
||||
* branch interleaves unpushed commits BELOW pushed ones, so a row-count rule fails in the dangerous
|
||||
* direction (calling an unpushed commit pushed). Marking itself is a SERVER decision
|
||||
* ([GitLogResult.upstreamBoundaryIndex] reads `unpushed`, honoured only when strictly `true`) because
|
||||
* `git log`'s `%h` is abbreviated while `rev-list` yields full SHAs.
|
||||
*/
|
||||
public fun GitLogResult.boundaryIndex(): Int =
|
||||
if (boundaryLabel() == null) NO_COMMIT_BOUNDARY else upstreamBoundaryIndex ?: NO_COMMIT_BOUNDARY
|
||||
|
||||
/** Whether this row draws the "not pushed yet" rail: only an explicit `true` counts. */
|
||||
public fun CommitLogEntry.isUnpushed(): Boolean = unpushed == true
|
||||
|
||||
// ── Worktree rows ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Chip colour role. [OK] is the green all-clear and is reserved for the header band's in-sync chip. */
|
||||
public enum class GitChipTone { NEUTRAL, ACCENT, WARN, DIRTY, OK }
|
||||
|
||||
/** One inert chip on a worktree row. */
|
||||
public data class WorktreeChip(val label: String, val tone: GitChipTone)
|
||||
|
||||
/**
|
||||
* Per-row git state (w6/G7 — probed per worktree). Absent means UNPROBED, and an unprobed row shows
|
||||
* no chip at all rather than a guess.
|
||||
*/
|
||||
public data class WorktreeRowState(
|
||||
val sync: SyncState? = null,
|
||||
val dirtyCount: Int? = null,
|
||||
/** Live sessions whose cwd sits inside this worktree. */
|
||||
val sessionCount: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* The chips for one worktree row, in the shipped order: main · current · locked · prunable · sync ·
|
||||
* dirty (mirror of public/projects.ts `makeWorktreeRow`).
|
||||
*
|
||||
* Deliberately NO green chip here: a worktree row cannot earn the all-clear, because the only state
|
||||
* that may read green needs a fresh fetch, and "no upstream" — the normal state of a fresh worktree
|
||||
* branch — must read as a warning instead.
|
||||
*/
|
||||
public fun worktreeChips(worktree: WorktreeInfo, state: WorktreeRowState? = null): List<WorktreeChip> {
|
||||
val chips = ArrayList<WorktreeChip>(6)
|
||||
if (worktree.isMain) chips += WorktreeChip(GitPanelCopy.MAIN_CHIP, GitChipTone.NEUTRAL)
|
||||
if (worktree.isCurrent) chips += WorktreeChip(GitPanelCopy.CURRENT_CHIP, GitChipTone.ACCENT)
|
||||
if (worktree.locked == true) chips += WorktreeChip(GitPanelCopy.LOCKED_CHIP, GitChipTone.NEUTRAL)
|
||||
if (worktree.prunable == true) chips += WorktreeChip(GitPanelCopy.PRUNABLE_CHIP, GitChipTone.WARN)
|
||||
val sync = state?.sync
|
||||
if (sync != null) {
|
||||
val ahead = sync.ahead ?: 0
|
||||
when {
|
||||
sync.detached -> chips += WorktreeChip(GitPanelCopy.DETACHED_CHIP_SHORT, GitChipTone.WARN)
|
||||
sync.upstream.isNullOrBlank() ->
|
||||
chips += WorktreeChip(GitPanelCopy.NO_UPSTREAM_CHIP, GitChipTone.WARN)
|
||||
ahead > 0 -> chips += WorktreeChip(GitPanelCopy.aheadChip(ahead), GitChipTone.ACCENT)
|
||||
}
|
||||
}
|
||||
val dirtyCount = state?.dirtyCount ?: 0
|
||||
if (dirtyCount > 0) chips += WorktreeChip(GitPanelCopy.dirtyCountChip(dirtyCount), GitChipTone.DIRTY)
|
||||
return chips
|
||||
}
|
||||
|
||||
/** User-visible copy for the git panel. Local UI text — no wire contract depends on these strings. */
|
||||
public object GitPanelCopy {
|
||||
// Sync band
|
||||
public const val UPSTREAM_CAPTION: String = "Upstream"
|
||||
public const val TO_PUSH_CAPTION: String = "待推送"
|
||||
public const val TO_PULL_CAPTION: String = "待拉取"
|
||||
public const val HEAD_CAPTION: String = "HEAD"
|
||||
public const val NO_UPSTREAM_CHIP: String = "无 upstream"
|
||||
public const val NO_UPSTREAM_NOTE: String = "该分支没有跟踪上游 —— 没有可报告的 ↑↓"
|
||||
public const val DETACHED_CHIP: String = "detached HEAD"
|
||||
public const val DETACHED_CHIP_SHORT: String = "detached"
|
||||
public const val IN_SYNC_CHIP: String = "✓ 已同步"
|
||||
public const val STALE_CHIP: String = "存疑"
|
||||
public const val NEVER_FETCHED_NOTE: String = "从未 fetch —— 这个数字不可信"
|
||||
public const val STALE_NOTE: String = "上次 fetch 已超过一小时 —— 这个数字不可信"
|
||||
public const val UP_TO_DATE_NOTE: String = "与远端一致"
|
||||
public const val WAITING_ON_REMOTE_NOTE: String = "远端有新提交"
|
||||
public const val NOTHING_TO_PUSH_NOTE: String = "没有待推送的提交"
|
||||
public const val DIRTY_NO_COUNT: String = "● 未提交"
|
||||
|
||||
// Fetch
|
||||
public const val FETCH: String = "Fetch"
|
||||
public const val FETCH_WORKING: String = "正在 fetch…"
|
||||
public const val FETCH_DONE: String = "已刷新远端引用(未 pull、未 merge)"
|
||||
public const val FETCH_FAILED: String = "fetch 失败,远端状态仍是上次的数据。"
|
||||
public const val FETCH_RATE_LIMITED: String = "fetch 太频繁,请稍后再试。"
|
||||
public const val FETCH_DISABLED_DETACHED: String = "HEAD 游离,无法 fetch"
|
||||
|
||||
// Commit list
|
||||
public const val RECENT_COMMITS: String = "最近提交"
|
||||
public const val UNPUSHED_MARK: String = "↑"
|
||||
|
||||
// Worktrees
|
||||
public const val MAIN_CHIP: String = "main"
|
||||
public const val CURRENT_CHIP: String = "当前"
|
||||
public const val LOCKED_CHIP: String = "locked"
|
||||
public const val PRUNABLE_CHIP: String = "prunable"
|
||||
|
||||
/** ALWAYS "Worktrees (n)" — one worktree per session means the count is the point, and a section
|
||||
* that renames itself at n=1 hides the whole model (w6/G5). */
|
||||
public fun worktreesTitle(count: Int): String = "Worktrees ($count)"
|
||||
|
||||
public fun dirtyChip(count: Int): String = "● $count 未提交"
|
||||
public fun dirtyCountChip(count: Int): String = "● $count"
|
||||
public fun aheadChip(count: Int): String = "↑ $count"
|
||||
public fun behindChip(count: Int): String = "↓ $count"
|
||||
public fun unpushedBadge(count: Int): String = "↑ $count 未推送"
|
||||
public fun aheadNote(count: Int): String = "$count 个提交只在本机"
|
||||
public fun sessionCount(count: Int): String = "● $count session"
|
||||
public fun truncatedNote(count: Int): String = "只显示最近 $count 条提交。"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.FetchResult
|
||||
import wang.yaojia.webterm.api.models.GitLogResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PrStatus
|
||||
@@ -14,6 +15,7 @@ import wang.yaojia.webterm.api.models.ProjectInfo
|
||||
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.api.models.WorktreeState
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
import wang.yaojia.webterm.wire.Validation
|
||||
@@ -418,6 +420,18 @@ public interface ProjectsGateway {
|
||||
public suspend fun projectPr(path: String): PrStatus
|
||||
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
|
||||
|
||||
/**
|
||||
* w6/G7 — one worktree's git state. Its own call (not part of `projectDetail`) so N rows do not
|
||||
* each pay for a worktree listing and a CLAUDE.md read.
|
||||
*/
|
||||
public suspend fun worktreeState(worktreePath: String): WorktreeState
|
||||
|
||||
/**
|
||||
* w6/G2 — refresh remote-tracking refs (no pull, no merge). GUARDED like the other git writes:
|
||||
* the server derives the remote itself and never accepts one from the client.
|
||||
*/
|
||||
public suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult>
|
||||
|
||||
// ── W5: guarded worktree write ─────────────────────────────────────────────────────────
|
||||
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
|
||||
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
|
||||
@@ -432,6 +446,8 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate
|
||||
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
|
||||
override suspend fun projectPr(path: String): PrStatus = api.projectPr(path)
|
||||
override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n)
|
||||
override suspend fun worktreeState(worktreePath: String): WorktreeState = api.worktreeState(worktreePath)
|
||||
override suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> = api.gitFetch(path)
|
||||
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
|
||||
api.createWorktree(path, branch, base)
|
||||
|
||||
|
||||
@@ -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<IdentityRepository>,
|
||||
private val httpTransportLazy: Lazy<HttpTransport>,
|
||||
private val termTransportLazy: Lazy<TermTransport>,
|
||||
/** The SAME jar instance installed on the one shared `OkHttpClient` (`di/AuthCookieModule`). */
|
||||
private val authCookieJarLazy: Lazy<AuthCookieJar>,
|
||||
/** Durable, Tink-AEAD-encrypted home of the `webterm_auth` cookie (`di/AuthCookieModule`). */
|
||||
private val authCookieStoreLazy: Lazy<AuthCookieStore>,
|
||||
/**
|
||||
* The off-screen preview painter (§6.7). `Lazy` because constructing it measures a `Paint` — an
|
||||
* `android.graphics` touch that must not happen while the Hilt graph is being built (it is stubbed
|
||||
* under JVM unit tests).
|
||||
*/
|
||||
private val thumbnailRasterizerLazy: Lazy<ThumbnailRasterizer>,
|
||||
) {
|
||||
/** 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,
|
||||
)
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ThumbnailKey, Deferred<Bitmap>>()
|
||||
private val inFlight = HashMap<ThumbnailKey, Deferred<Bitmap?>>()
|
||||
private val inFlightMutex = Mutex()
|
||||
|
||||
/**
|
||||
* The cached (or freshly rendered) thumbnail for [session]. Returns the cached bitmap immediately when
|
||||
* `(id, lastOutputAt)` is unchanged; otherwise fetches the preview over mTLS, rasterises off-screen,
|
||||
* caches, and returns it. Never throws for a fetch/render failure — a placeholder is cached and
|
||||
* returned instead (only cooperative cancellation of [scope] propagates).
|
||||
* The 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<LiveSessionInfo>) {
|
||||
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<Bitmap> = scope.async {
|
||||
val bitmap = renderGuarded(key.sessionId)
|
||||
// Publish under the lock so a concurrent [thumbnail] re-check sees the result the instant this
|
||||
// key stops being in-flight — success AND placeholder are cached identically (§6.7 no-retry-storm).
|
||||
private fun startRender(key: ThumbnailKey): Deferred<Bitmap?> = 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 {
|
||||
cache.put(key, bitmap)
|
||||
// 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<ThumbnailKey>)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<ThumbnailKey>) {
|
||||
for (key in lru.snapshot().keys) {
|
||||
if (key !in keys) lru.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production [PreviewSource] over an [ApiClient] (which sends through the shared mTLS `HttpTransport`, so
|
||||
* tunnel-host previews traverse nginx). Re-caps the body at 256 KiB client-side — defence-in-depth over
|
||||
* the server's own cap (plan §6.7 / §4.2).
|
||||
* tunnel-host previews traverse nginx). `GET /live-sessions/:id/preview` is a READ-ONLY route: it does NOT
|
||||
* attach, register a client, or touch the session's idle clock. 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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||
import wang.yaojia.webterm.components.sessionRowMenuActions
|
||||
import wang.yaojia.webterm.designsystem.DisplayStatus
|
||||
import wang.yaojia.webterm.viewmodels.SessionRow
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The pointer-context-menu ACTION SET for a session row (A26 — the large-screen right-click menu).
|
||||
*
|
||||
* The menu's *visibility* gate is [PointerMenuPolicy] (already covered by `LayoutPolicyTest`); this covers
|
||||
* the other half — which entries a row offers and what each one does. Pure JVM: the entries are built by
|
||||
* [sessionRowMenuActions], so the wiring is verified without a device (the pointer gesture itself is
|
||||
* device-QA, plan §7).
|
||||
*
|
||||
* NOTE the "copy" entry iOS has is deliberately ABSENT: copy-out belongs to the terminal's own text
|
||||
* selection `ActionMode`, and a list row has no selected text — so the menu must not advertise it.
|
||||
*
|
||||
* (This file sits in the `nav` test package because it covers the row-menu half of the adaptive shell
|
||||
* wired here; the function itself lives beside [wang.yaojia.webterm.components.PointerContextMenu] and
|
||||
* [wang.yaojia.webterm.components.ContextMenuAction], which is its cohesive home.)
|
||||
*/
|
||||
class SessionRowMenuTest {
|
||||
|
||||
private fun row(cwd: String?): SessionRow = SessionRow(
|
||||
info = LiveSessionInfo(
|
||||
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
|
||||
createdAt = 0L,
|
||||
clientCount = 1,
|
||||
exited = false,
|
||||
cwd = cwd,
|
||||
cols = 80,
|
||||
rows = 24,
|
||||
lastOutputAt = 1L,
|
||||
),
|
||||
displayStatus = DisplayStatus.Working,
|
||||
title = "web-terminal",
|
||||
isUnread = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a row with a cwd offers open, new-session-in-cwd and kill — and never copy`() {
|
||||
val actions = sessionRowMenuActions(row("/srv/app"), onOpen = {}, onNewSessionInCwd = {}, onKill = {})
|
||||
|
||||
assertEquals(3, actions.size)
|
||||
assertTrue(actions[0].label.contains("打开"), "the first entry opens the session: ${actions[0].label}")
|
||||
assertTrue(actions[1].label.contains("开新会话"), "cwd spawn entry missing: ${actions[1].label}")
|
||||
assertTrue(actions[2].label.contains("终止"), "kill entry missing: ${actions[2].label}")
|
||||
assertFalse(actions.any { it.label.contains("复制") }, "copy is a documented gap — must not appear")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row without a usable cwd drops the new-session entry`() {
|
||||
assertEquals(2, sessionRowMenuActions(row(null), onOpen = {}, onNewSessionInCwd = {}, onKill = {}).size)
|
||||
assertEquals(2, sessionRowMenuActions(row(" "), onOpen = {}, onNewSessionInCwd = {}, onKill = {}).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no spawn handler wired means no new-session entry, even with a cwd`() {
|
||||
val actions = sessionRowMenuActions(row("/srv/app"), onOpen = {}, onNewSessionInCwd = null, onKill = {})
|
||||
assertEquals(2, actions.size)
|
||||
assertFalse(actions.any { it.label.contains("开新会话") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each entry invokes its callback with the row's own id and cwd`() {
|
||||
var opened: UUID? = null
|
||||
var spawnedIn: String? = null
|
||||
var killed: UUID? = null
|
||||
val target = row("/home/dev/project")
|
||||
|
||||
val actions = sessionRowMenuActions(
|
||||
row = target,
|
||||
onOpen = { opened = it },
|
||||
onNewSessionInCwd = { spawnedIn = it },
|
||||
onKill = { killed = it },
|
||||
)
|
||||
actions.forEach { it.onClick() }
|
||||
|
||||
assertEquals(target.id, opened)
|
||||
assertEquals("/home/dev/project", spawnedIn)
|
||||
assertEquals(target.id, killed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.components.BannerModel
|
||||
import wang.yaojia.webterm.session.FailureReason
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* The §6.4 latest-writer-wins reclaim policy — the pure half of the device-switch resize path
|
||||
* (plan §6.4 / A21). The Compose/lifecycle plumbing that feeds it is device-QA (§7); WHICH events
|
||||
* must reach `SessionEngine.notifyForegrounded` is decided here, in JVM-testable data.
|
||||
*
|
||||
* The rule that makes this non-obvious: `:terminal-view`'s `TerminalResizeDriver` now owns pushing
|
||||
* the measured grid on a layout change (and dedups sub-cell churn), so an extra engine-level
|
||||
* re-assert on the SAME size-changed event would double every rotation's `resize` frame. The engine
|
||||
* call therefore belongs to the events the view layer cannot see — becoming the active device again
|
||||
* (resume / window-focus gain) — plus the disconnected case, where it is the connect-now nudge.
|
||||
*/
|
||||
class TerminalReclaimPolicyTest {
|
||||
|
||||
// ── Lifecycle: only ON_RESUME means "this device is active again" ────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `ON_RESUME reclaims the shared PTY size`() {
|
||||
assertTrue(TerminalReclaimPolicy.reclaimsOnLifecycle(Lifecycle.Event.ON_RESUME))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no other lifecycle event reclaims`() {
|
||||
Lifecycle.Event.values()
|
||||
.filter { it != Lifecycle.Event.ON_RESUME }
|
||||
.forEach { event ->
|
||||
assertFalse(TerminalReclaimPolicy.reclaimsOnLifecycle(event), "$event must not reclaim")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Window focus: gain only (a blur must never resize — plan §6.4 removed the size-vote) ────
|
||||
|
||||
@Test
|
||||
fun `window-focus GAIN reclaims and a blur does not`() {
|
||||
assertTrue(TerminalReclaimPolicy.reclaimsOnWindowFocus(focused = true))
|
||||
assertFalse(TerminalReclaimPolicy.reclaimsOnWindowFocus(focused = false))
|
||||
}
|
||||
|
||||
// ── View size change: nudge only while disconnected (the driver owns the connected case) ────
|
||||
|
||||
@Test
|
||||
fun `a size change while connected does NOT re-assert (the view driver already pushed it)`() {
|
||||
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Hidden))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a size change while connecting or reconnecting nudges connect-now`() {
|
||||
assertTrue(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Connecting))
|
||||
assertTrue(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Reconnecting(2, 4.seconds)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a size change on a terminal banner never nudges (no reconnect is owed)`() {
|
||||
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE)))
|
||||
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Exited(0, null)))
|
||||
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Exited(-1, "spawn failed")))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.FetchResult
|
||||
import wang.yaojia.webterm.api.models.GitLogResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PrStatus
|
||||
@@ -9,6 +10,7 @@ import wang.yaojia.webterm.api.models.ProjectInfo
|
||||
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.api.models.WorktreeState
|
||||
|
||||
/**
|
||||
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
|
||||
@@ -18,6 +20,8 @@ import wang.yaojia.webterm.api.models.UiPrefs
|
||||
*/
|
||||
class FakeWorktreeGateway(
|
||||
private val detail: ProjectDetail? = null,
|
||||
private val worktreeStates: Map<String, WorktreeState> = emptyMap(),
|
||||
private val fetchOutcome: GitWriteOutcome<FetchResult> = GitWriteOutcome.Ok(FetchResult()),
|
||||
private val prResult: PrStatus? = null,
|
||||
private val prThrows: Boolean = false,
|
||||
private val logResult: GitLogResult? = null,
|
||||
@@ -29,6 +33,8 @@ class FakeWorktreeGateway(
|
||||
val createCalls = mutableListOf<Triple<String, String, String?>>()
|
||||
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
|
||||
val pruneCalls = mutableListOf<String>()
|
||||
val worktreeStateCalls = mutableListOf<String>()
|
||||
val fetchCalls = mutableListOf<String>()
|
||||
var detailCalls = 0
|
||||
private set
|
||||
|
||||
@@ -51,6 +57,16 @@ class FakeWorktreeGateway(
|
||||
return logResult ?: throw NotImplementedError("no log configured")
|
||||
}
|
||||
|
||||
override suspend fun worktreeState(worktreePath: String): WorktreeState {
|
||||
worktreeStateCalls += worktreePath
|
||||
return worktreeStates[worktreePath] ?: throw RuntimeException("probe failed for $worktreePath")
|
||||
}
|
||||
|
||||
override suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> {
|
||||
fetchCalls += path
|
||||
return fetchOutcome
|
||||
}
|
||||
|
||||
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
|
||||
createCalls += Triple(path, branch, base)
|
||||
return createOutcome
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.session.Gate
|
||||
import wang.yaojia.webterm.session.GateState
|
||||
import wang.yaojia.webterm.session.SessionEvent
|
||||
import wang.yaojia.webterm.wire.ApprovalPreview
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
import wang.yaojia.webterm.wire.GateKind
|
||||
import wang.yaojia.webterm.wire.PreviewDiffFile
|
||||
import wang.yaojia.webterm.wire.PreviewDiffHunk
|
||||
import wang.yaojia.webterm.wire.PreviewDiffLine
|
||||
import wang.yaojia.webterm.wiring.TerminalSessionController
|
||||
|
||||
/**
|
||||
* W1 approval preview — a remote one-tap approval must show WHAT it approves (the command or the
|
||||
* diff), not just a tool name. The content is derived server-side from the hook `tool_input`, i.e. it
|
||||
* is **attacker-influenced**: it renders strictly inert, and this test pins the client-side
|
||||
* defence-in-depth sanitizer plus the rule that an ABSENT preview is a NORMAL state (the name-only
|
||||
* gate stays fully decidable — a missing preview never blocks or breaks a gate).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class GatePreviewTest {
|
||||
|
||||
private class FakeController : TerminalSessionController {
|
||||
private val channel = Channel<SessionEvent>(Channel.UNLIMITED)
|
||||
val decisions = mutableListOf<Pair<Int, ClientMessage>>()
|
||||
override fun controlEvents(): Flow<SessionEvent> = channel.receiveAsFlow()
|
||||
override val output: Flow<ByteArray> = emptyFlow()
|
||||
override fun start() = Unit
|
||||
override fun sendInput(data: String) = Unit
|
||||
override fun resize(cols: Int, rows: Int) = Unit
|
||||
override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit
|
||||
override fun decideGate(epoch: Int, message: ClientMessage) { decisions += epoch to message }
|
||||
override fun close() = Unit
|
||||
fun emit(event: SessionEvent) { channel.trySend(event) }
|
||||
}
|
||||
|
||||
// ── Absent preview is a NORMAL state ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a gate with no preview stays fully decidable and shows no preview block`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
fake.emit(Gate(GateState(kind = GateKind.TOOL, detail = "Bash", epoch = 1)))
|
||||
|
||||
assertNotNull(vm.uiState.value.gate, "the gate itself is unaffected")
|
||||
assertNull(vm.uiState.value.preview, "no preview → the name-only bar, not an error")
|
||||
vm.decide(GateDecision.APPROVE, 1)
|
||||
assertEquals(1, fake.decisions.size, "a preview-less gate is still approvable")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a held gate carries its preview onto the ui state`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
fake.emit(
|
||||
Gate(
|
||||
GateState(
|
||||
kind = GateKind.TOOL,
|
||||
detail = "Bash",
|
||||
epoch = 1,
|
||||
preview = ApprovalPreview.Command("rm -rf build/"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val preview = vm.uiState.value.preview
|
||||
assertTrue(preview is ApprovalPreview.Command, "expected a command preview, got $preview")
|
||||
assertEquals("rm -rf build/", (preview as ApprovalPreview.Command).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lifted gate clears any preview with it`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
fake.emit(
|
||||
Gate(
|
||||
GateState(
|
||||
kind = GateKind.TOOL,
|
||||
detail = "Bash",
|
||||
epoch = 1,
|
||||
preview = ApprovalPreview.Command("rm -rf build/"),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNotNull(vm.uiState.value.preview)
|
||||
fake.emit(Gate(null))
|
||||
|
||||
assertNull(vm.uiState.value.gate)
|
||||
assertNull(vm.uiState.value.preview, "a stale preview must never outlive its gate")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new gate replaces the previous gate's preview`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
fake.emit(Gate(gateWithCommand(epoch = 1)))
|
||||
fake.emit(Gate(gateWithCommand(epoch = 2)))
|
||||
|
||||
assertEquals("cmd for epoch 2", (vm.uiState.value.preview as ApprovalPreview.Command).text)
|
||||
}
|
||||
|
||||
private fun gateWithCommand(epoch: Int) = GateState(
|
||||
kind = GateKind.TOOL,
|
||||
detail = "Bash",
|
||||
epoch = epoch,
|
||||
preview = ApprovalPreview.Command("cmd for epoch $epoch"),
|
||||
)
|
||||
|
||||
// ── Inert rendering (§8) ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `command preview text keeps newlines and strips control and ANSI bytes`() {
|
||||
val raw = "rm -rf build/\n\u001B[31mecho\u0007 boom\u0000"
|
||||
|
||||
val safe = inertPreviewText(raw)
|
||||
|
||||
assertTrue(safe.contains("\n"), "a shell command's line structure is meaningful")
|
||||
assertFalse(safe.contains("\u001B"), "no ESC \u2014 preview text must never drive the terminal")
|
||||
assertFalse(safe.contains("\u0007"), "no BEL")
|
||||
assertFalse(safe.contains("\u0000"), "no NUL")
|
||||
assertEquals("rm -rf build/\n[31mecho boom", safe)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `command preview leaves ordinary text and markup characters literal`() {
|
||||
val raw = "grep -n '<script>alert(1)</script>' *.ts && echo \"a&b\""
|
||||
|
||||
assertEquals(raw, inertPreviewText(raw), "no escaping, no linkify — the UI renders it as text")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tabs survive but carriage returns do not`() {
|
||||
assertEquals("a\tb", inertPreviewText("a\tb"))
|
||||
assertEquals("ab", inertPreviewText("a\rb"), "a bare CR would rewrite the rendered line")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the display text of a command preview is the sanitized one`() {
|
||||
val preview = ApprovalPreview.Command("echo \u001Bx", truncated = true)
|
||||
|
||||
assertEquals("echo x", preview.inertText(), "what the UI paints")
|
||||
assertTrue(preview.truncated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a command preview with nothing legible left is not a preview at all`() {
|
||||
assertNull(ApprovalPreview.Command(" \u0000 ").inertText())
|
||||
assertEquals("ls", ApprovalPreview.Command("ls").inertText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a diff preview carries its one synthetic file and truncation flag`() {
|
||||
val file = PreviewDiffFile(
|
||||
oldPath = "a.ts",
|
||||
newPath = "a.ts",
|
||||
status = "modified",
|
||||
added = 1,
|
||||
removed = 0,
|
||||
binary = false,
|
||||
hunks = listOf(PreviewDiffHunk("@@ -1 +1 @@", listOf(PreviewDiffLine("added", "+x")))),
|
||||
)
|
||||
|
||||
val preview = ApprovalPreview.Diff(file, truncated = true)
|
||||
|
||||
assertEquals("a.ts", preview.file.newPath)
|
||||
assertEquals("+x", preview.file.hunks.single().lines.single().text)
|
||||
assertTrue(preview.truncated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.CommitLogEntry
|
||||
import wang.yaojia.webterm.api.models.GitLogResult
|
||||
import wang.yaojia.webterm.api.models.SyncState
|
||||
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||
|
||||
/**
|
||||
* w6 git panel — the pure presentation core (`syncBandOf` / `boundaryIndex` / `worktreeChips` /
|
||||
* `headerDirtyChip`), ported from the shipped web behaviour in `public/projects.ts` +
|
||||
* `public/git-log.ts` and the design in `docs/mockups/project-detail-git.html`.
|
||||
*
|
||||
* The load-bearing rule these tests exist for: **exactly one state may read as the green
|
||||
* "all clear"** — `ahead == 0 && behind == 0` AND a fetch inside SyncState.FETCH_FRESH_WINDOW_MS. "No upstream"
|
||||
* leaves ahead/behind undefined and MUST fall through to an explicit `no upstream`, never to green
|
||||
* (docs/plans/w6-project-git-panel.md: "the single easiest bug to ship").
|
||||
*/
|
||||
class GitSyncPanelTest {
|
||||
|
||||
private val now = 1_800_000_000_000L
|
||||
private val fresh = now - 60_000L // 1 min ago → inside the 1 h staleness window
|
||||
private val ancient = now - (19L * 24 * 60 * 60 * 1000) // 19 days ago
|
||||
|
||||
// ── The green path: exactly one state ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `ahead zero behind zero with a fresh fetch is the one green all-clear`() {
|
||||
val band = syncBandOf(
|
||||
SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = fresh),
|
||||
nowMs = now,
|
||||
)
|
||||
|
||||
val tracking = assertTracking(band)
|
||||
assertFalse(tracking.isStale, "a 1-minute-old fetch is fresh")
|
||||
assertTrue(tracking.isAllClear, "↑0 ↓0 + fresh fetch is the single green state")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ahead zero behind zero with a stale fetch is never green and marks the behind number`() {
|
||||
val band = syncBandOf(
|
||||
SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = ancient),
|
||||
nowMs = now,
|
||||
)
|
||||
|
||||
val tracking = assertTracking(band)
|
||||
assertTrue(tracking.isStale, "a 19-day-old FETCH_HEAD is stale")
|
||||
assertFalse(tracking.isAllClear, "a stale ↓0 is a guess, not a fact — never green")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `never fetched is stale, not fresh`() {
|
||||
val band = syncBandOf(
|
||||
SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = null),
|
||||
nowMs = now,
|
||||
)
|
||||
|
||||
val tracking = assertTracking(band)
|
||||
assertTrue(tracking.isStale)
|
||||
assertFalse(tracking.isAllClear)
|
||||
}
|
||||
|
||||
/** THE fall-through this feature is most likely to get wrong (plan §"Design rule"). */
|
||||
@Test
|
||||
fun `no upstream renders an explicit no-upstream state and never the green path`() {
|
||||
val band = syncBandOf(
|
||||
SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh),
|
||||
nowMs = now,
|
||||
)
|
||||
|
||||
assertEquals(SyncBand.NoUpstream, band, "a branch that tracks nothing has no ↑↓ to report")
|
||||
assertFalse(band!!.isAllClear, "absent numbers must never read as 'in sync'")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank upstream string is treated as no upstream`() {
|
||||
val band = syncBandOf(SyncState(upstream = " ", lastFetchMs = fresh), nowMs = now)
|
||||
|
||||
assertEquals(SyncBand.NoUpstream, band)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the tracking-in-sync state is ever all clear`() {
|
||||
val allClear = listOf(
|
||||
syncBandOf(SyncState(detached = true), nowMs = now),
|
||||
syncBandOf(SyncState(upstream = null), nowMs = now),
|
||||
syncBandOf(SyncState(upstream = "origin/x", ahead = 1, behind = 0, lastFetchMs = fresh), nowMs = now),
|
||||
syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 3, lastFetchMs = fresh), nowMs = now),
|
||||
syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = ancient), nowMs = now),
|
||||
syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = fresh), nowMs = now),
|
||||
).map { it?.isAllClear }
|
||||
|
||||
assertEquals(listOf(false, false, false, false, false, true), allClear)
|
||||
}
|
||||
|
||||
// ── Degraded shapes ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a detached HEAD shows the short sha, no branch, and disables fetch`() {
|
||||
val band = syncBandOf(SyncState(detached = true, lastFetchMs = fresh), head = "1dbed54", nowMs = now)
|
||||
|
||||
assertEquals(SyncBand.Detached("1dbed54"), band)
|
||||
assertFalse(band!!.canFetch, "fetch is not available on a detached HEAD")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch stays available for tracking and no-upstream states`() {
|
||||
assertTrue(syncBandOf(SyncState(upstream = null), nowMs = now)!!.canFetch)
|
||||
assertTrue(syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now)!!.canFetch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `absent sync facts render no band at all (non-git project)`() {
|
||||
assertNull(syncBandOf(null, nowMs = now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `absent ahead and behind on a tracking branch count as zero`() {
|
||||
val tracking = assertTracking(
|
||||
syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now),
|
||||
)
|
||||
|
||||
assertEquals(0, tracking.ahead)
|
||||
assertEquals(0, tracking.behind)
|
||||
assertTrue(tracking.isAllClear, "a tracking branch with no counts and a fresh fetch IS in sync")
|
||||
}
|
||||
|
||||
// ── Header dirty chip (● 3, not a bare dot) ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a dirty count renders the number, not a bare dot`() {
|
||||
assertEquals(GitPanelCopy.dirtyChip(3), headerDirtyChip(dirtyCount = 3, dirty = true))
|
||||
assertTrue(headerDirtyChip(dirtyCount = 3, dirty = true)!!.contains("3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dirty without a count falls back to the countless chip, and clean renders nothing`() {
|
||||
assertEquals(GitPanelCopy.DIRTY_NO_COUNT, headerDirtyChip(dirtyCount = null, dirty = true))
|
||||
assertNull(headerDirtyChip(dirtyCount = 0, dirty = true), "0 changed files is not dirty")
|
||||
assertNull(headerDirtyChip(dirtyCount = null, dirty = false))
|
||||
assertNull(headerDirtyChip(dirtyCount = null, dirty = null))
|
||||
}
|
||||
|
||||
// ── Commit list: unpushed marking + ONE boundary ─────────────────────────────────────────────
|
||||
|
||||
private fun log(vararg unpushed: Boolean, upstream: String? = null, truncated: Boolean = false) =
|
||||
GitLogResult(
|
||||
commits = unpushed.mapIndexed { i, flag ->
|
||||
CommitLogEntry(hash = "hash$i", at = now - i * 1000L, subject = "s$i", unpushed = flag)
|
||||
},
|
||||
truncated = truncated,
|
||||
upstream = upstream,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the boundary is drawn once, after the last unpushed commit`() {
|
||||
val log = log(true, true, false, false, upstream = "origin/develop")
|
||||
|
||||
assertEquals(1, log.boundaryIndex(), "one boundary, right after the last unpushed row")
|
||||
assertEquals("origin/develop", log.boundaryLabel())
|
||||
assertEquals(2, log.unpushedCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* A merge of an older branch interleaves an unpushed commit BELOW a pushed one. The per-commit flag
|
||||
* is authoritative — never the row index — and the single boundary sits after the LAST unpushed row
|
||||
* (plan §TDD step 19).
|
||||
*/
|
||||
@Test
|
||||
fun `an unpushed commit sorting below a pushed one still moves the boundary`() {
|
||||
val log = log(true, false, true, false, upstream = "origin/develop")
|
||||
|
||||
assertEquals(2, log.boundaryIndex())
|
||||
assertEquals(listOf(true, false, true, false), log.commits.map { it.isUnpushed() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no unpushed commits means no boundary`() {
|
||||
val log = log(false, false, upstream = "origin/develop")
|
||||
|
||||
assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex())
|
||||
assertEquals(0, log.unpushedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without an upstream no boundary is drawn even when rows are marked`() {
|
||||
val log = log(true, true, upstream = null)
|
||||
|
||||
assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex(), "nothing to draw the boundary against")
|
||||
assertNull(log.boundaryLabel())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank upstream draws no boundary and no label`() {
|
||||
val log = log(true, upstream = " ")
|
||||
|
||||
assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex())
|
||||
assertNull(log.boundaryLabel(), "an empty label would claim a position it cannot name")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent unpushed flag reads as pushed, never the other way round`() {
|
||||
val commit = CommitLogEntry(hash = "h", at = now, subject = "s", unpushed = null)
|
||||
|
||||
assertFalse(commit.isUnpushed(), "unknown must not be marked; only an explicit true counts")
|
||||
}
|
||||
|
||||
// ── Worktree section ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the worktree section always reads Worktrees with its count, even at one`() {
|
||||
assertEquals("Worktrees (1)", GitPanelCopy.worktreesTitle(1))
|
||||
assertEquals("Worktrees (2)", GitPanelCopy.worktreesTitle(2))
|
||||
assertEquals("Worktrees (0)", GitPanelCopy.worktreesTitle(0))
|
||||
}
|
||||
|
||||
private val mainWorktree = WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true)
|
||||
|
||||
@Test
|
||||
fun `a worktree with no upstream shows no-upstream and never a green chip`() {
|
||||
val chips = worktreeChips(
|
||||
WorktreeInfo(path = "/repo/.claude/worktrees/x", branch = "worktree-x"),
|
||||
WorktreeRowState(sync = SyncState(upstream = null, lastFetchMs = fresh)),
|
||||
)
|
||||
|
||||
assertTrue(chips.any { it.label == GitPanelCopy.NO_UPSTREAM_CHIP })
|
||||
assertFalse(chips.any { it.tone == GitChipTone.OK }, "a worktree row has no green all-clear chip")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worktree chips report main current locked prunable then sync then dirty`() {
|
||||
val chips = worktreeChips(
|
||||
mainWorktree.copy(locked = true, prunable = true),
|
||||
WorktreeRowState(sync = SyncState(upstream = "origin/develop", ahead = 9, lastFetchMs = fresh), dirtyCount = 3),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GitPanelCopy.MAIN_CHIP,
|
||||
GitPanelCopy.CURRENT_CHIP,
|
||||
GitPanelCopy.LOCKED_CHIP,
|
||||
GitPanelCopy.PRUNABLE_CHIP,
|
||||
GitPanelCopy.aheadChip(9),
|
||||
GitPanelCopy.dirtyCountChip(3),
|
||||
),
|
||||
chips.map { it.label },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unprobed worktree row shows no sync chip at all rather than a guess`() {
|
||||
val chips = worktreeChips(mainWorktree, WorktreeRowState())
|
||||
|
||||
assertEquals(listOf(GitPanelCopy.MAIN_CHIP, GitPanelCopy.CURRENT_CHIP), chips.map { it.label })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a detached worktree row says detached`() {
|
||||
val chips = worktreeChips(
|
||||
WorktreeInfo(path = "/repo/wt", branch = null, head = "1dbed54"),
|
||||
WorktreeRowState(sync = SyncState(detached = true)),
|
||||
)
|
||||
|
||||
assertEquals(listOf(GitPanelCopy.DETACHED_CHIP_SHORT), chips.map { it.label })
|
||||
}
|
||||
|
||||
private fun assertTracking(band: SyncBand?): SyncBand.Tracking {
|
||||
assertTrue(band is SyncBand.Tracking, "expected a tracking band, got $band")
|
||||
return band as SyncBand.Tracking
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.FetchResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.GitLogResult
|
||||
import wang.yaojia.webterm.api.models.PrAvailability
|
||||
import wang.yaojia.webterm.api.models.PrStatus
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||
import wang.yaojia.webterm.api.models.WorktreeState
|
||||
|
||||
/**
|
||||
* w6/G2+G3 — the Fetch action on the project-detail sync band.
|
||||
*
|
||||
* `git fetch` only moves remote-tracking refs (no pull, no merge), so it is the ONE write the panel
|
||||
* offers. The rules pinned here: one in-flight fetch at a time (a second tap is dropped, never
|
||||
* queued), a success re-loads the detail so `behind`/`lastFetchMs` actually move, and a FAILURE
|
||||
* changes nothing — `lastFetchMs` stays where it was so the `stale` flag stays ON (never silently
|
||||
* mark the data fresh).
|
||||
*/
|
||||
class ProjectDetailFetchTest {
|
||||
|
||||
private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "develop")
|
||||
|
||||
private fun okFetch(lastFetchMs: Long?): GitWriteOutcome<FetchResult> =
|
||||
GitWriteOutcome.Ok(FetchResult(lastFetchMs))
|
||||
|
||||
private fun vmWith(
|
||||
fetchRemote: (suspend () -> GitWriteOutcome<FetchResult>)?,
|
||||
): Pair<ProjectDetailViewModel, IntArray> {
|
||||
val detailCalls = intArrayOf(0)
|
||||
val vm = ProjectDetailViewModel(
|
||||
path = "/repo",
|
||||
fetch = { detailCalls[0]++; detail },
|
||||
fetchRemote = fetchRemote,
|
||||
)
|
||||
return vm to detailCalls
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no fetch seam wired means the button is not offered`() {
|
||||
val (vm, _) = vmWith(null)
|
||||
|
||||
assertFalse(vm.canFetch, "without a fetch gateway the band must not offer the action")
|
||||
assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful fetch reports the new lastFetchMs and re-loads the detail`() = runTest {
|
||||
val (vm, detailCalls) = vmWith { okFetch(42L) }
|
||||
vm.load()
|
||||
val callsAfterLoad = detailCalls[0]
|
||||
|
||||
vm.fetchUpstream()
|
||||
|
||||
assertTrue(vm.canFetch)
|
||||
assertEquals(ProjectDetailViewModel.FetchPhase.Done(42L), vm.fetchPhase.value)
|
||||
assertEquals(callsAfterLoad + 1, detailCalls[0], "a fetch refreshes ahead/behind")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing fetch surfaces a short message and does NOT re-load or mark data fresh`() = runTest {
|
||||
val (vm, detailCalls) = vmWith { throw RuntimeException("offline") }
|
||||
vm.load()
|
||||
val callsAfterLoad = detailCalls[0]
|
||||
|
||||
vm.fetchUpstream()
|
||||
|
||||
val phase = vm.fetchPhase.value
|
||||
assertTrue(phase is ProjectDetailViewModel.FetchPhase.Failed, "expected Failed, got $phase")
|
||||
assertEquals(GitPanelCopy.FETCH_FAILED, (phase as ProjectDetailViewModel.FetchPhase.Failed).message)
|
||||
assertEquals(callsAfterLoad, detailCalls[0], "a failed fetch must not claim fresh data")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second fetch while one is in flight is dropped, not queued`() = runTest {
|
||||
val gate = CompletableDeferred<GitWriteOutcome<FetchResult>>()
|
||||
var invocations = 0
|
||||
val vm = ProjectDetailViewModel(
|
||||
path = "/repo",
|
||||
fetch = { detail },
|
||||
fetchRemote = { invocations++; gate.await() },
|
||||
)
|
||||
|
||||
val first = launch { vm.fetchUpstream() }
|
||||
// Let the first call reach the suspension point, then tap again.
|
||||
kotlinx.coroutines.yield()
|
||||
assertEquals(ProjectDetailViewModel.FetchPhase.Working, vm.fetchPhase.value)
|
||||
vm.fetchUpstream()
|
||||
gate.complete(okFetch(7L))
|
||||
first.join()
|
||||
|
||||
assertEquals(1, invocations, "the in-flight fetch is not re-entered")
|
||||
assertEquals(ProjectDetailViewModel.FetchPhase.Done(7L), vm.fetchPhase.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fetch banner is dismissable back to idle`() = runTest {
|
||||
val (vm, _) = vmWith { okFetch(1L) }
|
||||
vm.fetchUpstream()
|
||||
|
||||
vm.clearFetchBanner()
|
||||
|
||||
assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rejected fetch surfaces the server's own safe message verbatim`() = runTest {
|
||||
val (vm, detailCalls) = vmWith { GitWriteOutcome.Rejected(status = 400, message = "两个 remote,无法确定目标") }
|
||||
vm.load()
|
||||
val callsAfterLoad = detailCalls[0]
|
||||
|
||||
vm.fetchUpstream()
|
||||
|
||||
assertEquals(
|
||||
ProjectDetailViewModel.FetchPhase.Failed("两个 remote,无法确定目标"),
|
||||
vm.fetchPhase.value,
|
||||
)
|
||||
assertEquals(callsAfterLoad, detailCalls[0], "a rejected fetch must not claim fresh data")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rate-limited fetch says so and does not retry`() = runTest {
|
||||
val (vm, _) = vmWith { GitWriteOutcome.RateLimited }
|
||||
|
||||
vm.fetchUpstream()
|
||||
|
||||
assertEquals(
|
||||
ProjectDetailViewModel.FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED),
|
||||
vm.fetchPhase.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fetch failure never fails the detail load`() = runTest {
|
||||
val (vm, _) = vmWith { throw RuntimeException("offline") }
|
||||
|
||||
vm.load()
|
||||
vm.fetchUpstream()
|
||||
|
||||
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "isolation: detail stays loaded")
|
||||
}
|
||||
|
||||
// ── w6/G7: the per-worktree probe ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `probing skips the current worktree and isolates a failing row`() = runTest {
|
||||
val worktrees = listOf(
|
||||
WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true),
|
||||
WorktreeInfo(path = "/repo/wt-a", branch = "worktree-a"),
|
||||
WorktreeInfo(path = "/repo/wt-b", branch = "worktree-b"),
|
||||
)
|
||||
val gateway = FakeWorktreeGateway(
|
||||
detail = detail.copy(worktrees = worktrees),
|
||||
prResult = PrStatus(availability = PrAvailability.NO_PR),
|
||||
logResult = GitLogResult(),
|
||||
// wt-b is missing on purpose: its probe throws, and must cost only its own row.
|
||||
worktreeStates = mapOf("/repo/wt-a" to WorktreeState(path = "/repo/wt-a", dirtyCount = 2)),
|
||||
)
|
||||
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
|
||||
vm.load()
|
||||
|
||||
vm.probeWorktrees()
|
||||
|
||||
assertEquals(listOf("/repo/wt-a", "/repo/wt-b"), gateway.worktreeStateCalls, "the current row is free")
|
||||
assertEquals(setOf("/repo/wt-a"), vm.worktreeStates.value.keys)
|
||||
assertEquals(2, vm.worktreeStates.value.getValue("/repo/wt-a").dirtyCount)
|
||||
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "a failed probe never fails the panel")
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,8 @@ class ProjectsViewModelTest {
|
||||
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
|
||||
override suspend fun 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()
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.viewmodels.TokenSubmission
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* [AccessTokenGateway] — the `WEBTERM_TOKEN` half of pairing, verified against the server contract in
|
||||
* `src/http/auth.ts` + `src/server.ts` (`POST /auth`: urlencoded `token` field; XHR ⇒ 204 valid / 401
|
||||
* invalid / 429 over the 10-per-minute limit; the global gate answers an unauthed read with 401).
|
||||
*
|
||||
* The credential-hygiene assertions are as load-bearing as the status mapping: the token is a SHELL
|
||||
* credential, so it must appear ONLY in the request body and never in a URL, a header, a returned
|
||||
* outcome, or any `toString()` that could reach a log or a crash report.
|
||||
*/
|
||||
@DisplayName("AccessTokenGateway — POST /auth + 401 gate detection")
|
||||
class AccessTokenGatewayTest {
|
||||
|
||||
private companion object {
|
||||
/** A token using the full server-side charset (`[A-Za-z0-9._~+/=-]`) — `+`, `/`, `=` are the
|
||||
* form-encoding traps: a raw `+` in an urlencoded body decodes to a SPACE server-side. */
|
||||
const val TOKEN = "ab+cd/ef=gh-ij._~"
|
||||
const val BASE = "http://10.0.0.5:3000"
|
||||
}
|
||||
|
||||
private class RecordingTransport(
|
||||
private val handler: (HttpRequest) -> HttpResponse,
|
||||
) : HttpTransport {
|
||||
val requests = mutableListOf<HttpRequest>()
|
||||
|
||||
override suspend fun send(request: HttpRequest): HttpResponse {
|
||||
requests += request
|
||||
return handler(request)
|
||||
}
|
||||
}
|
||||
|
||||
private class ThrowingTransport(private val error: Throwable) : HttpTransport {
|
||||
override suspend fun send(request: HttpRequest): HttpResponse = throw error
|
||||
}
|
||||
|
||||
private fun endpoint(url: String = BASE): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
|
||||
|
||||
private fun status(code: Int): (HttpRequest) -> HttpResponse =
|
||||
{ HttpResponse(status = code, body = ByteArray(0)) }
|
||||
|
||||
private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport }
|
||||
|
||||
// ── Gate detection: is this host token-gated, or is it just not a web-terminal? ──────────────────
|
||||
|
||||
@Test
|
||||
fun `a 401 on the unauthenticated read means the host is token-gated`() = runTest {
|
||||
val transport = RecordingTransport(status(401))
|
||||
|
||||
assertTrue(gateway(transport).requiresAccessToken(endpoint()))
|
||||
assertEquals("$BASE/live-sessions", transport.requests.single().url)
|
||||
assertEquals(HttpMethod.GET, transport.requests.single().method)
|
||||
// The read is Origin-free (plan §4.3: Origin only on guarded routes).
|
||||
assertTrue(transport.requests.single().headers.keys.none { it.equals("Origin", ignoreCase = true) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 200 host is not token-gated`() = runTest {
|
||||
assertFalse(gateway(RecordingTransport(status(200))).requiresAccessToken(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `some other 4xx or 5xx is not read as the token gate`() = runTest {
|
||||
for (code in listOf(403, 404, 418, 500, 503)) {
|
||||
assertFalse(
|
||||
gateway(RecordingTransport(status(code))).requiresAccessToken(endpoint()),
|
||||
"HTTP $code must not be mistaken for the WEBTERM_TOKEN gate",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreachable host is not reported as token-gated`() = runTest {
|
||||
// "Nothing answered" must stay distinguishable from "answered 401" — never prompt for a
|
||||
// credential because the host is down.
|
||||
assertFalse(gateway(ThrowingTransport(IOException("connect refused"))).requiresAccessToken(endpoint()))
|
||||
}
|
||||
|
||||
// ── POST /auth status mapping ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `204 accepts the token`() = runTest {
|
||||
assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `401 is a wrong token, distinct from an unreachable host`() = runTest {
|
||||
assertEquals(TokenSubmission.Rejected, gateway(RecordingTransport(status(401))).submit(endpoint(), TOKEN))
|
||||
|
||||
val unreachable = gateway(ThrowingTransport(IOException("no route"))).submit(endpoint(), TOKEN)
|
||||
assertInstanceOf(TokenSubmission.Unreachable::class.java, unreachable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 is surfaced as rate-limited and never auto-retried`() = runTest {
|
||||
val transport = RecordingTransport(status(429))
|
||||
|
||||
assertEquals(TokenSubmission.RateLimited, gateway(transport).submit(endpoint(), TOKEN))
|
||||
assertEquals(1, transport.requests.size, "a 429 must not be retried inside the gateway")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unexpected status is reported as unreachable, not as a wrong token`() = runTest {
|
||||
val outcome = gateway(RecordingTransport(status(500))).submit(endpoint(), TOKEN)
|
||||
|
||||
assertInstanceOf(TokenSubmission.Unreachable::class.java, outcome)
|
||||
assertEquals("HTTP 500", (outcome as TokenSubmission.Unreachable).reason)
|
||||
}
|
||||
|
||||
// ── Request shape (the server contract) ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the token is posted as an urlencoded form field with every reserved byte escaped`() = runTest {
|
||||
val transport = RecordingTransport(status(204))
|
||||
|
||||
gateway(transport).submit(endpoint(), TOKEN)
|
||||
|
||||
val request = transport.requests.single()
|
||||
assertEquals(HttpMethod.POST, request.method)
|
||||
assertEquals("$BASE/auth", request.url)
|
||||
assertEquals(
|
||||
"application/x-www-form-urlencoded",
|
||||
request.headers.entries.single { it.key.equals("Content-Type", ignoreCase = true) }.value,
|
||||
)
|
||||
// `+` MUST NOT survive raw (Express's qs decodes it to a space) and `/`/`=` would split the field.
|
||||
assertEquals("token=ab%2Bcd%2Fef%3Dgh-ij._~", request.body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no Accept text-html header is sent, so the server answers 204 or 401 instead of redirecting`() =
|
||||
runTest {
|
||||
val transport = RecordingTransport(status(204))
|
||||
|
||||
gateway(transport).submit(endpoint(), TOKEN)
|
||||
|
||||
val accept = transport.requests.single().headers.entries
|
||||
.firstOrNull { it.key.equals("Accept", ignoreCase = true) }?.value.orEmpty()
|
||||
assertFalse(accept.contains("text/html"), "an HTML Accept makes /auth answer 302, not 204/401")
|
||||
}
|
||||
|
||||
// ── Credential hygiene ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the token never reaches the URL, a header, the outcome or any toString`() = runTest {
|
||||
val transport = RecordingTransport(status(401))
|
||||
|
||||
val outcome = gateway(transport).submit(endpoint(), TOKEN)
|
||||
|
||||
val request = transport.requests.single()
|
||||
assertFalse(request.url.contains(TOKEN), "never put the credential in a URL (logs / Referer)")
|
||||
assertFalse(request.headers.toString().contains(TOKEN))
|
||||
assertFalse(outcome.toString().contains(TOKEN))
|
||||
// The data class renders `body=[B@…` — the bytes are never stringified.
|
||||
assertFalse(request.toString().contains(TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transport failure reports only the error type, never the message or the token`() = runTest {
|
||||
// An exception message is attacker/host-influenced and could echo a request; carry the type only.
|
||||
val error = IOException("failed posting token=$TOKEN")
|
||||
val outcome = gateway(ThrowingTransport(error)).submit(endpoint(), TOKEN)
|
||||
|
||||
val reason = (outcome as TokenSubmission.Unreachable).reason
|
||||
assertEquals("IOException", reason)
|
||||
assertFalse(outcome.toString().contains(TOKEN))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieId
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
|
||||
import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME
|
||||
import wang.yaojia.webterm.transport.AuthCookieJar
|
||||
|
||||
/**
|
||||
* [AuthCookieHydrator] — the cold-start half of the `WEBTERM_TOKEN` gate: without it the durable
|
||||
* `webterm_auth` cookie never reaches the live [AuthCookieJar], so a paired token-gated host would ask
|
||||
* for the token again on every launch.
|
||||
*
|
||||
* Two properties are security-load-bearing and asserted directly:
|
||||
* - **per-host isolation** — a record filed under host A must never be presented to host B;
|
||||
* - **hydrate exactly once** — a second hydration could overwrite a FRESHER in-memory cookie with the
|
||||
* stale persisted one (the durable write is asynchronous), silently reverting to a dead credential.
|
||||
*/
|
||||
@DisplayName("AuthCookieHydrator — cold-start restore of the webterm_auth cookie")
|
||||
class AuthCookieHydratorTest {
|
||||
|
||||
private companion object {
|
||||
const val HOST_A = "http://10.0.0.5:3000"
|
||||
const val HOST_B = "http://10.0.0.9:3000"
|
||||
const val TOKEN = "persisted-shell-credential"
|
||||
const val FAR_FUTURE = 4_000_000_000_000L // ~2096
|
||||
}
|
||||
|
||||
private fun record(
|
||||
hostKey: String,
|
||||
host: String,
|
||||
value: String = TOKEN,
|
||||
expiresAt: Long = FAR_FUTURE,
|
||||
) = AuthCookieRecord(
|
||||
hostKey = hostKey,
|
||||
name = AUTH_COOKIE_NAME,
|
||||
value = value,
|
||||
domain = host,
|
||||
path = "/",
|
||||
expiresAtEpochMillis = expiresAt,
|
||||
secure = false,
|
||||
httpOnly = true,
|
||||
hostOnly = true,
|
||||
)
|
||||
|
||||
private fun hydrator(store: AuthCookieStore, jar: AuthCookieJar) =
|
||||
AuthCookieHydrator(store = { store }, jar = { jar })
|
||||
|
||||
private fun AuthCookieJar.valuesFor(baseUrl: String): List<String> =
|
||||
loadForRequest("$baseUrl/live-sessions".toHttpUrl()).map { it.value }
|
||||
|
||||
@Test
|
||||
fun `a persisted cookie is restored onto its own host and nowhere else`() = runTest {
|
||||
val store = InMemoryAuthCookieStore(
|
||||
listOf(record(HOST_A, "10.0.0.5"), record(HOST_B, "10.0.0.9", value = "other-host-token")),
|
||||
)
|
||||
val jar = AuthCookieJar()
|
||||
|
||||
hydrator(store, jar).hydrateOnce()
|
||||
|
||||
assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A))
|
||||
assertEquals(listOf("other-host-token"), jar.valuesFor(HOST_B))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired record is not resurrected`() = runTest {
|
||||
val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5", expiresAt = 1L)))
|
||||
val jar = AuthCookieJar()
|
||||
|
||||
hydrator(store, jar).hydrateOnce()
|
||||
|
||||
assertTrue(jar.valuesFor(HOST_A).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hydration happens exactly once, so a fresher in-memory cookie is never clobbered`() = runTest {
|
||||
val store = CountingStore(listOf(record(HOST_A, "10.0.0.5", value = "stale")))
|
||||
val jar = AuthCookieJar()
|
||||
val hydrator = hydrator(store, jar)
|
||||
|
||||
hydrator.hydrateOnce()
|
||||
assertEquals(listOf("stale"), jar.valuesFor(HOST_A))
|
||||
|
||||
// A live re-auth replaces the credential in memory (its durable write is async and may lag).
|
||||
jar.saveFromResponse(
|
||||
"$HOST_A/auth".toHttpUrl(),
|
||||
listOf(
|
||||
okhttp3.Cookie.Builder()
|
||||
.name(AUTH_COOKIE_NAME)
|
||||
.value("fresh")
|
||||
.expiresAt(FAR_FUTURE)
|
||||
.path("/")
|
||||
.hostOnlyDomain("10.0.0.5")
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
|
||||
hydrator.hydrateOnce()
|
||||
|
||||
assertEquals(1, store.loads, "a second warm-up must not re-read the store")
|
||||
assertEquals(listOf("fresh"), jar.valuesFor(HOST_A))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing store never breaks warm-up and is retried on the next one`() = runTest {
|
||||
val store = FailingThenWorkingStore(listOf(record(HOST_A, "10.0.0.5")))
|
||||
val jar = AuthCookieJar()
|
||||
val hydrator = hydrator(store, jar)
|
||||
|
||||
hydrator.hydrateOnce() // throws inside → swallowed
|
||||
assertTrue(jar.valuesFor(HOST_A).isEmpty())
|
||||
|
||||
hydrator.hydrateOnce() // the failure did not consume the one-shot
|
||||
assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the restored credential is not exposed by the jar or record toString`() = runTest {
|
||||
val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5")))
|
||||
val jar = AuthCookieJar()
|
||||
|
||||
hydrator(store, jar).hydrateOnce()
|
||||
|
||||
assertFalse(jar.toString().contains(TOKEN))
|
||||
assertFalse(store.loadAll().toString().contains(TOKEN))
|
||||
}
|
||||
|
||||
// ── Doubles ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private class CountingStore(initial: List<AuthCookieRecord>) : AuthCookieStore {
|
||||
private val delegate = InMemoryAuthCookieStore(initial)
|
||||
var loads: Int = 0
|
||||
|
||||
override suspend fun loadAll(): List<AuthCookieRecord> {
|
||||
loads++
|
||||
return delegate.loadAll()
|
||||
}
|
||||
|
||||
override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey)
|
||||
override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record)
|
||||
override suspend fun remove(id: AuthCookieId) = delegate.remove(id)
|
||||
override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey)
|
||||
override suspend fun replaceHost(hostKey: String, records: List<AuthCookieRecord>) =
|
||||
delegate.replaceHost(hostKey, records)
|
||||
}
|
||||
|
||||
private class FailingThenWorkingStore(initial: List<AuthCookieRecord>) : AuthCookieStore {
|
||||
private val delegate = InMemoryAuthCookieStore(initial)
|
||||
private var failed = false
|
||||
|
||||
override suspend fun loadAll(): List<AuthCookieRecord> {
|
||||
if (!failed) {
|
||||
failed = true
|
||||
error("keystore unavailable")
|
||||
}
|
||||
return delegate.loadAll()
|
||||
}
|
||||
|
||||
override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey)
|
||||
override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record)
|
||||
override suspend fun remove(id: AuthCookieId) = delegate.remove(id)
|
||||
override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey)
|
||||
override suspend fun replaceHost(hostKey: String, records: List<AuthCookieRecord>) =
|
||||
delegate.replaceHost(hostKey, records)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME
|
||||
import wang.yaojia.webterm.transport.AuthCookieSnapshot
|
||||
|
||||
/**
|
||||
* The `:app`-owned bridge between `:transport-okhttp`'s [AuthCookieSnapshot] (what the live cookie jar
|
||||
* publishes) and `:host-registry`'s `AuthCookieRecord` (what is encrypted at rest). Neither module may
|
||||
* depend on the other (`android/README.md`: dependencies only flow down), so this 2-function mapping is
|
||||
* the seam — and a field dropped here is a silently broken credential, hence the field-for-field test.
|
||||
*/
|
||||
@DisplayName("AuthCookie snapshot ⇄ record mapping")
|
||||
class AuthCookieMappingTest {
|
||||
|
||||
private companion object {
|
||||
const val HOST_KEY = "https://star.terminal.yaojia.wang:443"
|
||||
const val TOKEN = "shell-credential"
|
||||
}
|
||||
|
||||
private fun snapshot() = AuthCookieSnapshot(
|
||||
name = AUTH_COOKIE_NAME,
|
||||
value = TOKEN,
|
||||
domain = "star.terminal.yaojia.wang",
|
||||
path = "/",
|
||||
expiresAtEpochMillis = 4_000_000_000_000L,
|
||||
secure = true,
|
||||
httpOnly = true,
|
||||
hostOnly = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every field survives the round trip, with the host key stamped on the record`() {
|
||||
val original = snapshot()
|
||||
|
||||
val record = original.toRecord(HOST_KEY)
|
||||
|
||||
assertEquals(HOST_KEY, record.hostKey)
|
||||
assertEquals(original.name, record.name)
|
||||
assertEquals(original.value, record.value)
|
||||
assertEquals(original.domain, record.domain)
|
||||
assertEquals(original.path, record.path)
|
||||
// ABSOLUTE wall-clock expiry, never a Max-Age duration (which would restart on every restore).
|
||||
assertEquals(original.expiresAtEpochMillis, record.expiresAtEpochMillis)
|
||||
assertEquals(original.secure, record.secure)
|
||||
assertEquals(original.httpOnly, record.httpOnly)
|
||||
assertEquals(original.hostOnly, record.hostOnly)
|
||||
|
||||
assertEquals(original, record.toSnapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `neither side renders the credential in toString`() {
|
||||
val record = snapshot().toRecord(HOST_KEY)
|
||||
|
||||
assertFalse(record.toString().contains(TOKEN))
|
||||
assertFalse(record.toSnapshot().toString().contains(TOKEN))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
||||
import wang.yaojia.webterm.viewmodels.PairingUiState
|
||||
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
||||
import wang.yaojia.webterm.viewmodels.TokenError
|
||||
import wang.yaojia.webterm.viewmodels.TokenSubmission
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* The `WEBTERM_TOKEN` acquisition flow in [PairingViewModel]: prompt → submit → success / wrong / 429.
|
||||
*
|
||||
* Lives in the `wiring` test package rather than next to `PairingViewModelTest` because the
|
||||
* `app/src/test` `viewmodels` package belongs to another agent's file-ownership lane in this run; the
|
||||
* subject under test is the (public) pairing state machine either way.
|
||||
*
|
||||
* Why the flow exists: when a host runs with `WEBTERM_TOKEN` set, `GET /live-sessions` answers **401**
|
||||
* before anything else can happen (`src/server.ts` `authGate`). The frozen probe taxonomy can only
|
||||
* report that as `HttpOkButNotWebTerminal` ("wrong port?"), which is actively misleading and leaves a
|
||||
* token-gated host impossible to pair. So a failed probe asks the prober whether the host is gated, and
|
||||
* the answer routes to a token prompt instead of the dead-end failure card.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@DisplayName("Pairing — WEBTERM_TOKEN acquisition")
|
||||
class PairingTokenFlowTest {
|
||||
|
||||
private companion object {
|
||||
const val TOKEN = "s3cret-shell-token"
|
||||
const val LAN = "http://10.0.0.5:3000"
|
||||
const val TUNNEL = "https://star.terminal.yaojia.wang"
|
||||
}
|
||||
|
||||
/** Records every probe AND every token submission so "no retry / no re-probe" is directly assertable. */
|
||||
private class FakeProber(
|
||||
var probeResult: (HostEndpoint) -> PairingProbeResult = { PairingProbeResult.Success(it) },
|
||||
var gated: Boolean = false,
|
||||
var submission: (String) -> TokenSubmission = { TokenSubmission.Accepted },
|
||||
) : PairingProber {
|
||||
val probes = mutableListOf<HostEndpoint>()
|
||||
val submitted = mutableListOf<String>()
|
||||
|
||||
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult {
|
||||
probes += endpoint
|
||||
return probeResult(endpoint)
|
||||
}
|
||||
|
||||
override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = gated
|
||||
|
||||
override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission {
|
||||
submitted += token
|
||||
return submission(token)
|
||||
}
|
||||
}
|
||||
|
||||
private fun endpoint(url: String): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
|
||||
|
||||
private fun gatedThenOpen(prober: FakeProber): (HostEndpoint) -> PairingProbeResult = { ep ->
|
||||
// Before the token: the gate 401s the reachability read, which the probe can only classify as
|
||||
// "not a web-terminal". After the token is accepted the same probe succeeds.
|
||||
if (prober.gated) PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||
else PairingProbeResult.Success(ep)
|
||||
}
|
||||
|
||||
private fun newVm(
|
||||
prober: PairingProber,
|
||||
hasCert: () -> Boolean = { false },
|
||||
store: InMemoryHostStore = InMemoryHostStore(),
|
||||
) = PairingViewModel(
|
||||
hostStore = store,
|
||||
prober = prober,
|
||||
hasDeviceCert = { hasCert() },
|
||||
newId = { "fixed-id" },
|
||||
)
|
||||
|
||||
// ── Prompt ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a token-gated host asks for the token instead of showing the wrong-port failure`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertInstanceOf(PairingUiState.TokenRequired::class.java, state)
|
||||
assertEquals(endpoint(LAN), (state as PairingUiState.TokenRequired).endpoint)
|
||||
assertEquals(null, state.error, "the first prompt is not an error")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ungated host still shows the wrong-port failure`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(
|
||||
probeResult = { PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) },
|
||||
gated = false,
|
||||
)
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
|
||||
assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-gate failure is never re-interpreted as a token prompt`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// `gated` is true here, but the failure is a timeout — only the ambiguous
|
||||
// HttpOkButNotWebTerminal case is allowed to become a token prompt.
|
||||
val prober = FakeProber(probeResult = { PairingProbeResult.Failure(PairingError.Timeout) }, gated = true)
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertInstanceOf(PairingUiState.Failed::class.java, state)
|
||||
assertEquals(PairingError.Timeout, (state as PairingUiState.Failed).error)
|
||||
}
|
||||
|
||||
// ── Submit ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `submitting the right token continues pairing and persists the host`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
// Accepting the token means the cookie jar now holds the credential → the gate opens.
|
||||
prober.submission = { prober.gated = false; TokenSubmission.Accepted }
|
||||
val store = InMemoryHostStore()
|
||||
val vm = newVm(prober, store = store)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertEquals(listOf(TOKEN), prober.submitted)
|
||||
assertEquals(2, prober.probes.size, "an accepted token re-runs the probe")
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(LAN, store.loadAll().single().endpoint.baseUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { TokenSubmission.Rejected }
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken("wrong")
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertInstanceOf(PairingUiState.TokenRequired::class.java, state)
|
||||
assertEquals(TokenError.INVALID, (state as PairingUiState.TokenRequired).error)
|
||||
assertEquals(1, prober.probes.size, "a rejected token must not re-probe")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 429 is surfaced honestly and never auto-retried`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { TokenSubmission.RateLimited }
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertEquals(TokenError.RATE_LIMITED, (vm.uiState.value as PairingUiState.TokenRequired).error)
|
||||
assertEquals(1, prober.submitted.size, "rate-limited must not resubmit by itself")
|
||||
assertEquals(1, prober.probes.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreachable host during submit is distinguishable from a wrong token`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { TokenSubmission.Unreachable("IOException") }
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertEquals(TokenError.UNREACHABLE, (vm.uiState.value as PairingUiState.TokenRequired).error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a prober with no token support reports the capability honestly`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
// The interface defaults exist so a probe-only seam (a test double) still compiles; they must
|
||||
// surface as an explicit "cannot submit", never as a silent success.
|
||||
val probeOnly = object : PairingProber {
|
||||
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult =
|
||||
PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||
}
|
||||
val vm = newVm(probeOnly)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
// Not gated by default → the plain failure card, exactly as before this feature existed.
|
||||
assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank token is never submitted`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken(" ")
|
||||
|
||||
assertTrue(prober.submitted.isEmpty())
|
||||
assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a token submitted outside the prompt is a no-op`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber()
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN) // still on the confirm gate — nothing has asked for a token
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertTrue(prober.submitted.isEmpty())
|
||||
assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
}
|
||||
|
||||
// ── The token must not outlive the submit ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the token is never held in the UI state or any of its toStrings`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { TokenSubmission.Rejected }
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
val promptState = vm.uiState.value
|
||||
vm.submitAccessToken(TOKEN)
|
||||
val afterState = vm.uiState.value
|
||||
|
||||
assertFalse(promptState.toString().contains(TOKEN))
|
||||
assertFalse(afterState.toString().contains(TOKEN))
|
||||
}
|
||||
|
||||
// ── The token path cannot bypass the tunnel cert-gate (RULE 4) ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `an accepted token still funnels through the device-cert gate`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
var certInstalled = true
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { prober.gated = false; TokenSubmission.Accepted }
|
||||
val vm = newVm(prober, hasCert = { certInstalled })
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(TUNNEL)
|
||||
vm.confirm()
|
||||
assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value)
|
||||
|
||||
// The cert is removed while the token prompt is open; the post-token probe must re-refuse.
|
||||
certInstalled = false
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value)
|
||||
assertEquals(1, prober.probes.size, "no network probe without a device cert")
|
||||
}
|
||||
}
|
||||
@@ -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<ThumbnailKey>) {
|
||||
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<Unit>()
|
||||
val source = FakePreviewSource(block = gate)
|
||||
val raster = FakeRasterizer()
|
||||
val pipeline = ThumbnailPipeline(
|
||||
previewSource = source,
|
||||
rasterizer = raster,
|
||||
cache = FakeBitmapCache(),
|
||||
dispatcher = StandardTestDispatcher(testScheduler),
|
||||
)
|
||||
val s = session(UUID.randomUUID(), lastOutputAt = 3L)
|
||||
|
||||
val scrolledAway = launch { pipeline.thumbnail(s) }
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
`<domain>` 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.
|
||||
|
||||
@@ -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 属性,`<domain>` 只装一个主机名或一个 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`。
|
||||
|
||||
Reference in New Issue
Block a user