feat(android): close the audit's remaining parity gaps
Seven items the earlier repair waves deliberately left alone, so that the
blocker fixes could land with a working safety net first.
CERTIFICATE LIFECYCLE (the audit's only "high"). iOS ships a
CertificateRotationScheduler; Android had no counterpart, so a device
certificate simply expired and needed a manual re-enroll. The decision is a
pure total function over five answers, with RE_ENROLL_REQUIRED checked first so
it outranks any backoff. Renewal is single-flight — a second trigger coalesces,
and a cancelled leader releases the slot so cancellation cannot wedge rotation
for the process lifetime. Failure leaves the prior identity fully live (the
existing atomic ping-pong flip already guaranteed that) and is published rather
than swallowed.
Two real defects surfaced while building it:
renew() decoded its 201 with EnrollResponseDto, whose deviceId is required —
but /device/:id/renew returns only {cert, caChain, notAfter}; only
/device/enroll echoes deviceId. Every silent renewal would have thrown
MalformedResponse. Fixed with a RenewResponseDto plus the deviceId from the
path segment we addressed.
And because no renew 201 carries renewAfter, a client that persisted it would
rotate exactly once and then report not-due until the cert died. That is why
renewAfter is re-derived from the live leaf as notBefore + 2/3·lifetime — the
control plane's own formula. This is a latent iOS defect that Android now
avoids rather than inherits.
/device/:id/recover was confirmed real (control-plane/src/api/renew.ts:443,
contract pinned by that suite's CP6e) and is now wired. It goes over a separate
NON-mTLS client that throws rather than falling back, because an expired client
certificate cannot authenticate its own recovery — the deadlock a previous
session already hit and recorded.
Asked explicitly about step-up: rotation is unaffected. stepUp appears nowhere
in control-plane/src; it lives only on the relay's WebSocket upgrade. On a
step-up host the SESSION is denied, not the rotation, so the certificate still
stays alive and that principal gap is orthogonal.
FOLLOW-UP QUEUE (W2). The queue frame decoded but the three routes managing it
were missing, so Android could see "N queued" and not queue anything. One
outcome union covers both guarded writes and distinguishes full / too-large /
disabled / session-gone / rate-limited, because they need different copy. 429 is
surfaced and never auto-retried — POST and DELETE share one 20/min bucket.
Queued text is raw shell input and travels verbatim, proven with control
characters and CJK.
UNREAD WATERMARK now persists, so the unread state survives process death —
which is exactly when it matters, since the product premise is walking away and
coming back. It stores a plain map rather than an UnreadLedger: the reducer
lives in :session-core, which :host-registry may not depend on, so the logic is
not restated anywhere.
COPY-OUT. Text selection was a recorded feature gap: the stock ActionMode's own
Copy handler dereferences the absent TerminalSession, so making it reachable
puts an NPE on the button — worse than no selection. The refusal was
re-verified from the bytecode, and the same disassembly showed the way out:
TerminalBuffer.getSelectedText touches only mLines/TerminalRow, no session and
no view. A first-party selection layer now builds on that, with a pure
row-major model so a backwards drag normalises correctly. Terminal content
reaches the clipboard only on an explicit user copy; OSC 52 stays declined.
HOST REMOVAL. PushRegistrar.unregisterHost had zero call sites, so a removed
host kept receiving pushes. Removal is now confirm-gated and cleans everything
keyed by host id — a half-removed host is worse than none.
/config/ui is finally honoured. It was implemented and never called, so
allowAutoMode was ignored and the plan gate offered "approve + auto" even where
the operator had disabled it. It fails CLOSED: an unfetchable config treats auto
as disabled, because the permissive default is the dangerous one.
Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 1150 JVM
tests, 0 failures (903 -> 1150). Instrumented suite re-run on emulator-5554
against live servers: 67 tests, 0 failures, 0 skipped — no device regression.
This commit is contained in:
@@ -15,8 +15,11 @@ import wang.yaojia.webterm.wire.HttpTransport
|
||||
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
|
||||
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
|
||||
* notBefore, notAfter, renewAfter }`
|
||||
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The
|
||||
* server schema is `.strict()`; NO keyAlg/subdomain/deviceName.
|
||||
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 `{ cert, caChain,
|
||||
* notAfter }`. The server schema is `.strict()`; NO
|
||||
* keyAlg/subdomain/deviceName.
|
||||
* `POST /device/:id/recover` [NO client cert — plain HTTPS] `{ cert, csr }` → 201 `{ cert,
|
||||
* caChain, notAfter }`. The EXPIRED leaf's escape hatch.
|
||||
*
|
||||
* Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is
|
||||
* sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
|
||||
@@ -29,8 +32,12 @@ public class DeviceEnrollmentClient(
|
||||
baseUrl: String,
|
||||
private val http: HttpTransport,
|
||||
) {
|
||||
/** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */
|
||||
private val base: String = baseUrl.trim().trimEnd('/')
|
||||
/**
|
||||
* Base control-plane URL with any trailing slash removed, so `baseUrl + path` is well-formed.
|
||||
* Readable so a rotation driver can persist WHICH control plane a device enrolled with and renew
|
||||
* against that same one (a URL is not a credential).
|
||||
*/
|
||||
public val baseUrl: String = baseUrl.trim().trimEnd('/')
|
||||
|
||||
/**
|
||||
* One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is
|
||||
@@ -98,7 +105,38 @@ public class DeviceEnrollmentClient(
|
||||
)
|
||||
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
|
||||
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
|
||||
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
|
||||
// The renew 201 is `{ cert, caChain, notAfter }` — it does NOT echo deviceId, so the id we
|
||||
// addressed the request to is the authoritative one.
|
||||
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover an EXPIRED leaf: `POST /device/:id/recover` carrying the lapsed [expiredCertDer] in the
|
||||
* BODY plus a fresh [csrDer] over the SAME hardware key.
|
||||
*
|
||||
* **This call must NOT ride an mTLS transport.** `/renew` is authenticated by the very leaf it
|
||||
* renews, so a lapsed leaf cannot renew itself — and no TLS terminator will even forward an expired
|
||||
* client certificate (nginx answers a bare 400 under `ssl_verify_client optional`; `optional_no_ca`
|
||||
* tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). The caller therefore supplies a
|
||||
* PLAIN [HttpTransport] with no client identity; possession is proven by the CSR self-signature,
|
||||
* which the control plane checks together with `CSR key == registered key`.
|
||||
*
|
||||
* No bearer is accepted on this path at all — the body is the whole credential story. Everything
|
||||
* else the server enforces is unchanged: real X.509 path validation to the device-CA, SPIFFE parse,
|
||||
* `notBefore` (never graced), `:id` must match the cert, and revocation still applies. Only
|
||||
* `notAfter` is graced (30 days), so a leaf lapsed beyond that answers 401.
|
||||
*/
|
||||
public suspend fun recover(deviceId: String, expiredCertDer: ByteArray, csrDer: ByteArray): EnrollmentResult {
|
||||
if (deviceId.isEmpty() || expiredCertDer.isEmpty() || csrDer.isEmpty()) {
|
||||
throw DeviceEnrollmentError.InvalidRequest
|
||||
}
|
||||
val body = EnrollJson.encodeToString(
|
||||
RecoverRequestBody.serializer(),
|
||||
RecoverRequestBody(cert = base64(expiredCertDer), csr = base64(csrDer)),
|
||||
)
|
||||
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/recover"
|
||||
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearer = null))
|
||||
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
|
||||
}
|
||||
|
||||
// ── Request/response plumbing ────────────────────────────────────────────────────────────
|
||||
@@ -114,7 +152,7 @@ public class DeviceEnrollmentClient(
|
||||
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
|
||||
// string must never emit a bare "Bearer " header.
|
||||
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
|
||||
return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody)
|
||||
return HttpRequest(method = method, url = baseUrl + path, headers = headers, body = jsonBody)
|
||||
}
|
||||
|
||||
/** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
|
||||
@@ -127,6 +165,24 @@ public class DeviceEnrollmentClient(
|
||||
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a re-issuance (renew / recover) 201, whose body carries no `deviceId`, using the [deviceId]
|
||||
* the request was addressed to. `renewAfter` is normally absent here — the rotation policy
|
||||
* re-derives it from the leaf's own validity window (`RotationPolicy.renewAfterFor`).
|
||||
*/
|
||||
private fun toReissueResult(dto: RenewResponseDto, deviceId: String): EnrollmentResult =
|
||||
EnrollmentResult(
|
||||
deviceId = deviceId,
|
||||
certificate = decodeDerOrThrow(dto.cert),
|
||||
caChain = dto.caChain.map { decodeDerOrThrow(it) },
|
||||
notBefore = parseInstantOrNull(dto.notBefore),
|
||||
notAfter = parseInstantOrNull(dto.notAfter),
|
||||
renewAfter = parseInstantOrNull(dto.renewAfter),
|
||||
)
|
||||
|
||||
private fun decodeDerOrThrow(base64Der: String): ByteArray =
|
||||
decodeBase64OrNull(base64Der) ?: throw DeviceEnrollmentError.MalformedResponse
|
||||
|
||||
private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
|
||||
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
|
||||
val chain = dto.caChain.map { entry ->
|
||||
|
||||
@@ -97,6 +97,19 @@ internal data class EnrollRequestBody(
|
||||
@Serializable
|
||||
internal data class RenewRequestBody(val csr: String)
|
||||
|
||||
/**
|
||||
* The `/device/:id/recover` request body — the EXPIRED leaf plus a fresh CSR over the same key.
|
||||
*
|
||||
* The lapsed certificate travels in the BODY (not as an mTLS client cert) because no TLS terminator
|
||||
* forwards an expired client certificate: nginx answers a bare 400 under `ssl_verify_client optional`,
|
||||
* and `optional_no_ca` tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`. Nothing is
|
||||
* lost: the CSR is self-signed by the same private key and the control-plane signer enforces CSR
|
||||
* proof-of-possession plus `CSR key == registered key`, so possession is proven exactly as the
|
||||
* handshake used to prove it. The server schema is `.strict()` on these two keys.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class RecoverRequestBody(val cert: String, val csr: String)
|
||||
|
||||
@Serializable
|
||||
internal data class LoginResponseDto(
|
||||
val enrollToken: String,
|
||||
@@ -114,6 +127,25 @@ internal data class EnrollResponseDto(
|
||||
val renewAfter: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The `/device/:id/renew` and `/device/:id/recover` 201 body — `{ cert, caChain, notAfter }` ONLY.
|
||||
*
|
||||
* Deliberately NOT [EnrollResponseDto]: the re-issuance routes do not echo `deviceId` (nor
|
||||
* `notBefore`/`renewAfter`) — only `/device/enroll` does (`control-plane/src/api/renew.ts` vs
|
||||
* `device-enroll.ts`). Decoding a renew 201 against the enroll DTO, whose `deviceId` is required,
|
||||
* failed EVERY silent rotation with `MalformedResponse`. The device id is supplied by the caller (it
|
||||
* is the path segment the request was addressed to, which is the authoritative value anyway), and the
|
||||
* absent `renewAfter` is re-derived from the leaf by `RotationPolicy.renewAfterFor`.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class RenewResponseDto(
|
||||
val cert: String,
|
||||
val caChain: List<String> = emptyList(),
|
||||
val notBefore: String? = null,
|
||||
val notAfter: String? = null,
|
||||
val renewAfter: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class ErrorDto(val error: String? = null)
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Rotation timing of the installed device identity — the input to [RotationPolicy]. Android analogue
|
||||
* of iOS `DeviceRenewalState`.
|
||||
*
|
||||
* [notAfter] is the leaf's HARD expiry (past it the TLS stack rejects the cert outright) and
|
||||
* [renewAfter] is the advisory "renew from the same key now" instant. Only non-secret timing plus the
|
||||
* non-secret [deviceId] (which is a URL path segment) surface here: the private key never leaves
|
||||
* AndroidKeyStore and the cert bytes are never carried through this type, so it is safe to log.
|
||||
*/
|
||||
public data class DeviceRenewalState(
|
||||
/** The enrolled device id — the `:id` in `POST /device/:id/renew` and `/device/:id/recover`. */
|
||||
val deviceId: String,
|
||||
val notAfter: Instant?,
|
||||
val renewAfter: Instant?,
|
||||
) {
|
||||
/**
|
||||
* Is the leaf due for renewal as of [now]? A missing [renewAfter] NEVER triggers (fail-safe — the
|
||||
* TLS stack is the real gate; the scheduler only pre-empts expiry). Mirrors
|
||||
* [EnrollmentResult.isRenewalDue] and iOS `DeviceRenewalState.isRenewalDue`.
|
||||
*/
|
||||
public fun isRenewalDue(now: Instant): Boolean {
|
||||
val due = renewAfter ?: return false
|
||||
return !now.isBefore(due) // now >= renewAfter
|
||||
}
|
||||
}
|
||||
|
||||
/** What a scheduler must do for one rotation pass — the total answer of [RotationPolicy.decide]. */
|
||||
public enum class RotationDecision {
|
||||
/** Nothing to do: the renew window has not opened (the cheap, common case). */
|
||||
NOT_DUE,
|
||||
|
||||
/** An attempt IS due but the previous one failed too recently — wait, do not hammer. */
|
||||
BACKING_OFF,
|
||||
|
||||
/** Still valid: re-CSR from the same key and renew over mTLS (`POST /device/:id/renew`). */
|
||||
RENEW,
|
||||
|
||||
/**
|
||||
* EXPIRED but inside the recovery grace: the leaf can no longer authenticate its own re-issuance,
|
||||
* so recover over PLAIN HTTPS with the lapsed cert in the body (`POST /device/:id/recover`).
|
||||
*/
|
||||
RECOVER,
|
||||
|
||||
/** TERMINAL — expired past the grace window. No request can succeed; only a fresh enroll can. */
|
||||
RE_ENROLL_REQUIRED,
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure, JVM-testable rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
|
||||
* `TerminalGridMath`). Given "now", the leaf's timing and the last failed attempt it answers
|
||||
* renew / recover / wait / re-enroll — no clock, no keystore, no network, so every branch is a unit
|
||||
* test rather than a device observation.
|
||||
*
|
||||
* Ported from iOS `CertificateRotationScheduler`'s timing rules, plus the two branches the phone
|
||||
* track needs and iOS lacks (it can only renew, so an iOS device whose leaf lapses is stranded):
|
||||
* - [RotationDecision.RECOVER] — the `/device/:id/recover` escape hatch, because no TLS terminator
|
||||
* forwards an expired client certificate, so a lapsed leaf can never authenticate its own renewal.
|
||||
* - [RotationDecision.RE_ENROLL_REQUIRED] — reported ONCE as terminal instead of retrying forever
|
||||
* (the host-track agent logged the same failure 6380 times before this rule existed).
|
||||
*/
|
||||
public object RotationPolicy {
|
||||
/**
|
||||
* The control plane issues `renewAfter = notBefore + 2/3 · lifetime`
|
||||
* (`control-plane/src/api/device-enroll.ts` `DEFAULT_RENEW_FRACTION`). The client mirrors the
|
||||
* fraction as exact integer duration math so [renewAfterFor] lands on the same instant.
|
||||
*/
|
||||
private const val RENEW_FRACTION_NUMERATOR: Long = 2L
|
||||
private const val RENEW_FRACTION_DENOMINATOR: Long = 3L
|
||||
|
||||
/**
|
||||
* How long after `notAfter` a lapsed leaf may still be swapped via `/device/:id/recover`
|
||||
* (30 days) — the same window the control plane's `DEFAULT_EXPIRED_RENEW_GRACE_MS` allows. Asking
|
||||
* outside it is pointless: the server answers 401.
|
||||
*/
|
||||
public val DEFAULT_EXPIRED_RECOVERY_GRACE: Duration = Duration.ofDays(30)
|
||||
|
||||
/**
|
||||
* Minimum gap after a FAILED attempt before another is made. A pass runs on every app
|
||||
* foreground, and the control plane rate-limits renewals to 30/hour per identity, so an
|
||||
* un-throttled retry converts one transient failure into a 429 storm.
|
||||
*/
|
||||
public val DEFAULT_FAILURE_BACKOFF: Duration = Duration.ofMinutes(15)
|
||||
|
||||
/**
|
||||
* Derive the advisory renew instant from the leaf's own validity window.
|
||||
*
|
||||
* This derivation is LOAD-BEARING, not a convenience: `POST /device/:id/renew` and
|
||||
* `POST /device/:id/recover` answer `{cert, caChain, notAfter}` only — neither returns
|
||||
* `renewAfter` (only `/device/enroll` does). A client that persisted the enroll-time value and
|
||||
* never recomputed it would rotate exactly once and then report "not due" until the cert died.
|
||||
*
|
||||
* Returns null when either bound is unknown or the window is not a lifetime (notAfter <=
|
||||
* notBefore) — fail-safe, because [decide] treats a null [DeviceRenewalState.renewAfter] as
|
||||
* never-due rather than inventing a past instant that would renew-storm.
|
||||
*/
|
||||
public fun renewAfterFor(notBefore: Instant?, notAfter: Instant?): Instant? {
|
||||
if (notBefore == null || notAfter == null) return null
|
||||
if (!notBefore.isBefore(notAfter)) return null
|
||||
val lifetime = Duration.between(notBefore, notAfter)
|
||||
return notBefore.plus(
|
||||
lifetime.multipliedBy(RENEW_FRACTION_NUMERATOR).dividedBy(RENEW_FRACTION_DENOMINATOR),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole decision table, in priority order:
|
||||
* 1. expired past [expiredRecoveryGrace] → [RotationDecision.RE_ENROLL_REQUIRED] (terminal; it
|
||||
* outranks the backoff because no amount of waiting can help),
|
||||
* 2. otherwise pick the desired action — expired → RECOVER, [DeviceRenewalState.isRenewalDue] →
|
||||
* RENEW, else NOT_DUE,
|
||||
* 3. an idle leaf stays [RotationDecision.NOT_DUE] (there is no attempt to throttle),
|
||||
* 4. a desired action within [failureBackoff] of [lastFailureAt] → [RotationDecision.BACKING_OFF].
|
||||
*
|
||||
* @param lastFailureAt when the last attempt FAILED (null = none, or the last one succeeded).
|
||||
*/
|
||||
public fun decide(
|
||||
state: DeviceRenewalState,
|
||||
now: Instant,
|
||||
lastFailureAt: Instant? = null,
|
||||
expiredRecoveryGrace: Duration = DEFAULT_EXPIRED_RECOVERY_GRACE,
|
||||
failureBackoff: Duration = DEFAULT_FAILURE_BACKOFF,
|
||||
): RotationDecision {
|
||||
require(!expiredRecoveryGrace.isNegative) { "expiredRecoveryGrace must not be negative" }
|
||||
require(!failureBackoff.isNegative) { "failureBackoff must not be negative" }
|
||||
|
||||
val notAfter = state.notAfter
|
||||
// Terminal first: past `notAfter + grace` nothing can succeed. A non-negative grace makes this
|
||||
// strictly stronger than "expired", so it needs no separate expiry guard.
|
||||
if (notAfter != null && now.isAfter(notAfter.plus(expiredRecoveryGrace))) {
|
||||
return RotationDecision.RE_ENROLL_REQUIRED
|
||||
}
|
||||
val isExpired = notAfter != null && now.isAfter(notAfter)
|
||||
|
||||
val desired = when {
|
||||
isExpired -> RotationDecision.RECOVER
|
||||
state.isRenewalDue(now) -> RotationDecision.RENEW
|
||||
else -> RotationDecision.NOT_DUE
|
||||
}
|
||||
if (desired == RotationDecision.NOT_DUE) return RotationDecision.NOT_DUE
|
||||
if (lastFailureAt != null && now.isBefore(lastFailureAt.plus(failureBackoff))) {
|
||||
return RotationDecision.BACKING_OFF
|
||||
}
|
||||
return desired
|
||||
}
|
||||
}
|
||||
@@ -42,4 +42,9 @@ public data class LiveSessionInfo(
|
||||
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
|
||||
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
|
||||
val lastOutputAt: Long? = null,
|
||||
/** W2 pending prompt-queue depth (`src/types.ts` `queueLength?`) — what the "N queued" badge
|
||||
* renders. Additive OPTIONAL: `null` on a pre-W2 server (queue unsupported / unknown), `0` when
|
||||
* the queue is empty; the badge shows only for a positive value. Decoded tolerantly
|
||||
* ([LossyIntSerializer]) so a garbled value can never hide a running session. */
|
||||
@Serializable(with = LossyIntSerializer::class) val queueLength: Int? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* W2 prompt queue — the server-side FIFO of follow-up prompts injected into a session's PTY the next
|
||||
* time Claude goes idle (`src/server.ts` ~605/644/654, `SessionManager.enqueueFollowup`/`clearQueue`).
|
||||
*
|
||||
* A queued entry is **raw keyboard input for a shell** (invariant #9 / byte-shuttle): it is stored and
|
||||
* later written to the PTY verbatim. Nothing on this path may trim, escape, re-encode or normalise it —
|
||||
* the only transformation is the server appending `\r` when the caller passed `appendEnter = true`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `GET /live-sessions/:id/queue` 200 → `{ length, items }` — the pending depth plus the queued texts
|
||||
* (verbatim keystroke bytes). Both fields default, so a missing/garbled key degrades to an empty queue
|
||||
* instead of throwing; a non-object body yields `null` from `LossyDecode` (→ `InvalidResponseBody`).
|
||||
*/
|
||||
@Serializable
|
||||
public data class QueueSnapshot(
|
||||
/** Pending entries on the server. Equals `items.size` for a healthy server; trust [items] for
|
||||
* rendering and [length] only as the badge number the server itself broadcasts. */
|
||||
val length: Int = 0,
|
||||
/** The queued prompts in FIFO order, verbatim. */
|
||||
val items: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Outcome of the two GUARDED queue writes — `POST` (enqueue) and `DELETE` (cancel-all). One union for
|
||||
* both ops (the `GitWriteOutcome` precedent: one union, six routes); [Full], [TooLarge] and [Disabled]
|
||||
* are simply unreachable for the DELETE, whose handler neither validates text nor consults the
|
||||
* `QUEUE_ENABLED` kill-switch.
|
||||
*
|
||||
* Every variant maps to different UI behaviour, which is why they are typed rather than folded into a
|
||||
* status code:
|
||||
* - [Ok] — the server accepted it; [length] is the authoritative new depth for the "N queued" badge.
|
||||
* - [Full] — 409, the bounded FIFO is at `QUEUE_MAX_ITEMS` (default 10). Cancel something first;
|
||||
* the server never silently drops.
|
||||
* - [TooLarge] — 413, over `QUEUE_ITEM_MAX_BYTES` (default 4 KiB, server-configurable — the client
|
||||
* deliberately does NOT pre-validate size, which would invent a policy it cannot know).
|
||||
* - [Disabled] — 503, `QUEUE_ENABLED=0` on this host: hide the affordance, do not retry.
|
||||
* - [SessionGone] — 404, unknown or already-exited session.
|
||||
* - [RateLimited] — 429 from the shared `QUEUE_RATE_MAX = 20`/minute/IP bucket. **Never auto-retry**;
|
||||
* a retry loop just burns the same budget a real enqueue needs.
|
||||
* - [Rejected] — any other 4xx/5xx, carrying the server's SAFE `error` string verbatim (`message` may
|
||||
* be `null` when the body is empty, e.g. the Origin guard's bare 403).
|
||||
*/
|
||||
public sealed interface QueueWriteOutcome {
|
||||
/** 200 — accepted; [length] is the new pending depth (0 for a cleared queue). */
|
||||
public data class Ok(val length: Int) : QueueWriteOutcome
|
||||
|
||||
/** 409 — the bounded FIFO is full (`QUEUE_MAX_ITEMS`). */
|
||||
public data object Full : QueueWriteOutcome
|
||||
|
||||
/** 413 — the prompt exceeds the server's per-item byte cap. */
|
||||
public data object TooLarge : QueueWriteOutcome
|
||||
|
||||
/** 503 — the queue feature is switched off on this host. */
|
||||
public data object Disabled : QueueWriteOutcome
|
||||
|
||||
/** 404 — the session is unknown or has already exited. */
|
||||
public data object SessionGone : QueueWriteOutcome
|
||||
|
||||
/** 429 — shared per-IP queue bucket. Do NOT auto-retry. */
|
||||
public data object RateLimited : QueueWriteOutcome
|
||||
|
||||
/** Any other failure, with the server's inert message (null when unparseable/empty). */
|
||||
public data class Rejected(val status: Int, val message: String?) : QueueWriteOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the new depth out of a queue write's 200 body (`{ length }`). The status already says it
|
||||
* succeeded, so a missing/garbled body degrades to `0` rather than throwing — the next `queue` read or
|
||||
* `queue` WS frame re-establishes the true depth.
|
||||
*/
|
||||
internal fun decodeQueueDepth(bytes: ByteArray): Int =
|
||||
LossyDecode.objectOrNull(bytes, QueueSnapshot.serializer())?.length ?: 0
|
||||
@@ -2,6 +2,8 @@ package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.nullable
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
@@ -9,7 +11,9 @@ import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import java.util.UUID
|
||||
|
||||
@@ -36,6 +40,23 @@ internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
|
||||
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tolerant `Int?` field decoder: a wrong-typed/garbled value degrades to `null` instead of failing
|
||||
* the whole enclosing object. Used for ADDITIVE OPTIONAL numeric fields (e.g.
|
||||
* `LiveSessionInfo.queueLength`) where dropping the entry would hide a running session for the sake of
|
||||
* a badge. Required numeric fields deliberately keep the strict built-in behaviour.
|
||||
*/
|
||||
internal object LossyIntSerializer : KSerializer<Int?> {
|
||||
private val delegate: KSerializer<Int?> = Int.serializer().nullable
|
||||
override val descriptor: SerialDescriptor = delegate.descriptor
|
||||
override fun serialize(encoder: Encoder, value: Int?) = delegate.serialize(encoder, value)
|
||||
override fun deserialize(decoder: Decoder): Int? {
|
||||
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
|
||||
val primitive = json.decodeJsonElement() as? JsonPrimitive ?: return null
|
||||
return if (primitive.isString) primitive.content.toIntOrNull() else primitive.intOrNull
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
|
||||
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,
|
||||
|
||||
@@ -11,6 +11,9 @@ import wang.yaojia.webterm.api.models.PrStatus
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||
import wang.yaojia.webterm.api.models.QueueSnapshot
|
||||
import wang.yaojia.webterm.api.models.QueueWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.decodeQueueDepth
|
||||
import wang.yaojia.webterm.api.models.FetchResult
|
||||
import wang.yaojia.webterm.api.models.PushResult
|
||||
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||
@@ -137,6 +140,19 @@ public class ApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /live-sessions/:id/queue` (W2) — the pending prompt queue: depth + the queued texts,
|
||||
* verbatim. READ-ONLY, so no Origin (the server guards only the two writes). 404 → the session is
|
||||
* gone; a non-object body → [ApiClientError.InvalidResponseBody] rather than a fake empty queue
|
||||
* (claiming "nothing queued" when entries exist would mislead a cancel-all decision).
|
||||
*/
|
||||
public suspend fun queue(id: UUID): QueueSnapshot {
|
||||
val response = perform(Endpoints.queue(id))
|
||||
requireOk(response)
|
||||
return LossyDecode.objectOrNull(response.body, QueueSnapshot.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
|
||||
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
|
||||
public suspend fun prefs(): UiPrefs {
|
||||
@@ -182,6 +198,52 @@ public class ApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
// ── G: W2 prompt queue (enqueue / cancel-all) → QueueWriteOutcome ──────────────────────
|
||||
|
||||
/**
|
||||
* `POST /live-sessions/:id/queue` (W2) — queue a follow-up prompt the server injects into the PTY
|
||||
* the next time Claude goes idle. Works with zero tabs attached, which is the whole point.
|
||||
*
|
||||
* [text] travels VERBATIM (invariant #9 — raw keyboard bytes for a shell); [appendEnter] is a flag
|
||||
* the SERVER turns into a trailing `\r`, so callers must not append one. An empty prompt is
|
||||
* rejected as [ApiClientError.QueueTextEmpty] before any network I/O; size is NOT pre-checked
|
||||
* client-side (the byte cap is server config) — an over-cap prompt comes back as
|
||||
* [QueueWriteOutcome.TooLarge].
|
||||
*/
|
||||
public suspend fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean = true): QueueWriteOutcome {
|
||||
if (text.isEmpty()) throw ApiClientError.QueueTextEmpty
|
||||
return queueWrite(Endpoints.enqueueFollowup(id, text, appendEnter))
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /live-sessions/:id/queue` (W2) — cancel every pending entry (escape hatch). 200 →
|
||||
* [QueueWriteOutcome.Ok] with depth 0.
|
||||
*/
|
||||
public suspend fun clearQueue(id: UUID): QueueWriteOutcome = queueWrite(Endpoints.clearQueue(id))
|
||||
|
||||
/**
|
||||
* Shared status mapping for the two guarded queue writes. Distinct typed outcomes because each one
|
||||
* demands different UI behaviour (see [QueueWriteOutcome]); in particular a 429 from the shared
|
||||
* `QUEUE_RATE_MAX = 20`/min/IP bucket is surfaced, NEVER auto-retried. Only 409/413/503 are
|
||||
* POST-specific — the DELETE handler cannot produce them.
|
||||
*/
|
||||
private suspend fun queueWrite(route: ApiRoute): QueueWriteOutcome {
|
||||
val response = perform(route)
|
||||
return when (response.status) {
|
||||
HttpStatus.OK -> QueueWriteOutcome.Ok(decodeQueueDepth(response.body))
|
||||
HttpStatus.CONFLICT -> QueueWriteOutcome.Full
|
||||
HttpStatus.PAYLOAD_TOO_LARGE -> QueueWriteOutcome.TooLarge
|
||||
HttpStatus.SERVICE_UNAVAILABLE -> QueueWriteOutcome.Disabled
|
||||
HttpStatus.NOT_FOUND -> QueueWriteOutcome.SessionGone
|
||||
HttpStatus.TOO_MANY_REQUESTS -> QueueWriteOutcome.RateLimited
|
||||
// `decodeGitError` reads the generic `{ error }` failure shape the queue routes share with
|
||||
// the git ones (400/403 here) — reused rather than duplicated.
|
||||
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
|
||||
QueueWriteOutcome.Rejected(response.status, decodeGitError(response.body))
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
|
||||
|
||||
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
|
||||
|
||||
@@ -44,6 +44,11 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
|
||||
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
|
||||
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
|
||||
|
||||
/** An empty follow-up prompt was passed to `POST /live-sessions/:id/queue` (which the server 400s
|
||||
* as `text must be a non-empty string`). Raised client-side, before any network I/O — mirrors the
|
||||
* [ProjectPathInvalid] precedent. */
|
||||
public data object QueueTextEmpty : ApiClientError("要排队的内容为空——请先输入要发送的提示词。")
|
||||
|
||||
/** 500 from `GET /projects/log` — the server failed reading the git log. */
|
||||
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@ internal object HttpStatus {
|
||||
const val BAD_REQUEST = 400
|
||||
const val FORBIDDEN = 403
|
||||
const val NOT_FOUND = 404
|
||||
const val CONFLICT = 409
|
||||
const val PAYLOAD_TOO_LARGE = 413
|
||||
const val TOO_MANY_REQUESTS = 429
|
||||
const val INTERNAL_SERVER_ERROR = 500
|
||||
const val SERVICE_UNAVAILABLE = 503
|
||||
|
||||
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
|
||||
const val CLIENT_ERROR_MIN = 400
|
||||
|
||||
@@ -12,10 +12,10 @@ import java.util.UUID
|
||||
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
|
||||
* pair (this client uses FCM, not APNs/VAPID).
|
||||
*
|
||||
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
|
||||
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
|
||||
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
|
||||
* · `POST|DELETE /push/fcm-token`
|
||||
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue`
|
||||
* · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
|
||||
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue`
|
||||
* · `POST /hook/decision` · `PUT /prefs` · `POST|DELETE /push/fcm-token`
|
||||
*
|
||||
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
|
||||
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
|
||||
@@ -70,6 +70,13 @@ internal object Endpoints {
|
||||
fun getPrefs(): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
||||
|
||||
/**
|
||||
* `GET /live-sessions/:id/queue` — RO (W2). Same threat model as `GET /live-sessions`, so the
|
||||
* server stamps no Origin guard on it and neither may we.
|
||||
*/
|
||||
fun queue(id: UUID): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, queuePath(id), OriginPolicy.READ_ONLY)
|
||||
|
||||
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
|
||||
fun projectPr(path: String): ApiRoute =
|
||||
ApiRoute(
|
||||
@@ -175,6 +182,36 @@ internal object Endpoints {
|
||||
fun gitPush(path: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
||||
|
||||
// ── G: W2 prompt queue (enqueue / cancel-all) ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `POST /live-sessions/:id/queue` — G. Body is exactly `{ text, appendEnter }` (express.json,
|
||||
* 16 kb limit).
|
||||
*
|
||||
* [text] is RAW KEYBOARD INPUT for a shell and is carried VERBATIM (invariant #9): JSON string
|
||||
* encoding is the only transformation, and it round-trips byte-exactly through `express.json`.
|
||||
* Never trim/escape/normalise it here. [appendEnter] is a FLAG — the server materialises the
|
||||
* trailing `\r` so the stored entry is byte-identical to a keystroke; the client must not append
|
||||
* one itself.
|
||||
*/
|
||||
fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean): ApiRoute =
|
||||
jsonBodyRoute(
|
||||
HttpMethod.POST,
|
||||
queuePath(id),
|
||||
EnqueueBody.serializer(),
|
||||
EnqueueBody(text, appendEnter),
|
||||
)
|
||||
|
||||
/**
|
||||
* `DELETE /live-sessions/:id/queue` — G, cancel-all escape hatch. Carries **no** body: unlike
|
||||
* `DELETE /projects/worktree` (the body-carrying DELETE precedent), this handler reads only `:id`
|
||||
* and clears the whole FIFO, so a body would be dead weight the server never parses.
|
||||
*/
|
||||
fun clearQueue(id: UUID): ApiRoute =
|
||||
ApiRoute(HttpMethod.DELETE, queuePath(id), OriginPolicy.GUARDED)
|
||||
|
||||
private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue"
|
||||
|
||||
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
|
||||
private fun <T> jsonBodyRoute(
|
||||
method: HttpMethod,
|
||||
@@ -242,4 +279,7 @@ internal object Endpoints {
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class FetchBody(val path: String)
|
||||
|
||||
@Serializable
|
||||
private data class EnqueueBody(val text: String, val appendEnter: Boolean)
|
||||
}
|
||||
|
||||
@@ -253,6 +253,137 @@ class DeviceEnrollmentClientTest {
|
||||
assertTrue(transport.recordedRequests.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renewDecodesTheRealServerResponseWhichCarriesNoDeviceId() = runTest {
|
||||
// THE REAL WIRE SHAPE: `/device/:id/renew` answers `{cert, caChain, notAfter}` — it does NOT
|
||||
// echo deviceId/notBefore/renewAfter (only `/device/enroll` does; see
|
||||
// control-plane/src/api/renew.ts). Decoding it against the enroll DTO (deviceId required)
|
||||
// made every silent renewal fail as MalformedResponse. The device id comes from the path we
|
||||
// called, which is authoritative anyway.
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201,
|
||||
body = """{"cert":"MAEC","caChain":["MAED"],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
|
||||
)
|
||||
|
||||
val result = client.renew("dev-9", byteArrayOf(0x02))
|
||||
|
||||
assertEquals("dev-9", result.deviceId, "the device id comes from the path segment we called")
|
||||
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
|
||||
assertNull(result.notBefore, "the renew 201 carries no notBefore")
|
||||
assertNull(result.renewAfter, "the renew 201 carries no renewAfter — the client derives it")
|
||||
assertEquals(1, result.caChain.size)
|
||||
}
|
||||
|
||||
// ── recover (EXPIRED-leaf escape hatch — plain HTTPS, cert in the BODY, never mTLS) ──────────
|
||||
|
||||
@Test
|
||||
fun recoverPostsTheExpiredLeafAndAFreshCsrWithNoAuthorizationHeader() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
|
||||
body = """{"cert":"MAEC","caChain":[],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
|
||||
)
|
||||
val expiredLeaf = byteArrayOf(0x30, 0x11)
|
||||
val csr = byteArrayOf(0x30, 0x22)
|
||||
|
||||
val result = client.recover("dev-9", expiredLeaf, csr)
|
||||
|
||||
val request = transport.recordedRequests.single()
|
||||
assertEquals(HttpMethod.POST, request.method)
|
||||
assertEquals("$BASE/device/dev-9/recover", request.url)
|
||||
// Recovery is NOT mTLS-authenticated and carries no bearer: an expired client certificate
|
||||
// cannot authenticate its own re-issuance (no TLS terminator will even forward one), so the
|
||||
// lapsed cert travels in the BODY and proof-of-possession comes from the CSR self-signature.
|
||||
assertNull(request.headers["Authorization"], "recovery carries no bearer")
|
||||
val obj = bodyObject(request)
|
||||
assertEquals(setOf("cert", "csr"), obj.keys, "the /recover schema is .strict() on {cert, csr}")
|
||||
assertEquals(Base64.getEncoder().encodeToString(expiredLeaf), obj["cert"]!!.jsonPrimitive.content)
|
||||
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
||||
assertEquals("dev-9", result.deviceId)
|
||||
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoverPercentEncodesTheDeviceIdPathSegment() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/a%2Fb/recover", status = 201,
|
||||
body = """{"cert":"MAEC","caChain":[]}""".toByteArray(),
|
||||
)
|
||||
client.recover("a/b", byteArrayOf(1), byteArrayOf(2))
|
||||
assertEquals("$BASE/device/a%2Fb/recover", transport.recordedRequests.single().url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoverRejectsEmptyArgumentsBeforeAnyNetworkIo() = runTest {
|
||||
assertEquals(
|
||||
DeviceEnrollmentError.InvalidRequest,
|
||||
runCatching { client.recover("", byteArrayOf(1), byteArrayOf(1)) }.exceptionOrNull(),
|
||||
)
|
||||
assertEquals(
|
||||
DeviceEnrollmentError.InvalidRequest,
|
||||
runCatching { client.recover("d", ByteArray(0), byteArrayOf(1)) }.exceptionOrNull(),
|
||||
)
|
||||
assertEquals(
|
||||
DeviceEnrollmentError.InvalidRequest,
|
||||
runCatching { client.recover("d", byteArrayOf(1), ByteArray(0)) }.exceptionOrNull(),
|
||||
)
|
||||
assertTrue(transport.recordedRequests.isEmpty(), "no I/O for a request that cannot succeed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoverSurfacesA401BeyondTheGraceWindowWithoutLeakingTheCert() = runTest {
|
||||
// Past `DEFAULT_EXPIRED_RENEW_GRACE_MS` the control plane answers a uniform 401. The raised
|
||||
// error must carry the STATUS + the server's `error` code only — never the submitted cert or
|
||||
// CSR bytes, which would put credential material into a crash log.
|
||||
val expiredLeaf = byteArrayOf(0x30, 0x11, 0x22, 0x33)
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 401,
|
||||
body = """{"error":"rejected"}""".toByteArray(),
|
||||
)
|
||||
|
||||
val error = runCatching { client.recover("dev-9", expiredLeaf, byteArrayOf(0x30, 0x44)) }.exceptionOrNull()
|
||||
|
||||
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
|
||||
val rendered = "${error!!.message} $error"
|
||||
assertFalse(
|
||||
rendered.contains(Base64.getEncoder().encodeToString(expiredLeaf)),
|
||||
"the raised error must not carry the certificate bytes",
|
||||
)
|
||||
assertFalse(rendered.contains("MDE"), "no base64 credential material in the error rendering")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoverSurfacesA403ForARevokedDevice() = runTest {
|
||||
// Recovery NEVER bypasses revocation (renew.test.ts CP6e) — surface the 403 verbatim.
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 403,
|
||||
body = """{"error":"rejected"}""".toByteArray(),
|
||||
)
|
||||
assertEquals(
|
||||
DeviceEnrollmentError.Http(403, "rejected"),
|
||||
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoverThrowsMalformedResponseOnAnUndecodable201() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
|
||||
body = """{"cert":"@@not-base64@@"}""".toByteArray(),
|
||||
)
|
||||
assertEquals(
|
||||
DeviceEnrollmentError.MalformedResponse,
|
||||
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theBaseUrlIsReadableSoTheRotationTargetCanBePersisted() = runTest {
|
||||
// The rotation driver must renew against the SAME control plane the device enrolled with; the
|
||||
// enroller persists this alongside the enrollment record.
|
||||
assertEquals(BASE, client.baseUrl)
|
||||
assertEquals(BASE, DeviceEnrollmentClient("$BASE/", transport).baseUrl, "a trailing slash is trimmed")
|
||||
}
|
||||
|
||||
// ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* The PURE rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
|
||||
* `TerminalGridMath` — a policy is a function, so it is exhaustively testable with no clock, no
|
||||
* keystore and no network). Ports the iOS `CertificateRotationSchedulerTests` timing cases and adds
|
||||
* the two the phone track needs and iOS lacks: the EXPIRED-leaf `/device/:id/recover` window and the
|
||||
* terminal beyond-grace state.
|
||||
*/
|
||||
class RotationPolicyTest {
|
||||
private companion object {
|
||||
val NOT_BEFORE: Instant = Instant.parse("2026-08-06T00:00:00Z")
|
||||
val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z")
|
||||
// notBefore + 2/3 · 61d lifetime — the value the control plane itself computes.
|
||||
val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z")
|
||||
|
||||
fun state(
|
||||
notAfter: Instant? = NOT_AFTER,
|
||||
renewAfter: Instant? = RENEW_AFTER,
|
||||
): DeviceRenewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter)
|
||||
}
|
||||
|
||||
// ── renewAfter derivation (the client MUST derive it: renew/recover 201s carry no renewAfter) ──
|
||||
|
||||
@Test
|
||||
fun renewAfterIsTwoThirdsThroughTheLeafLifetime() {
|
||||
// The control plane computes `notBefore + floor(2/3 · lifetime)` (device-enroll.ts
|
||||
// DEFAULT_RENEW_FRACTION); the client must land on the same instant from the leaf alone,
|
||||
// because `/device/:id/renew` and `/device/:id/recover` return `{cert, caChain, notAfter}`
|
||||
// ONLY — no renewAfter. Without this derivation rotation would fire exactly once, ever.
|
||||
assertEquals(RENEW_AFTER, RotationPolicy.renewAfterFor(NOT_BEFORE, NOT_AFTER))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renewAfterIsNullWhenEitherBoundIsUnknown() {
|
||||
assertNull(RotationPolicy.renewAfterFor(null, NOT_AFTER))
|
||||
assertNull(RotationPolicy.renewAfterFor(NOT_BEFORE, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renewAfterIsNullForANonsensicalWindow() {
|
||||
// notAfter before notBefore is not a lifetime — degrade to "unknown" (fail-safe: the TLS
|
||||
// stack is the real gate) instead of inventing a past instant that would renew-storm.
|
||||
assertNull(RotationPolicy.renewAfterFor(NOT_AFTER, NOT_BEFORE))
|
||||
}
|
||||
|
||||
// ── the wait branch ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun aLeafInsideItsRenewWindowIsNotDue() {
|
||||
assertEquals(
|
||||
RotationDecision.NOT_DUE,
|
||||
RotationPolicy.decide(state(), now = RENEW_AFTER.minusSeconds(1)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissingRenewAfterNeverTriggers() {
|
||||
// iOS fail-safe, ported verbatim: absent timing NEVER renews — the TLS stack is the real gate
|
||||
// and the scheduler only pre-empts expiry.
|
||||
assertEquals(
|
||||
RotationDecision.NOT_DUE,
|
||||
RotationPolicy.decide(state(renewAfter = null), now = NOT_AFTER.minusSeconds(1)),
|
||||
)
|
||||
assertFalse(state(renewAfter = null).isRenewalDue(NOT_AFTER.minusSeconds(1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownNotAfterFallsBackToTheRenewAfterBranch() {
|
||||
// A cert whose expiry could not be read must not be treated as expired (that would push a
|
||||
// working device into recovery); it is judged solely on the advisory renew timing.
|
||||
assertEquals(
|
||||
RotationDecision.NOT_DUE,
|
||||
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER.minusSeconds(1)),
|
||||
)
|
||||
assertEquals(
|
||||
RotationDecision.RENEW,
|
||||
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER),
|
||||
)
|
||||
}
|
||||
|
||||
// ── the renew branch ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun renewFiresAtTheRenewAfterBoundary() {
|
||||
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = RENEW_AFTER))
|
||||
assertTrue(state().isRenewalDue(RENEW_AFTER), "the >= boundary renews (iOS parity)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renewStillFiresAtTheLastInstantBeforeExpiry() {
|
||||
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = NOT_AFTER))
|
||||
}
|
||||
|
||||
// ── the recover branch (expired leaf — the phone-track deadlock escape hatch) ───────────────
|
||||
|
||||
@Test
|
||||
fun anExpiredLeafInsideTheGraceWindowRecovers() {
|
||||
// mTLS `/renew` cannot authenticate an expired leaf (nginx refuses to forward one at all), so
|
||||
// the only route left is the plain-HTTPS `/device/:id/recover`.
|
||||
assertEquals(
|
||||
RotationDecision.RECOVER,
|
||||
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(8))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryIsStillAvailableAtTheExactGraceBoundary() {
|
||||
val grace = Duration.ofDays(30)
|
||||
assertEquals(
|
||||
RotationDecision.RECOVER,
|
||||
RotationPolicy.decide(state(), now = NOT_AFTER.plus(grace), expiredRecoveryGrace = grace),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLeafExpiredBeyondTheGraceWindowRequiresAFreshEnroll() {
|
||||
assertEquals(
|
||||
RotationDecision.RE_ENROLL_REQUIRED,
|
||||
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(31))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aZeroGraceDisablesRecoveryEntirely() {
|
||||
// Mirrors the control plane's `expiredRenewGraceMs = 0` posture (renew.test.ts CP6e).
|
||||
assertEquals(
|
||||
RotationDecision.RE_ENROLL_REQUIRED,
|
||||
RotationPolicy.decide(
|
||||
state(),
|
||||
now = NOT_AFTER.plusMillis(1),
|
||||
expiredRecoveryGrace = Duration.ZERO,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTerminalStateWinsOverTheFailureBackoff() {
|
||||
// Beyond grace nothing can ever succeed, so the answer must be the terminal one even while a
|
||||
// backoff is armed — otherwise the user is told "retrying" forever (the exact failure mode
|
||||
// the agent's 6380 identical warnings came from).
|
||||
assertEquals(
|
||||
RotationDecision.RE_ENROLL_REQUIRED,
|
||||
RotationPolicy.decide(
|
||||
state(),
|
||||
now = NOT_AFTER.plus(Duration.ofDays(31)),
|
||||
lastFailureAt = NOT_AFTER.plus(Duration.ofDays(31)).minusSeconds(1),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── the backoff branch (a failed attempt must not hammer the control plane) ─────────────────
|
||||
|
||||
@Test
|
||||
fun aRecentFailureBacksOffInsteadOfRetryingImmediately() {
|
||||
// Every foreground fires a pass; the control plane rate-limits renewals to 30/hour/identity,
|
||||
// so an un-throttled retry turns one failure into a 429 storm.
|
||||
val now = RENEW_AFTER.plus(Duration.ofMinutes(1))
|
||||
assertEquals(
|
||||
RotationDecision.BACKING_OFF,
|
||||
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minus(Duration.ofMinutes(1))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theBackoffExpiresAndTheRenewIsRetried() {
|
||||
val now = RENEW_AFTER.plus(Duration.ofHours(1))
|
||||
assertEquals(
|
||||
RotationDecision.RENEW,
|
||||
RotationPolicy.decide(
|
||||
state(),
|
||||
now = now,
|
||||
lastFailureAt = now.minus(RotationPolicy.DEFAULT_FAILURE_BACKOFF),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRecentFailureAlsoThrottlesTheRecoveryPath() {
|
||||
val now = NOT_AFTER.plus(Duration.ofDays(8))
|
||||
assertEquals(
|
||||
RotationDecision.BACKING_OFF,
|
||||
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minusSeconds(30)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailureNeverTurnsAnIdleLeafIntoAnAttempt() {
|
||||
// NOT_DUE has no attempt to throttle — reporting BACKING_OFF there would be a lie.
|
||||
assertEquals(
|
||||
RotationDecision.NOT_DUE,
|
||||
RotationPolicy.decide(
|
||||
state(),
|
||||
now = RENEW_AFTER.minusSeconds(1),
|
||||
lastFailureAt = RENEW_AFTER.minusSeconds(2),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── boundary validation ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun negativeDurationsAreRejectedAtTheBoundary() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
RotationPolicy.decide(state(), now = RENEW_AFTER, expiredRecoveryGrace = Duration.ofDays(-1))
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
RotationPolicy.decide(state(), now = RENEW_AFTER, failureBackoff = Duration.ofMinutes(-1))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theStateCarriesNoCredentialInItsToString() {
|
||||
// The rotation state is logged/surfaced; it must never be able to carry key or cert bytes.
|
||||
val rendered = state().toString()
|
||||
assertTrue(rendered.contains("dev-1"), "the non-secret device id is the only identifier here")
|
||||
assertFalse(rendered.contains("MII"), "no DER/PEM material may appear in a loggable rendering")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tolerant decode for the W2 queue read (`GET /live-sessions/:id/queue` → `{length, items}`) and for
|
||||
* the additive `LiveSessionInfo.queueLength` the badge reads. The server is UNTRUSTED here: unknown
|
||||
* keys, missing keys and wrong-typed items must degrade, never crash — and the queued strings, which
|
||||
* are raw keyboard bytes, must come back verbatim.
|
||||
*/
|
||||
@DisplayName("W2 queue models — tolerant decode + verbatim items")
|
||||
class PromptQueueTest {
|
||||
private fun snapshot(json: String): QueueSnapshot? =
|
||||
LossyDecode.objectOrNull(json.toByteArray(), QueueSnapshot.serializer())
|
||||
|
||||
@Test
|
||||
fun `decodes length and items`() {
|
||||
val s = snapshot("""{"length":2,"items":["first","second"]}""")!!
|
||||
assertEquals(2, s.length)
|
||||
assertEquals(listOf("first", "second"), s.items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queued items keep control characters and CJK verbatim`() {
|
||||
// [A + TAB + CR + CJK, exactly as the server stored the keystroke bytes.
|
||||
val s = snapshot("""{"length":1,"items":["[A\t你好\r"]}""")!!
|
||||
assertEquals("[A\t你好\r", s.items.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty queue and unknown keys both decode`() {
|
||||
val s = snapshot("""{"length":0,"items":[],"futureField":true}""")!!
|
||||
assertEquals(0, s.length)
|
||||
assertTrue(s.items.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing fields fall back to an empty queue and a non-object body is null`() {
|
||||
val s = snapshot("{}")!!
|
||||
assertEquals(0, s.length)
|
||||
assertTrue(s.items.isEmpty())
|
||||
|
||||
assertNull(snapshot("[]"))
|
||||
assertNull(snapshot("garbage"))
|
||||
}
|
||||
|
||||
// ── LiveSessionInfo.queueLength (additive optional, src/types.ts:318) ─────────────────────
|
||||
|
||||
private val base =
|
||||
"""{"id":"11111111-2222-3333-4444-555555555555","createdAt":1,"clientCount":0,"exited":false,"cols":80,"rows":24"""
|
||||
|
||||
private fun info(extra: String): LiveSessionInfo? =
|
||||
LossyDecode.objectOrNull("$base$extra}".toByteArray(), LiveSessionInfo.serializer())
|
||||
|
||||
@Test
|
||||
fun `queueLength decodes when present`() {
|
||||
assertEquals(4, info(""","queueLength":4""")!!.queueLength)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queueLength is null on a pre-W2 server that omits it`() {
|
||||
assertNull(info("")!!.queueLength)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrong-typed queueLength does not drop the whole session`() {
|
||||
// A garbled value must not make a running session invisible — the field degrades to null.
|
||||
assertNull(info(",\"queueLength\":\"lots\"")!!.queueLength)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.QueueWriteOutcome
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Status → outcome mapping for the W2 prompt queue, read off the server handlers
|
||||
* (`src/server.ts` ~605/644/654):
|
||||
*
|
||||
* POST: 200 `{length}` → [QueueWriteOutcome.Ok] · 409 `{error:"full"}` → `Full` ·
|
||||
* 413 → `TooLarge` · 503 `{error:"queue disabled"}` → `Disabled` ·
|
||||
* 404 `{error:"unknown"|"exited"}` → `SessionGone` · 429 (empty) → `RateLimited` ·
|
||||
* 400/403/anything else → `Rejected(status, safe error)`.
|
||||
* DELETE: 200 `{length:0}` → `Ok(0)` · 404 → `SessionGone` · 403 → `Rejected`.
|
||||
* GET: 200 → snapshot · 404 → [ApiClientError.SessionNotFound].
|
||||
*
|
||||
* 429 comes from the shared `QUEUE_RATE_MAX = 20`/min/IP bucket and is a DISTINCT outcome precisely
|
||||
* so no caller auto-retries into it (the `GitWriteOutcome.RateLimited` precedent).
|
||||
*/
|
||||
@DisplayName("W2 queue — status mapping")
|
||||
class ApiClientQueueTest {
|
||||
private companion object {
|
||||
const val BASE = "http://h:3000"
|
||||
val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555")
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||
private val queueUrl = "$BASE/live-sessions/$ID/queue"
|
||||
|
||||
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
|
||||
|
||||
private fun queuePost(status: Int, body: String = "") =
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, status = status, body = body.toByteArray())
|
||||
|
||||
private fun queueDelete(status: Int, body: String = "") =
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, status = status, body = body.toByteArray())
|
||||
|
||||
private suspend fun enqueue(text: String = "do the thing") = client.enqueueFollowup(ID, text, appendEnter = true)
|
||||
|
||||
// ── POST ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a 200 yields Ok carrying the new depth`() = runTest {
|
||||
queuePost(200, """{"length":3}""")
|
||||
assertEquals(QueueWriteOutcome.Ok(3), enqueue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 200 with a garbled body still succeeds with an unknown depth of zero`() = runTest {
|
||||
queuePost(200, "not json")
|
||||
assertEquals(QueueWriteOutcome.Ok(0), enqueue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `409 full 413 too-large and 503 disabled are distinct typed outcomes`() = runTest {
|
||||
queuePost(409, """{"error":"full"}""")
|
||||
assertEquals(QueueWriteOutcome.Full, enqueue())
|
||||
|
||||
queuePost(413, """{"error":"text too large"}""")
|
||||
assertEquals(QueueWriteOutcome.TooLarge, enqueue())
|
||||
|
||||
queuePost(503, """{"error":"queue disabled"}""")
|
||||
assertEquals(QueueWriteOutcome.Disabled, enqueue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `404 unknown or exited is SessionGone`() = runTest {
|
||||
queuePost(404, """{"error":"unknown"}""")
|
||||
assertEquals(QueueWriteOutcome.SessionGone, enqueue())
|
||||
|
||||
queuePost(404, """{"error":"exited"}""")
|
||||
assertEquals(QueueWriteOutcome.SessionGone, enqueue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 from the shared 20-per-minute bucket is RateLimited and nothing is retried`() = runTest {
|
||||
queuePost(429)
|
||||
assertEquals(QueueWriteOutcome.RateLimited, enqueue())
|
||||
// Exactly ONE request went out — a rate-limited enqueue must never auto-retry.
|
||||
assertEquals(1, transport.recordedRequests.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `400 and 403 surface Rejected with the server's safe message`() = runTest {
|
||||
queuePost(400, """{"error":"text must be a non-empty string"}""")
|
||||
assertEquals(QueueWriteOutcome.Rejected(400, "text must be a non-empty string"), enqueue())
|
||||
|
||||
// The Origin guard 403s with an EMPTY body — message degrades to null, never throws.
|
||||
queuePost(403)
|
||||
assertEquals(QueueWriteOutcome.Rejected(403, null), enqueue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an odd non-error status is UnexpectedStatus`() = runTest {
|
||||
queuePost(302)
|
||||
assertTrue(errorOf { enqueue() } is ApiClientError.UnexpectedStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty prompt is rejected before any network IO`() = runTest {
|
||||
assertEquals(ApiClientError.QueueTextEmpty, errorOf { client.enqueueFollowup(ID, "", appendEnter = true) })
|
||||
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty prompt")
|
||||
}
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `clearQueue maps 200 404 403 and 429`() = runTest {
|
||||
queueDelete(200, """{"length":0}""")
|
||||
assertEquals(QueueWriteOutcome.Ok(0), client.clearQueue(ID))
|
||||
|
||||
queueDelete(404)
|
||||
assertEquals(QueueWriteOutcome.SessionGone, client.clearQueue(ID))
|
||||
|
||||
queueDelete(403)
|
||||
assertEquals(QueueWriteOutcome.Rejected(403, null), client.clearQueue(ID))
|
||||
|
||||
queueDelete(429)
|
||||
assertEquals(QueueWriteOutcome.RateLimited, client.clearQueue(ID))
|
||||
}
|
||||
|
||||
// ── GET ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `queue maps 404 to SessionNotFound and a garbled 200 to InvalidResponseBody`() = runTest {
|
||||
transport.queueSuccess(url = queueUrl, status = 404)
|
||||
assertEquals(ApiClientError.SessionNotFound, errorOf { client.queue(ID) })
|
||||
|
||||
transport.queueSuccess(url = queueUrl, body = "[]".toByteArray())
|
||||
assertEquals(ApiClientError.InvalidResponseBody, errorOf { client.queue(ID) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the W2 prompt-queue trio
|
||||
* (`src/server.ts` ~605/644/654):
|
||||
*
|
||||
* - `GET /live-sessions/:id/queue` — read-only ⇒ MUST NOT carry Origin;
|
||||
* - `POST /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin + JSON body;
|
||||
* - `DELETE /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin, and (unlike
|
||||
* `DELETE /projects/worktree`, the precedent that DOES carry a body) MUST NOT carry a body — the
|
||||
* server handler reads nothing but `:id` and clears the whole queue.
|
||||
*
|
||||
* A route reclassified read↔write turns this red instead of silently shipping either a CSWSH hole or a
|
||||
* write the server 403s.
|
||||
*
|
||||
* The enqueued text is RAW KEYBOARD INPUT for a shell (invariant #9): it must survive the JSON body
|
||||
* **verbatim** — no trim, no escape-rewrite, no Unicode normalisation, and no client-side `\r`
|
||||
* (`appendEnter` is a flag the SERVER materialises).
|
||||
*/
|
||||
@DisplayName("W2 queue routes — shape, Origin policy, verbatim body")
|
||||
class QueueRouteShapeTest {
|
||||
private companion object {
|
||||
const val BASE = "http://192.168.1.5:3000"
|
||||
const val ORIGIN = "http://192.168.1.5:3000"
|
||||
val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555")
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||
|
||||
private val queueUrl = "$BASE/live-sessions/$ID/queue"
|
||||
|
||||
private fun last(): HttpRequest = transport.recordedRequests.last()
|
||||
|
||||
private fun assertGuarded(r: HttpRequest) =
|
||||
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
|
||||
|
||||
private fun assertReadOnly(r: HttpRequest) =
|
||||
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
|
||||
|
||||
/** Parse the recorded request body back out of JSON — proves round-trip byte-exactness without
|
||||
* pinning any particular escaping strategy. */
|
||||
private fun bodyText(r: HttpRequest): String? =
|
||||
Json.parseToJsonElement(r.body!!.decodeToString()).jsonObject["text"]?.jsonPrimitive?.content
|
||||
|
||||
// ── GET — read-only, no Origin ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `queue is a read-only GET with no Origin and no body`() = runTest {
|
||||
transport.queueSuccess(url = queueUrl, body = """{"length":2,"items":["a","b"]}""".toByteArray())
|
||||
|
||||
val snapshot = client.queue(ID)
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.GET, r.method)
|
||||
assertEquals(queueUrl, r.url)
|
||||
assertReadOnly(r)
|
||||
assertNull(r.body, "a GET must not carry a body")
|
||||
assertEquals(2, snapshot.length)
|
||||
assertEquals(listOf("a", "b"), snapshot.items)
|
||||
}
|
||||
|
||||
// ── POST — guarded, JSON body ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `enqueueFollowup is a guarded POST with a text-appendEnter body`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
|
||||
|
||||
client.enqueueFollowup(ID, "run the tests", appendEnter = true)
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.POST, r.method)
|
||||
assertEquals(queueUrl, r.url)
|
||||
assertGuarded(r)
|
||||
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
|
||||
assertEquals("""{"text":"run the tests","appendEnter":true}""", r.body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `appendEnter false is sent verbatim and the client never appends CR itself`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
|
||||
|
||||
client.enqueueFollowup(ID, "no enter", appendEnter = false)
|
||||
|
||||
assertEquals("""{"text":"no enter","appendEnter":false}""", last().body?.decodeToString())
|
||||
assertEquals("no enter", bodyText(last()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `control characters and CJK survive the body byte-exactly`() = runTest {
|
||||
// ESC[A (up-arrow), a literal TAB, ETX (Ctrl-C), CR, a stray NUL, CJK, an emoji, and
|
||||
// deliberate surrounding whitespace that must NOT be trimmed.
|
||||
val raw = " \u001B[A\t\u0003 \u0000 \u4F60\u597D\uFF0C\u4E16\u754C \uD83C\uDF0F caf\u00E9\r\n "
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
|
||||
|
||||
client.enqueueFollowup(ID, raw, appendEnter = false)
|
||||
|
||||
assertEquals(raw, bodyText(last()), "queued prompt bytes must travel verbatim (invariant #9)")
|
||||
}
|
||||
|
||||
// ── DELETE — guarded, and (unlike DELETE /projects/worktree) body-less ────────────────────
|
||||
|
||||
@Test
|
||||
fun `clearQueue is a guarded DELETE with NO body`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray())
|
||||
|
||||
client.clearQueue(ID)
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.DELETE, r.method)
|
||||
assertEquals(queueUrl, r.url)
|
||||
assertGuarded(r)
|
||||
assertNull(r.body, "DELETE /live-sessions/:id/queue takes no body (server reads only :id)")
|
||||
assertFalse(r.headers.containsKey(HeaderName.CONTENT_TYPE), "no body ⇒ no Content-Type")
|
||||
}
|
||||
|
||||
// ── the batch invariant ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `only the two writes carry Origin (batch invariant)`() = runTest {
|
||||
transport.queueSuccess(url = queueUrl, body = """{"length":0,"items":[]}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray())
|
||||
|
||||
client.queue(ID)
|
||||
client.enqueueFollowup(ID, "x", appendEnter = true)
|
||||
client.clearQueue(ID)
|
||||
|
||||
assertReadOnly(transport.recordedRequests[0])
|
||||
assertGuarded(transport.recordedRequests[1])
|
||||
assertGuarded(transport.recordedRequests[2])
|
||||
assertNotNull(transport.recordedRequests[1].body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session id is serialized lowercase in the path`() = runTest {
|
||||
val upper = UUID.fromString("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")
|
||||
val url = "$BASE/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue"
|
||||
transport.queueSuccess(url = url, body = """{"length":0,"items":[]}""".toByteArray())
|
||||
|
||||
client.queue(upper)
|
||||
|
||||
assertTrue(last().url.endsWith("/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.Radius
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
import wang.yaojia.webterm.viewmodels.QueueNotice
|
||||
import wang.yaojia.webterm.viewmodels.QueueUiState
|
||||
import wang.yaojia.webterm.viewmodels.QueueViewModel
|
||||
|
||||
/**
|
||||
* # QueueBadge (W2) — the "N queued" pill.
|
||||
*
|
||||
* Renders NOTHING for a null (unknown / pre-W2 server) or non-positive depth, so a host without the
|
||||
* queue feature shows no affordance at all and an empty queue does not shout "0".
|
||||
*
|
||||
* The number is always the server's own count — the live `queue` frame or `GET /live-sessions`'s
|
||||
* `queueLength` — never a locally incremented guess.
|
||||
*/
|
||||
@Composable
|
||||
public fun QueueBadge(depth: Int?, modifier: Modifier = Modifier) {
|
||||
val pending = depth ?: return
|
||||
if (pending <= 0) return
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(Radius.sm8))
|
||||
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs2),
|
||||
) {
|
||||
Text(
|
||||
text = "$pending 待发",
|
||||
style = WebTermType.metaMono, // tabular numerals so the pill does not jitter as N changes
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* # QueuePanel (W2) — queue / review / cancel the follow-up prompt FIFO.
|
||||
*
|
||||
* The Android counterpart of `public/queue.ts`: a bottom sheet that queues a prompt the server injects
|
||||
* into the PTY the next time Claude goes idle, lists what is already pending, and offers the ONE cancel
|
||||
* the server actually has — cancel-ALL. There is deliberately no per-item cancel affordance, because
|
||||
* `DELETE /live-sessions/:id/queue` clears the whole FIFO and pretending otherwise would lie.
|
||||
*
|
||||
* ### The composed text is raw keyboard input (invariant #9)
|
||||
* The field's contents go to [onSend] **verbatim** — this file never trims, normalises, or appends a
|
||||
* `\r`. `appendEnter` is a FLAG on the request, so the SERVER materialises the carriage return
|
||||
* byte-identically to a keystroke. The send action is enabled by [QueueViewModel.canSend], which refuses
|
||||
* only a genuinely EMPTY string; whitespace is legitimate shell input.
|
||||
*
|
||||
* ### Untrusted text (plan §8)
|
||||
* Queued items are round-tripped through the server, so they are rendered as INERT [Text] — no linkify,
|
||||
* no Markdown, no `AnnotatedString` autolink — exactly like every other server-derived string. So is the
|
||||
* [QueueNotice.Rejected] message, which is the server's own already-sanitized `error` field.
|
||||
*
|
||||
* Sheet presentation / detents / IME behaviour are device-QA (plan §7); the state machine is
|
||||
* `QueueViewModelTest`.
|
||||
*
|
||||
* @param onSend the composed prompt, verbatim. The panel clears its field only after invoking this.
|
||||
* @param onClearAll `DELETE …/queue` — cancel every pending entry.
|
||||
* @param onRefresh re-read the queue (used when the live depth says the cached list is stale).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
public fun QueuePanel(
|
||||
state: QueueUiState,
|
||||
onSend: (String) -> Unit,
|
||||
onClearAll: () -> Unit,
|
||||
onDismissNotice: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onRefresh: () -> Unit = {},
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var draft by remember { mutableStateOf("") }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.lg16, vertical = Spacing.md12),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Text(
|
||||
text = "排队提示词",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
QueueBadge(depth = state.depth)
|
||||
}
|
||||
Text(
|
||||
text = "Claude 下次空闲时,服务器会按顺序把它们送进终端。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
state.notice?.let { notice ->
|
||||
QueueNoticeRow(notice = notice, onDismiss = onDismissNotice)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it }, // VERBATIM: no trim / normalisation anywhere on this path
|
||||
label = { Text("要排队的提示词") },
|
||||
enabled = !state.isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 6,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
Button(
|
||||
onClick = {
|
||||
onSend(draft) // exactly what the user typed
|
||||
draft = ""
|
||||
},
|
||||
enabled = !state.isBusy && QueueViewModel.canSend(draft),
|
||||
) { Text("加入队列") }
|
||||
TextButton(onClick = onClearAll, enabled = !state.isBusy && state.depth > 0) {
|
||||
Text("全部取消")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
QueuedItems(state = state, onRefresh = onRefresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The pending prompts, inert. A stale list offers a re-read rather than contradicting the badge. */
|
||||
@Composable
|
||||
private fun QueuedItems(state: QueueUiState, onRefresh: () -> Unit) {
|
||||
if (state.isItemsStale) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Text(
|
||||
text = "队列已变化。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRefresh) { Text("刷新") }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.items.isEmpty()) {
|
||||
Text(
|
||||
text = "队列是空的。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = QUEUE_LIST_MAX_HEIGHT),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
itemsIndexed(state.items) { index, item ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
Text(
|
||||
text = "${index + 1}.",
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// INERT: round-tripped through the server, so plain Text only (plan §8).
|
||||
Text(
|
||||
text = item,
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One honest line per failure. A 429 says so plainly and offers NO retry button — the bucket is shared
|
||||
* (20/min/IP), so a retry only burns the budget a real enqueue needs.
|
||||
*/
|
||||
@Composable
|
||||
private fun QueueNoticeRow(notice: QueueNotice, onDismiss: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8))
|
||||
.padding(Spacing.sm8),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Text(
|
||||
text = queueNoticeText(notice),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDismiss) { Text("知道了") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-authored copy for each outcome. [QueueNotice.Rejected] is the ONE case that surfaces a server
|
||||
* string, and it is displayed inertly and verbatim (the server already sanitized it); a null message
|
||||
* falls back to app copy rather than printing "null".
|
||||
*/
|
||||
internal fun queueNoticeText(notice: QueueNotice): String = when (notice) {
|
||||
QueueNotice.Full -> "队列已满,请先取消一条。"
|
||||
QueueNotice.TooLarge -> "提示词太长了,请缩短后重试。"
|
||||
QueueNotice.Disabled -> "这台主机关闭了排队功能。"
|
||||
QueueNotice.SessionGone -> "会话已退出,无法排队。"
|
||||
// No retry offered: the 20/min bucket is shared with every other client of this host.
|
||||
QueueNotice.RateLimited -> "操作过于频繁(每分钟上限 20 次),请稍后再手动重试。"
|
||||
QueueNotice.Unreachable -> "无法连接主机,队列状态未知。"
|
||||
is QueueNotice.Rejected -> notice.message ?: "主机拒绝了这次操作。"
|
||||
}
|
||||
|
||||
/** Keeps the sheet's buttons reachable no matter how long the queue gets. */
|
||||
private val QUEUE_LIST_MAX_HEIGHT = Spacing.xxl24 * 8 // 192dp
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "QueueBadge")
|
||||
@Composable
|
||||
private fun QueueBadgePreview() {
|
||||
WebTermTheme {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
|
||||
QueueBadge(depth = 3)
|
||||
QueueBadge(depth = 0) // renders nothing
|
||||
QueueBadge(depth = null) // renders nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,10 @@ private fun TitleLine(row: SessionRow) {
|
||||
) {
|
||||
StatusBadge(status = row.displayStatus)
|
||||
if (row.isUnread) UnreadDot()
|
||||
// W2: the pending follow-up depth from `GET /live-sessions`'s `queueLength` (null on a pre-W2
|
||||
// server → renders nothing). The badge is the cold/list source; an ATTACHED session's live depth
|
||||
// comes from the `queue` WS frame instead.
|
||||
QueueBadge(depth = row.info.queueLength)
|
||||
Text(
|
||||
text = row.title.ifBlank { fallbackLabel(row.info) },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -202,7 +206,12 @@ private fun SessionListRowPreview() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
|
||||
SessionListRow(
|
||||
row = SessionRow(
|
||||
info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L),
|
||||
info = previewInfo(
|
||||
status = ClaudeStatus.WORKING,
|
||||
title = "web-terminal",
|
||||
lastOutputAt = 2L,
|
||||
queueLength = 2,
|
||||
),
|
||||
displayStatus = DisplayStatus.Working,
|
||||
title = "web-terminal",
|
||||
isUnread = true,
|
||||
@@ -228,6 +237,7 @@ private fun previewInfo(
|
||||
cols: Int = 161,
|
||||
rows: Int = 50,
|
||||
lastOutputAt: Long? = null,
|
||||
queueLength: Int? = null,
|
||||
): LiveSessionInfo = LiveSessionInfo(
|
||||
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
|
||||
createdAt = 1L,
|
||||
@@ -240,4 +250,5 @@ private fun previewInfo(
|
||||
rows = rows,
|
||||
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
|
||||
lastOutputAt = lastOutputAt,
|
||||
queueLength = queueLength,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package wang.yaojia.webterm.di
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import wang.yaojia.webterm.push.PushRegistrar
|
||||
import wang.yaojia.webterm.push.PushRegistrarTokenSink
|
||||
import wang.yaojia.webterm.push.PushTokenSink
|
||||
import wang.yaojia.webterm.wiring.HostPushUnregister
|
||||
import wang.yaojia.webterm.wiring.PushTokenSource
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with
|
||||
@@ -13,10 +21,52 @@ import wang.yaojia.webterm.push.PushTokenSink
|
||||
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
|
||||
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
|
||||
* (self-heal). This is the intended optional-collaborator handoff (no mutable global).
|
||||
*
|
||||
* It also binds the two seams the HOST-REMOVAL path needs
|
||||
* ([HostPushUnregister] + [PushTokenSource]): `PushRegistrar.unregisterHost` had zero call sites, so a
|
||||
* removed host kept this device's token and kept pushing for a host the user had dropped.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
public interface PushModule {
|
||||
@Binds
|
||||
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* `DELETE /push/fcm-token` for ONE host — the FIRST caller of [PushRegistrar.unregisterHost],
|
||||
* which was defined and never invoked, so a removed host kept this device's token forever.
|
||||
*
|
||||
* Routed through the registrar rather than a fresh `ApiClient` so the removal inherits its
|
||||
* already-tested best-effort discipline (a `CancellationException` rethrown, any other failure
|
||||
* swallowed with the token never logged).
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideHostPushUnregister(
|
||||
registrar: PushRegistrar,
|
||||
): HostPushUnregister = HostPushUnregister { host, token -> registrar.unregisterHost(host, token) }
|
||||
|
||||
/**
|
||||
* The current FCM registration token. Suspends on Firebase's own `Task` rather than blocking, and
|
||||
* answers `null` for every degraded case (no `google-services.json`, uninitialised `FirebaseApp`,
|
||||
* a failed fetch) so a push-less build still removes hosts cleanly. The token is returned to the
|
||||
* caller and NEVER logged (plan §8).
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun providePushTokenSource(): PushTokenSource = PushTokenSource {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val task = runCatching { FirebaseMessaging.getInstance().token }.getOrNull()
|
||||
if (task == null) {
|
||||
continuation.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
task
|
||||
.addOnSuccessListener { token -> continuation.resume(token?.takeIf { it.isNotEmpty() }) }
|
||||
.addOnFailureListener { continuation.resume(null) }
|
||||
.addOnCanceledListener { continuation.resume(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,22 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import wang.yaojia.webterm.hostregistry.DataStoreHostStore
|
||||
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
|
||||
import wang.yaojia.webterm.hostregistry.DataStoreSessionWatermarkStore
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore.
|
||||
* The host list ([HostStore], key `"hosts"`) and per-host last-session id ([LastSessionStore], key
|
||||
* `"lastSessionId.<hostId>"`) use disjoint keys, so they safely share one DataStore file. Secret
|
||||
* material (the device cert) never lives here — that is the Tink store in [TlsModule].
|
||||
* The host list ([HostStore], key `"hosts"`), the per-host last-session id ([LastSessionStore], key
|
||||
* `"lastSessionId.<hostId>"`) and the unread watermarks ([SessionWatermarkStore], key
|
||||
* `"unreadWatermarks"`) use disjoint keys, so they safely share one DataStore file. Secret material (the
|
||||
* device cert, the `webterm_auth` cookie) never lives here in the clear — those are the Tink-sealed
|
||||
* stores in [TlsModule] / [AuthCookieModule].
|
||||
*
|
||||
* Two `DataStore` instances over one file THROW, so every store here takes the single [provideDataStore]
|
||||
* singleton; a new store means a new KEY, never a new file.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@@ -43,5 +50,16 @@ public object StorageModule {
|
||||
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
|
||||
DataStoreLastSessionStore(dataStore)
|
||||
|
||||
/**
|
||||
* The durable home of the session-list unread watermarks (`sessionId → last-seen ms`). Until this was
|
||||
* wired the ledger was memory-only, so the unread dot reset on every process death — precisely when
|
||||
* it matters, since the product premise is walking away and coming back.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideSessionWatermarkStore(
|
||||
dataStore: DataStore<Preferences>,
|
||||
): SessionWatermarkStore = DataStoreSessionWatermarkStore(dataStore)
|
||||
|
||||
private const val DATASTORE_NAME: String = "webterm"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.tlsandroid.TinkCertStore
|
||||
import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wiring.CertificateRotationScheduler
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
@@ -78,4 +80,35 @@ public object TlsModule {
|
||||
@Singleton
|
||||
public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher =
|
||||
repository
|
||||
|
||||
/**
|
||||
* Silent device-certificate rotation (the iOS `CertificateRotationScheduler` counterpart). Android had
|
||||
* NO rotation at all, so a device certificate simply expired and stranded the app; the scheduler also
|
||||
* routes an ALREADY-lapsed leaf to `POST /device/:id/recover`, which iOS cannot do.
|
||||
*
|
||||
* ### This provider must stay I/O-free
|
||||
* The shared `OkHttpClient` and the mTLS [HttpTransport] are taken as [dagger.Lazy] and passed as
|
||||
* SUPPLIERS, so nothing here builds an `SSLSocketFactory` (AndroidKeyStore + Tink reads) while the
|
||||
* Hilt graph is assembled — that assembly happens on `Main`, and a previous review caught exactly
|
||||
* that class of ANR. The scheduler resolves both suppliers inside its own `Dispatchers.IO` hop.
|
||||
*
|
||||
* `plainTransport` is deliberately NOT passed: its default builds a fresh identity-free client, and
|
||||
* that separation is the entire point of the recovery path (no TLS terminator will forward an expired
|
||||
* client certificate, in any `ssl_verify_client` mode).
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideCertificateRotationScheduler(
|
||||
certStore: CertStore,
|
||||
recordStore: EnrollmentRecordStore,
|
||||
cacheRefresher: IdentityCacheRefresher,
|
||||
client: dagger.Lazy<OkHttpClient>,
|
||||
httpTransport: dagger.Lazy<HttpTransport>,
|
||||
): CertificateRotationScheduler = CertificateRotationScheduler.create(
|
||||
certStore = certStore,
|
||||
recordStore = recordStore,
|
||||
cacheRefresher = cacheRefresher,
|
||||
sharedClient = { client.get() },
|
||||
mtlsTransport = { httpTransport.get() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import wang.yaojia.webterm.viewmodels.CertPhase
|
||||
import wang.yaojia.webterm.viewmodels.CertSummaryView
|
||||
import wang.yaojia.webterm.viewmodels.ClientCertUiState
|
||||
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
||||
import wang.yaojia.webterm.wiring.RotationOutcome
|
||||
|
||||
/**
|
||||
* # ClientCertScreen (A27) — device-certificate (mTLS) management.
|
||||
@@ -78,6 +79,7 @@ public fun ClientCertScreen(
|
||||
viewModel: ClientCertViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
rotationOutcome: RotationOutcome? = rememberRotationOutcome(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
// Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load.
|
||||
@@ -117,9 +119,21 @@ public fun ClientCertScreen(
|
||||
onDismissError = viewModel::clearError,
|
||||
modifier = modifier,
|
||||
onBack = onBack,
|
||||
rotationOutcome = rotationOutcome,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The last silent-rotation outcome, from the app graph. `null` outside a Hilt application (a `@Preview`),
|
||||
* and `null` before the first pass — both render nothing.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberRotationOutcome(): RotationOutcome? {
|
||||
val outcomes = rememberAppEnvironment()?.rotationOutcomes() ?: return null
|
||||
val outcome by outcomes.collectAsStateWithLifecycle()
|
||||
return outcome
|
||||
}
|
||||
|
||||
/** Stateless body — pure inputs so a [Preview] and any host can drive it without the SAF/VM machinery. */
|
||||
@Composable
|
||||
public fun ClientCertScreen(
|
||||
@@ -132,6 +146,7 @@ public fun ClientCertScreen(
|
||||
onDismissError: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
rotationOutcome: RotationOutcome? = null,
|
||||
) {
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
@@ -159,6 +174,10 @@ public fun ClientCertScreen(
|
||||
|
||||
state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) }
|
||||
|
||||
// Silent rotation is invisible by design; a FAILING one must not be. Only the states
|
||||
// the user can act on are rendered — a healthy/not-due pass stays quiet.
|
||||
RotationStatusRow(rotationOutcome)
|
||||
|
||||
PassphraseField(
|
||||
passphrase = passphrase,
|
||||
onPassphraseChange = onPassphraseChange,
|
||||
@@ -220,6 +239,45 @@ private fun InstalledCertCard(summary: CertSummaryView) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The last silent-rotation pass, when it is something the user can act on.
|
||||
*
|
||||
* Quiet for the healthy states ([RotationOutcome.NotDue] / [RotationOutcome.Renewed] /
|
||||
* [RotationOutcome.Recovered] / [RotationOutcome.NotEnrolled] / [RotationOutcome.BackingOff]) — a
|
||||
* successful invisible renewal should stay invisible. Loud only for the three the user must know about:
|
||||
* a lapsed certificate past its recovery window, a host that has no control-plane URL recorded, and a
|
||||
* repeatedly failing renewal.
|
||||
*
|
||||
* The failure text is the outcome's error SHAPE (a class name or `Http(status,code)`), which is all the
|
||||
* scheduler retains — deliberately never `Throwable.message`, which can embed a control-plane URL, a
|
||||
* response body or credential material (plan §8).
|
||||
*/
|
||||
@Composable
|
||||
private fun RotationStatusRow(outcome: RotationOutcome?) {
|
||||
val message = rotationStatusText(outcome) ?: return
|
||||
Text(text = message, style = MaterialTheme.typography.bodySmall, color = WebTermColors.statusStuck)
|
||||
}
|
||||
|
||||
/** App-authored copy for the actionable rotation outcomes; null = say nothing. */
|
||||
internal fun rotationStatusText(outcome: RotationOutcome?): String? = when (outcome) {
|
||||
null,
|
||||
RotationOutcome.NotEnrolled,
|
||||
RotationOutcome.Renewed,
|
||||
RotationOutcome.Recovered,
|
||||
is RotationOutcome.NotDue,
|
||||
is RotationOutcome.BackingOff,
|
||||
-> null
|
||||
|
||||
is RotationOutcome.ReEnrollRequired ->
|
||||
"⚠ 设备证书已过期超过可恢复期,自动续期无法完成。请用「自动获取证书」重新登记本机。"
|
||||
|
||||
is RotationOutcome.NotConfigured ->
|
||||
"⚠ 未记录控制面地址,无法自动续期证书。请重新执行一次「自动获取证书」。"
|
||||
|
||||
is RotationOutcome.Failed ->
|
||||
"⚠ 证书自动续期失败(${outcome.stage.name.lowercase()}:${outcome.errorShape})。当前证书仍然有效。"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRow(label: String, value: String) {
|
||||
Row(
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -37,11 +39,16 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.components.PointerContextMenu
|
||||
import wang.yaojia.webterm.components.SessionListRow
|
||||
@@ -51,9 +58,12 @@ import wang.yaojia.webterm.designsystem.Radius
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.nav.LayoutMode
|
||||
import wang.yaojia.webterm.viewmodels.HostOption
|
||||
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
|
||||
import wang.yaojia.webterm.viewmodels.SessionListUiState
|
||||
import wang.yaojia.webterm.viewmodels.SessionListViewModel
|
||||
import wang.yaojia.webterm.viewmodels.SessionRow
|
||||
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
|
||||
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -90,6 +100,14 @@ import java.util.UUID
|
||||
* [LayoutMode.STACK] default means a caller that does not pass it gets today's phone behaviour.
|
||||
* @param onNewSessionInCwd pointer-menu「在当前目录开新会话」→ `attach(null, cwd)` in the row's cwd.
|
||||
* `null` (the default) drops that entry.
|
||||
* @param watermarkStore the DURABLE unread-watermark home, installed into [viewModel] before its first
|
||||
* fetch so the dot survives process death. Defaults to the app-graph store
|
||||
* ([rememberAppEnvironment]); a test/preview passes an
|
||||
* [InMemoryUnreadWatermarkStore][wang.yaojia.webterm.viewmodels.InMemoryUnreadWatermarkStore].
|
||||
* @param hostRemoval the un-pair path (`HostRemover`), likewise installed into [viewModel]. `null`
|
||||
* removes the affordance entirely, which is the right behaviour for a surface that cannot un-pair.
|
||||
* @param onForeground fired on every STARTED transition — production runs the silent certificate
|
||||
* rotation pass here (the app's landing surface is the reliable "we are in the foreground" edge).
|
||||
*/
|
||||
@Composable
|
||||
public fun SessionListScreen(
|
||||
@@ -103,14 +121,26 @@ public fun SessionListScreen(
|
||||
thumbnails: SessionThumbnails? = null,
|
||||
layoutMode: LayoutMode = LayoutMode.STACK,
|
||||
onNewSessionInCwd: ((String) -> Unit)? = null,
|
||||
watermarkStore: UnreadWatermarkStore? = rememberAppEnvironment()?.unreadWatermarkStore,
|
||||
hostRemoval: HostRemovalGateway? = rememberAppEnvironment()?.hostRemoval,
|
||||
onForeground: () -> Unit = rememberCertificateRotationTrigger(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var confirmingRemove by remember { mutableStateOf(false) }
|
||||
|
||||
// STARTED-scoped poll: fetch→wait→repeat while foregrounded, cancelled on background (plan §6.6).
|
||||
LaunchedEffect(viewModel, lifecycleOwner) {
|
||||
// The app-scoped collaborators are installed FIRST, inside the same effect, so the install is
|
||||
// provably ordered before `poll()`'s first ledger read (a separate LaunchedEffect would only be
|
||||
// ordered by composition order — true today, but not a property worth depending on).
|
||||
LaunchedEffect(viewModel, lifecycleOwner, watermarkStore, hostRemoval) {
|
||||
watermarkStore?.let(viewModel::useWatermarkStore)
|
||||
hostRemoval?.let(viewModel::useRemovalGateway)
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
// Every foreground return: rotate the device certificate if it is due (idempotent, and
|
||||
// NotDue costs one store read). Fire-and-forget — it must never delay the list.
|
||||
onForeground()
|
||||
viewModel.poll()
|
||||
}
|
||||
}
|
||||
@@ -125,6 +155,7 @@ public fun SessionListScreen(
|
||||
onPairHost = onPairHost,
|
||||
onEnroll = onEnroll,
|
||||
onImportCert = onImportCert,
|
||||
onRemoveHost = if (hostRemoval != null) ({ confirmingRemove = true }) else null,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
@@ -142,8 +173,43 @@ public fun SessionListScreen(
|
||||
thumbnails = thumbnails,
|
||||
layoutMode = layoutMode,
|
||||
onNewSessionInCwd = onNewSessionInCwd,
|
||||
onDismissRemoveError = viewModel::dismissRemoveError,
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmingRemove) {
|
||||
RemoveHostConfirmDialog(
|
||||
hostName = state.hosts.firstOrNull { it.isActive }?.name.orEmpty(),
|
||||
onConfirm = {
|
||||
confirmingRemove = false
|
||||
scope.launch { viewModel.removeActiveHost() }
|
||||
},
|
||||
onDismiss = { confirmingRemove = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-gate for un-pairing (plan §8): removal deletes this device's push registration ON the host and
|
||||
* erases the paired record, the last-session pointer, the saved access cookie and the unread watermarks.
|
||||
* It is not destructive to the host's SESSIONS — they keep running — and the copy says exactly that so
|
||||
* the user is not guessing. The device certificate is app-wide and deliberately survives.
|
||||
*/
|
||||
@Composable
|
||||
private fun RemoveHostConfirmDialog(hostName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("移除主机") },
|
||||
text = {
|
||||
Text(
|
||||
// hostName is user-entered at pairing time; rendered inert like every other stored string.
|
||||
text = "将移除「$hostName」的配对信息、访问凭证与推送注册(该主机不会再向本机推送通知)。" +
|
||||
"主机上的会话会继续运行;设备证书不会被删除。",
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text("移除") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Top bar + host menu ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -156,6 +222,7 @@ private fun SessionListTopBar(
|
||||
onPairHost: () -> Unit,
|
||||
onEnroll: () -> Unit,
|
||||
onImportCert: () -> Unit,
|
||||
onRemoveHost: (() -> Unit)?,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话"
|
||||
@@ -175,13 +242,20 @@ private fun SessionListTopBar(
|
||||
onPairHost = { menuOpen = false; onPairHost() },
|
||||
onEnroll = { menuOpen = false; onEnroll() },
|
||||
onImportCert = { menuOpen = false; onImportCert() },
|
||||
onRemoveHost = onRemoveHost?.let { remove -> { menuOpen = false; remove() } },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */
|
||||
/**
|
||||
* The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions.
|
||||
*
|
||||
* 「移除此主机」acts on the ACTIVE host only — the un-pair has to drop the unread watermarks of that
|
||||
* host's sessions, and those ids are only known for the host currently listed. It is confirm-gated by the
|
||||
* caller, and hidden entirely when no removal path is wired.
|
||||
*/
|
||||
@Composable
|
||||
private fun HostMenu(
|
||||
expanded: Boolean,
|
||||
@@ -191,6 +265,7 @@ private fun HostMenu(
|
||||
onPairHost: () -> Unit,
|
||||
onEnroll: () -> Unit,
|
||||
onImportCert: () -> Unit,
|
||||
onRemoveHost: (() -> Unit)?,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
for (host in hosts) {
|
||||
@@ -206,6 +281,13 @@ private fun HostMenu(
|
||||
// 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS.
|
||||
DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll)
|
||||
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
|
||||
if (onRemoveHost != null && hosts.any { it.isActive }) {
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = "移除此主机", color = MaterialTheme.colorScheme.error) },
|
||||
onClick = onRemoveHost,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,33 +305,106 @@ private fun SessionListBody(
|
||||
thumbnails: SessionThumbnails?,
|
||||
layoutMode: LayoutMode,
|
||||
onNewSessionInCwd: ((String) -> Unit)?,
|
||||
onDismissRemoveError: () -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.pulltorefresh.PullToRefreshBox(
|
||||
isRefreshing = state.isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier.fillMaxSize().padding(contentPadding),
|
||||
) {
|
||||
when {
|
||||
state.activeHostId == null ->
|
||||
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
|
||||
state.rows.isEmpty() ->
|
||||
EmptyState(
|
||||
message = if (state.hasLoadError) "无法加载会话列表。下拉重试。" else "该主机没有运行中的会话。",
|
||||
actionLabel = "开新会话",
|
||||
onAction = onNewSession,
|
||||
)
|
||||
else -> SessionList(
|
||||
state = state,
|
||||
onOpen = onOpen,
|
||||
onKill = onKill,
|
||||
thumbnails = thumbnails,
|
||||
layoutMode = layoutMode,
|
||||
onNewSessionInCwd = onNewSessionInCwd,
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// The un-pair failed and NOTHING was removed (all-or-nothing) — say so above the live list.
|
||||
if (state.hasRemoveError) RemoveErrorBanner(onDismiss = onDismissRemoveError)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
when {
|
||||
state.activeHostId == null ->
|
||||
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
|
||||
state.rows.isEmpty() ->
|
||||
EmptyState(
|
||||
message = if (state.hasLoadError) {
|
||||
"无法加载会话列表。下拉重试。"
|
||||
} else {
|
||||
"该主机没有运行中的会话。"
|
||||
},
|
||||
actionLabel = "开新会话",
|
||||
onAction = onNewSession,
|
||||
)
|
||||
else -> SessionList(
|
||||
state = state,
|
||||
onOpen = onOpen,
|
||||
onKill = onKill,
|
||||
thumbnails = thumbnails,
|
||||
layoutMode = layoutMode,
|
||||
onNewSessionInCwd = onNewSessionInCwd,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "nothing was removed" notice — the host is still paired and the action is safe to retry. */
|
||||
@Composable
|
||||
private fun RemoveErrorBanner(onDismiss: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8))
|
||||
.padding(Spacing.md12),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Text(
|
||||
text = "移除主机失败,未做任何更改。可以再试一次。",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDismiss) { Text("知道了") }
|
||||
}
|
||||
}
|
||||
|
||||
// ── The Hilt escape hatch for app-scoped collaborators (see TerminalScreen's palette store) ─────────────
|
||||
|
||||
/**
|
||||
* The app-scoped [AppEnvironment], resolved from the Hilt `SingletonComponent` — the standard escape
|
||||
* hatch for a Composable, which cannot take constructor injection. Returns `null` outside a Hilt
|
||||
* application (a `@Preview` or a plain-Compose test host), so those surfaces degrade to no durable
|
||||
* watermarks and no un-pair affordance instead of crashing.
|
||||
*
|
||||
* Resolving it is `Main`-safe: every heavy collaborator inside is behind `dagger.Lazy` and the instance
|
||||
* already exists by the time any screen composes (`MainActivity` field-injects it in `onCreate`).
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberAppEnvironment(): AppEnvironment? {
|
||||
val application = LocalContext.current.applicationContext
|
||||
return remember(application) {
|
||||
runCatching {
|
||||
EntryPointAccessors
|
||||
.fromApplication(application, AppEnvironmentEntryPoint::class.java)
|
||||
.appEnvironment()
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default `onForeground` action: run one silent certificate-rotation pass if due. A no-op outside a
|
||||
* Hilt application. Fire-and-forget by construction — [AppEnvironment.runCertificateRotationIfDue]
|
||||
* launches on its own app-scoped IO scope, so nothing here can delay the session list.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberCertificateRotationTrigger(): () -> Unit {
|
||||
val env = rememberAppEnvironment()
|
||||
return remember(env) { { env?.runCertificateRotationIfDue() } }
|
||||
}
|
||||
|
||||
/** Reads the app-scoped [AppEnvironment] for [rememberAppEnvironment]. */
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface AppEnvironmentEntryPoint {
|
||||
public fun appEnvironment(): AppEnvironment
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionList(
|
||||
state: SessionListUiState,
|
||||
|
||||
@@ -46,8 +46,11 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.components.AwayDigestView
|
||||
import wang.yaojia.webterm.components.BannerModel
|
||||
import wang.yaojia.webterm.components.DataStoreQuickReplyStore
|
||||
@@ -55,6 +58,8 @@ import wang.yaojia.webterm.components.GateBanner
|
||||
import wang.yaojia.webterm.components.HardwareKeyRouter
|
||||
import wang.yaojia.webterm.components.KeyBar
|
||||
import wang.yaojia.webterm.components.PlanGateSheet
|
||||
import wang.yaojia.webterm.components.QueueBadge
|
||||
import wang.yaojia.webterm.components.QueuePanel
|
||||
import wang.yaojia.webterm.components.QuickReply
|
||||
import wang.yaojia.webterm.components.QuickReplyPalette
|
||||
import wang.yaojia.webterm.components.QuickReplyPaletteEditor
|
||||
@@ -65,10 +70,16 @@ import wang.yaojia.webterm.designsystem.LocalReduceMotion
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||
import wang.yaojia.webterm.terminalview.RemoteTerminalView
|
||||
import wang.yaojia.webterm.terminalview.TerminalCopyResult
|
||||
import wang.yaojia.webterm.viewmodels.QueueGateway
|
||||
import wang.yaojia.webterm.viewmodels.QueueUiState
|
||||
import wang.yaojia.webterm.viewmodels.QueueViewModel
|
||||
import wang.yaojia.webterm.wire.GateKind
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wiring.RetainedSessionHolder
|
||||
import wang.yaojia.webterm.wiring.TerminalSessionControllerImpl
|
||||
import wang.yaojia.webterm.wiring.UiConfigGate
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The arguments to open a NEW session — the pure output of the "new session in current cwd" decision.
|
||||
@@ -142,6 +153,10 @@ public object NewSessionInCwd {
|
||||
* ([rememberAppQuickReplyStore] — the SAME `DataStore<Preferences>` singleton every other store uses, so
|
||||
* the palette really persists); a test/preview passes an
|
||||
* [InMemoryQuickReplyStore][wang.yaojia.webterm.components.InMemoryQuickReplyStore].
|
||||
* @param queueGateway the W2 follow-up-queue seam for this host. `null` hides the queue affordance
|
||||
* entirely (a surface with no way to reach the host cannot queue).
|
||||
* @param uiConfigGate the host's `GET /config/ui` policy. `null` leaves `allowAutoMode` at its
|
||||
* fail-closed `false`, so "approve + auto-accept" is never offered on an unknown host.
|
||||
*/
|
||||
@Composable
|
||||
public fun TerminalScreen(
|
||||
@@ -154,6 +169,8 @@ public fun TerminalScreen(
|
||||
onWarmUp: suspend () -> Unit = {},
|
||||
onDigestExpand: () -> Unit = {},
|
||||
quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(),
|
||||
queueGateway: QueueGateway? = rememberAppQueueGateway(endpoint),
|
||||
uiConfigGate: UiConfigGate? = rememberAppEnvironment()?.uiConfigGate,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = remember(context) { context.findActivity() }
|
||||
@@ -243,6 +260,36 @@ public fun TerminalScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// `GET /config/ui` → may this host be put into auto-accept-edits? Fetched once per host and pushed
|
||||
// into the gate presenter, which drops an ACCEPT_EDITS decision while it is false. FAIL CLOSED: a
|
||||
// failed/absent fetch never calls this, so the presenter keeps its `false` default and the sheet's
|
||||
// third affordance is not offered at all (see the plan-gate branch below).
|
||||
LaunchedEffect(gateViewModel, uiConfigGate, endpoint) {
|
||||
val gate = uiConfigGate ?: return@LaunchedEffect
|
||||
// Off-Main: the first touch resolves the shared mTLS client (AndroidKeyStore/Tink reads).
|
||||
val allowed = withContext(Dispatchers.IO) { gate.allowsAutoMode(endpoint) }
|
||||
if (allowed) gateViewModel.setAutoModeAllowed(true)
|
||||
}
|
||||
|
||||
// W2 follow-up queue. The presenter is composition-scoped (it holds only HTTP-derived state), while the
|
||||
// authoritative DEPTH rides the retained gate presenter's control mailbox — so a rotation cannot leak a
|
||||
// mailbox or lose the badge. A freshly spawned session has no id until the server adopts one.
|
||||
val queueVm = remember(queueGateway) { queueGateway?.let { QueueViewModel(it) } }
|
||||
// Remembered UNCONDITIONALLY (a `remember` inside an elvis branch is a conditional composable call)
|
||||
// so the stand-in for "no queue on this host" is a stable flow, not a per-recomposition instance.
|
||||
val noQueueState = remember { MutableStateFlow(QueueUiState()) }
|
||||
val queueUi by (queueVm?.uiState ?: noQueueState).collectAsStateWithLifecycle()
|
||||
val liveSessionId = remember(sessionId, gateUi.sessionId) {
|
||||
(sessionId ?: gateUi.sessionId)?.let { raw -> runCatching { UUID.fromString(raw) }.getOrNull() }
|
||||
}
|
||||
// The live `queue` frame is the authoritative depth; hand it to the panel presenter so its badge and
|
||||
// its item list can never disagree with what the server just broadcast.
|
||||
LaunchedEffect(queueVm, gateUi.queueDepth) {
|
||||
gateUi.queueDepth?.let { depth -> queueVm?.onServerDepth(depth) }
|
||||
}
|
||||
var showQueuePanel by remember { mutableStateOf(false) }
|
||||
val queueScope = rememberCoroutineScope()
|
||||
|
||||
// A25 quick-reply palette: one remembered presenter over the injected store, loaded once per store.
|
||||
val palette = remember(quickReplyStore) { QuickReplyPalette(quickReplyStore) }
|
||||
val quickReplyChips by palette.chips.collectAsStateWithLifecycle()
|
||||
@@ -260,20 +307,62 @@ public fun TerminalScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Copy-out (first-party selection layer in `:terminal-view`). The view handle is captured per
|
||||
// `generation` so a real-background rebuild never leaves a stale reference behind.
|
||||
var remoteTerminalView by remember { mutableStateOf<RemoteTerminalView?>(null) }
|
||||
var isSelecting by remember { mutableStateOf(false) }
|
||||
var copyNotice by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(copyNotice) {
|
||||
if (copyNotice != null) {
|
||||
delay(COPY_NOTICE_MS)
|
||||
copyNotice = null
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
TerminalToolbar(
|
||||
title = title,
|
||||
onNewSessionInCwd = requestNewSession,
|
||||
onEditQuickReplies = { showPaletteEditor = true },
|
||||
queueDepth = gateUi.queueDepth,
|
||||
onOpenQueue = if (queueVm != null && liveSessionId != null && queueUi.isSupported) {
|
||||
{
|
||||
showQueuePanel = true
|
||||
queueScope.launch { queueVm.refresh(liveSessionId) }
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// Shown only while a selection is live: an OEM whose floating toolbar misbehaves still has a
|
||||
// reachable Copy, and the action is ALWAYS an explicit user gesture (§8 — terminal bytes reach
|
||||
// the clipboard on no other path).
|
||||
onCopySelection = if (isSelecting) {
|
||||
{
|
||||
copyNotice = copyResultText(remoteTerminalView?.copySelection())
|
||||
remoteTerminalView?.clearSelection()
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
copyNotice?.let { notice -> InlineNotice(text = notice) }
|
||||
ReconnectBanner(model = banner, onNewSession = requestNewSession)
|
||||
// Cockpit surfaces (A22): away-digest (once per reconnect) + statusLine telemetry chips. Both hide
|
||||
// themselves when their state is null/empty; server strings render inert (§8).
|
||||
gateUi.digest?.let { AwayDigestView(digest = it, onExpand = onDigestExpand) } // onExpand → A28 timeline (wired)
|
||||
gateUi.telemetry?.let { TelemetryChips(telemetry = it, nowMs = nowMs) }
|
||||
// Tool gate → an inline approve/reject card; plan gates use the ModalBottomSheet below.
|
||||
// The W1 `status.preview` MUST be passed: without it the card is a name-only bar and the user is
|
||||
// approving a `Bash` call without seeing the command (the "blind approval" defect).
|
||||
gateUi.gate?.takeIf { it.kind == GateKind.TOOL }?.let { toolGate ->
|
||||
GateBanner(gate = toolGate, onDecide = gateViewModel::decide)
|
||||
GateBanner(gate = toolGate, onDecide = gateViewModel::decide, preview = gateUi.preview)
|
||||
}
|
||||
// A plan gate on a host that FORBIDS auto-accept-edits degrades to the same two-way card: only
|
||||
// 批准 (approve + review) and 拒绝 (keep planning) are real affordances there, so offering the
|
||||
// three-way sheet would advertise a decision the host will not honour. The authoritative drop
|
||||
// lives in `GateViewModel.decide`; this is the "don't offer it" half.
|
||||
gateUi.gate?.takeIf { it.kind == GateKind.PLAN && !gateUi.allowAutoMode }?.let { planGate ->
|
||||
GateBanner(gate = planGate, onDecide = gateViewModel::decide, preview = gateUi.preview)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
key(generation) {
|
||||
@@ -297,6 +386,12 @@ public fun TerminalScreen(
|
||||
controller.notifyForegrounded(null, null)
|
||||
}
|
||||
}
|
||||
// Copy-out: mirror the selection state up so the toolbar can offer a Copy action.
|
||||
// Stock Termux selection stays unreachable (its ActionMode dereferences the absent
|
||||
// TerminalSession) — this is the first-party layer, and nothing here calls
|
||||
// startTextSelectionMode.
|
||||
remoteView.onSelectionChanged = { selecting -> isSelecting = selecting }
|
||||
remoteTerminalView = remoteView
|
||||
controller.remoteSession.attachView(remoteView)
|
||||
// The host view IS the View to compose: :terminal-view now exposes its own
|
||||
// `RemoteTerminalHostView` (a FrameLayout that owns focus + the IME contract and
|
||||
@@ -306,7 +401,11 @@ public fun TerminalScreen(
|
||||
},
|
||||
// Config change / real background: unbind the view but the holder-owned emulator
|
||||
// survives (FIX 3) — never close it here (that is the holder teardown's job).
|
||||
onRelease = { controller.remoteSession.detachView() },
|
||||
onRelease = {
|
||||
remoteTerminalView = null
|
||||
isSelecting = false
|
||||
controller.remoteSession.detachView()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (covered) PrivacyCover()
|
||||
@@ -336,12 +435,70 @@ public fun TerminalScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Plan gate → a three-way ModalBottomSheet overlay. Dismissing (scrim/back) resolves NOTHING — the
|
||||
// gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in
|
||||
// GateViewModel.decide.
|
||||
gateUi.gate?.takeIf { it.kind == GateKind.PLAN }?.let { planGate ->
|
||||
PlanGateSheet(gate = planGate, onDecide = gateViewModel::decide, onDismiss = {})
|
||||
// Plan gate → a three-way ModalBottomSheet overlay, but ONLY on a host that permits auto-accept-edits
|
||||
// (`GET /config/ui`); otherwise the two-way card above stands in. Dismissing (scrim/back) resolves
|
||||
// NOTHING — the gate stays held until an explicit decision (PlanGateSheet contract); the epoch
|
||||
// stale-guard lives in GateViewModel.decide.
|
||||
gateUi.gate?.takeIf { it.kind == GateKind.PLAN && gateUi.allowAutoMode }?.let { planGate ->
|
||||
PlanGateSheet(
|
||||
gate = planGate,
|
||||
onDecide = gateViewModel::decide,
|
||||
onDismiss = {},
|
||||
preview = gateUi.preview,
|
||||
)
|
||||
}
|
||||
|
||||
// W2 queue panel. Every write is one attempt with a typed outcome — the presenter never auto-retries,
|
||||
// least of all a 429 (the 20/min bucket is shared with every other client of this host).
|
||||
if (showQueuePanel && queueVm != null && liveSessionId != null) {
|
||||
QueuePanel(
|
||||
state = queueUi,
|
||||
onSend = { text -> queueScope.launch { queueVm.enqueue(liveSessionId, text) } },
|
||||
onClearAll = { queueScope.launch { queueVm.clearAll(liveSessionId) } },
|
||||
onDismissNotice = queueVm::dismissNotice,
|
||||
onDismiss = { showQueuePanel = false },
|
||||
onRefresh = { queueScope.launch { queueVm.refresh(liveSessionId) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a copy-outcome notice stays on screen. */
|
||||
private const val COPY_NOTICE_MS: Long = 2_000L
|
||||
|
||||
/**
|
||||
* App-authored copy for a [TerminalCopyResult]. The copied TEXT is never rendered or logged (§8) — only
|
||||
* whether the copy happened.
|
||||
*/
|
||||
internal fun copyResultText(result: TerminalCopyResult?): String = when (result) {
|
||||
TerminalCopyResult.COPIED -> "已复制到剪贴板"
|
||||
TerminalCopyResult.NOTHING_SELECTED -> "没有选中内容"
|
||||
TerminalCopyResult.NOTHING_TO_COPY -> "选中的区域是空白"
|
||||
TerminalCopyResult.CLIPBOARD_UNAVAILABLE -> "系统剪贴板拒绝了这次复制"
|
||||
null -> "终端还没准备好"
|
||||
}
|
||||
|
||||
/** A short-lived inert status line (copy outcome). Fixed app copy — never a server or exception string. */
|
||||
@Composable
|
||||
private fun InlineNotice(text: String) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The W2 queue seam for [endpoint], resolved from the Hilt graph (a Composable cannot be
|
||||
* constructor-injected). `null` outside a Hilt application, which correctly hides the affordance in a
|
||||
* `@Preview`. Remembered per endpoint so a recomposition does not mint a new presenter.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberAppQueueGateway(endpoint: HostEndpoint): QueueGateway? {
|
||||
val env = rememberAppEnvironment()
|
||||
return remember(env, endpoint) { env?.buildQueueGateway(endpoint) }
|
||||
}
|
||||
|
||||
/** Telemetry-chip staleness tick interval (ms) — re-reads the wall clock so chips grey as a frame ages. */
|
||||
@@ -358,6 +515,9 @@ private fun TerminalToolbar(
|
||||
title: String,
|
||||
onNewSessionInCwd: () -> Unit,
|
||||
onEditQuickReplies: () -> Unit,
|
||||
queueDepth: Int? = null,
|
||||
onOpenQueue: (() -> Unit)? = null,
|
||||
onCopySelection: (() -> Unit)? = null,
|
||||
) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
@@ -373,6 +533,14 @@ private fun TerminalToolbar(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Only while text is selected — the clipboard write is always an explicit gesture (§8).
|
||||
onCopySelection?.let { copy -> TextButton(onClick = copy) { Text("复制") } }
|
||||
onOpenQueue?.let { open ->
|
||||
TextButton(onClick = open) {
|
||||
Text("排队")
|
||||
QueueBadge(depth = queueDepth, modifier = Modifier.padding(start = Spacing.xs4))
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onEditQuickReplies) { Text("快捷回复") }
|
||||
TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") }
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.session.Adopted
|
||||
import wang.yaojia.webterm.session.AwayDigest
|
||||
import wang.yaojia.webterm.session.Digest
|
||||
import wang.yaojia.webterm.session.Exited
|
||||
import wang.yaojia.webterm.session.Gate
|
||||
import wang.yaojia.webterm.session.GateState
|
||||
import wang.yaojia.webterm.session.Queued
|
||||
import wang.yaojia.webterm.session.SessionEvent
|
||||
import wang.yaojia.webterm.session.Telemetry
|
||||
import wang.yaojia.webterm.wire.ApprovalPreview
|
||||
@@ -59,6 +61,19 @@ import wang.yaojia.webterm.wiring.TerminalSessionController
|
||||
* mailbox that is INDEPENDENT of the banner's (and the A28 timeline's). Every consumer sees every
|
||||
* control event; none steals from another. Capturing at construction registers the mailbox eagerly,
|
||||
* before the holder calls `controller.start()`, so the first gate is never dropped.
|
||||
*
|
||||
* ### `allowAutoMode` fails CLOSED (`GET /config/ui`)
|
||||
* `approve.mode="acceptEdits"` puts Claude into auto-accept-edits, which an operator can switch off
|
||||
* host-side (`ALLOW_AUTO_MODE=0` → `GET /config/ui` `{allowAutoMode:false}`). [decide] therefore drops
|
||||
* [GateDecision.ACCEPT_EDITS] unless [setAutoModeAllowed] has been told the host permits it — the
|
||||
* default is `false`, so a host that is merely unreachable can never *widen* what the phone may approve.
|
||||
* This is the authoritative half; the surface additionally stops OFFERING the affordance.
|
||||
*
|
||||
* ### It also carries the two cockpit signals nothing else owns
|
||||
* The `queue` frame ([Queued]) and the adopted session id ([Adopted]) arrive on this same control
|
||||
* mailbox and used to be discarded here. They live in [GateUiState] because this VM is the retained,
|
||||
* rotation-surviving cockpit consumer (`RetainedSessionHolder`): a composition-scoped collector would
|
||||
* both lose them on rotation and leak a fresh orphaned mailbox each time.
|
||||
*/
|
||||
public class GateViewModel(
|
||||
private val controller: TerminalSessionController,
|
||||
@@ -106,12 +121,25 @@ public class GateViewModel(
|
||||
is Gate -> onGate(event.gate)
|
||||
is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry)
|
||||
is Digest -> onDigest(event.digest)
|
||||
// The session ended: a held gate is no longer decidable — clear it so no stale card lingers.
|
||||
is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null)
|
||||
else -> Unit // Output / Connection / Adopted are not cockpit concerns.
|
||||
// W2: the server's authoritative pending-prompt depth (never a local count).
|
||||
is Queued -> _uiState.value = _uiState.value.copy(queueDepth = event.length.coerceAtLeast(0))
|
||||
// The server-issued id — the only way a freshly spawned session learns what to queue against.
|
||||
is Adopted -> _uiState.value = _uiState.value.copy(sessionId = event.sessionId)
|
||||
// The session ended: a held gate is no longer decidable and there is no queue to show.
|
||||
is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null, queueDepth = null)
|
||||
else -> Unit // Output / Connection are not cockpit concerns.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record whether this host permits `approve.mode="acceptEdits"` (`GET /config/ui`
|
||||
* `{allowAutoMode}`). Called by the screen after a SUCCESSFUL fetch only — a failed fetch must leave
|
||||
* the fail-closed `false` in place, because the permissive default is the dangerous one.
|
||||
*/
|
||||
public fun setAutoModeAllowed(allowed: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(allowAutoMode = allowed)
|
||||
}
|
||||
|
||||
private fun onGate(gate: GateState?) {
|
||||
// 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.
|
||||
@@ -136,8 +164,12 @@ public class GateViewModel(
|
||||
* an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message.
|
||||
*/
|
||||
public fun decide(decision: GateDecision, epoch: Int) {
|
||||
val held = _uiState.value.gate ?: return // canDecide line 1: no gate held → drop.
|
||||
if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop.
|
||||
val state = _uiState.value
|
||||
val held = state.gate ?: return // canDecide line 1: no gate held → drop.
|
||||
if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop.
|
||||
// The host's own policy outranks the phone: auto-accept-edits is refused unless a successful
|
||||
// `GET /config/ui` said it is allowed (fail closed — see the class doc).
|
||||
if (decision == GateDecision.ACCEPT_EDITS && !state.allowAutoMode) return
|
||||
val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop.
|
||||
controller.decideGate(epoch, message)
|
||||
}
|
||||
@@ -182,6 +214,22 @@ public data class GateUiState(
|
||||
val telemetry: StatusTelemetry? = null,
|
||||
/** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */
|
||||
val digest: AwayDigest? = null,
|
||||
/**
|
||||
* W2 pending prompt-queue depth from the live `queue` frame — `null` = unknown (no frame yet, or the
|
||||
* session exited), `0` = empty. The badge renders only for a positive value.
|
||||
*/
|
||||
val queueDepth: Int? = null,
|
||||
/**
|
||||
* The SERVER-issued session id (`attached`/`Adopted`), or `null` before the first attach. The queue
|
||||
* routes are addressed by it, so a freshly spawned session (opened with a null id) can only be
|
||||
* queued to once this arrives.
|
||||
*/
|
||||
val sessionId: String? = null,
|
||||
/**
|
||||
* Does this host permit `approve.mode="acceptEdits"` (`GET /config/ui` `{allowAutoMode}`)?
|
||||
* **Defaults to `false` and is only ever raised by a SUCCESSFUL fetch** — fail closed.
|
||||
*/
|
||||
val allowAutoMode: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.api.models.QueueSnapshot
|
||||
import wang.yaojia.webterm.api.models.QueueWriteOutcome
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* # QueueViewModel (W2) — the follow-up prompt queue presenter.
|
||||
*
|
||||
* Drives the three server routes (`POST` / `GET` / `DELETE /live-sessions/:id/queue`) behind the
|
||||
* [QueueGateway] seam: queue a prompt Claude will be handed the next time it goes idle, review what is
|
||||
* pending, and cancel everything. Ports the web client's `public/queue.ts` affordance.
|
||||
*
|
||||
* ### The queued text is raw shell input (invariant #9 / byte-shuttle)
|
||||
* [enqueue] passes [text] to the gateway **verbatim** — no trim, no normalisation, no case folding, and
|
||||
* **never a client-side `\r`**. `appendEnter` stays a FLAG so the SERVER materialises the carriage
|
||||
* return, byte-identical to a keystroke. The only refusal is a genuinely EMPTY string (mirroring
|
||||
* [ApiClientError.QueueTextEmpty], raised before any I/O); a whitespace-only prompt is legitimate shell
|
||||
* input and is sent as-is.
|
||||
*
|
||||
* ### The depth is always the server's number
|
||||
* [QueueUiState.depth] is only ever assigned from an authoritative source — a write's
|
||||
* [QueueWriteOutcome.Ok.length], a [refresh] snapshot, or the live `queue` WS frame ([onServerDepth]).
|
||||
* Nothing here increments a local counter, so the badge can never drift from the FIFO the server holds.
|
||||
*
|
||||
* ### Failure honesty (no auto-retry, ever)
|
||||
* Each [QueueWriteOutcome] maps to its own [QueueNotice] and the pass STOPS there:
|
||||
* - [QueueNotice.RateLimited] (429, the shared 20/min/IP bucket) is **shown, never retried** — a retry
|
||||
* loop only burns the budget a real enqueue needs;
|
||||
* - [QueueNotice.Disabled] (503, `QUEUE_ENABLED=0`) additionally clears [QueueUiState.isSupported], so
|
||||
* the affordance disappears for this host instead of failing over and over;
|
||||
* - [QueueNotice.Full] / [QueueNotice.TooLarge] / [QueueNotice.SessionGone] are actionable states, and
|
||||
* [QueueNotice.Rejected] carries the server's own already-sanitized `error` string, rendered INERT.
|
||||
* A transport throw becomes [QueueNotice.Unreachable] and the last-good queue is kept — a network blip
|
||||
* must not make the panel claim the queue is empty.
|
||||
*
|
||||
* ### Testability
|
||||
* A plain presenter (not an `androidx.lifecycle.ViewModel`) so every branch runs under `runTest` with no
|
||||
* `Dispatchers.Main`/Robolectric. Its Compose surface ([wang.yaojia.webterm.components.QueuePanel]) is
|
||||
* device-QA (plan §7).
|
||||
*/
|
||||
public class QueueViewModel(private val gateway: QueueGateway) {
|
||||
|
||||
private val _uiState = MutableStateFlow(QueueUiState())
|
||||
|
||||
/** The single snapshot the queue panel + depth badge render from. */
|
||||
public val uiState: StateFlow<QueueUiState> = _uiState.asStateFlow()
|
||||
|
||||
/**
|
||||
* `GET /live-sessions/:id/queue` — the pending depth plus the queued prompts (verbatim), for the
|
||||
* "review before cancelling" panel. Read-only and safe to call on every panel open.
|
||||
*/
|
||||
public suspend fun refresh(sessionId: UUID) {
|
||||
_uiState.update { it.copy(isBusy = true) }
|
||||
val snapshot = try {
|
||||
gateway.snapshot(sessionId)
|
||||
} catch (cancel: CancellationException) {
|
||||
_uiState.update { it.copy(isBusy = false) }
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
_uiState.update { it.copy(isBusy = false, notice = noticeFor(error)) }
|
||||
return
|
||||
}
|
||||
publish(snapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /live-sessions/:id/queue` — queue [text] VERBATIM. An empty [text] is refused locally (no
|
||||
* network I/O), matching the server's own 400 and [ApiClientError.QueueTextEmpty]; the size cap is
|
||||
* deliberately NOT pre-checked here (it is server config the client cannot know) and surfaces as
|
||||
* [QueueNotice.TooLarge].
|
||||
*
|
||||
* @param appendEnter leave `true` so the SERVER appends the `\r`; never pre-append one.
|
||||
*/
|
||||
public suspend fun enqueue(sessionId: UUID, text: String, appendEnter: Boolean = true) {
|
||||
if (!canSend(text)) return
|
||||
write { gateway.enqueue(sessionId, text, appendEnter) }
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /live-sessions/:id/queue` — cancel EVERY pending entry. There is no per-item cancel on the
|
||||
* server, so the panel must not pretend to offer one.
|
||||
*/
|
||||
public suspend fun clearAll(sessionId: UUID) {
|
||||
write { gateway.clear(sessionId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The live `queue` WS frame ([wang.yaojia.webterm.session.Queued]) — the server's authoritative
|
||||
* depth. A positive depth that no longer matches the cached [QueueUiState.items] marks them STALE
|
||||
* (the panel then re-reads rather than showing a list that contradicts the badge); a zero depth is
|
||||
* unambiguous and empties the list outright.
|
||||
*/
|
||||
public fun onServerDepth(length: Int) {
|
||||
val depth = length.coerceAtLeast(0)
|
||||
_uiState.update { state ->
|
||||
if (depth == 0) {
|
||||
state.copy(depth = 0, items = emptyList(), isItemsStale = false)
|
||||
} else {
|
||||
state.copy(depth = depth, isItemsStale = depth != state.items.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the current [QueueNotice]; the queue contents and depth are untouched. */
|
||||
public fun dismissNotice() {
|
||||
_uiState.update { it.copy(notice = null) }
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Shared body of the two guarded writes: busy flag → one attempt → typed outcome → NO retry. */
|
||||
private suspend fun write(attempt: suspend () -> QueueWriteOutcome) {
|
||||
_uiState.update { it.copy(isBusy = true, notice = null) }
|
||||
val outcome = try {
|
||||
attempt()
|
||||
} catch (cancel: CancellationException) {
|
||||
_uiState.update { it.copy(isBusy = false) }
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
_uiState.update { it.copy(isBusy = false, notice = noticeFor(error)) }
|
||||
return
|
||||
}
|
||||
apply(outcome)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one write outcome into the state. On success the depth is the server's number and the cached
|
||||
* item list is re-read lazily (marked stale unless the queue is now empty).
|
||||
*/
|
||||
private fun apply(outcome: QueueWriteOutcome) {
|
||||
when (outcome) {
|
||||
is QueueWriteOutcome.Ok -> {
|
||||
onServerDepth(outcome.length)
|
||||
_uiState.update { it.copy(isBusy = false, notice = null) }
|
||||
}
|
||||
|
||||
QueueWriteOutcome.Full -> fail(QueueNotice.Full)
|
||||
QueueWriteOutcome.TooLarge -> fail(QueueNotice.TooLarge)
|
||||
QueueWriteOutcome.SessionGone -> fail(QueueNotice.SessionGone)
|
||||
QueueWriteOutcome.RateLimited -> fail(QueueNotice.RateLimited)
|
||||
QueueWriteOutcome.Disabled -> {
|
||||
// A capability answer, not a transient failure: stop offering the affordance on this host.
|
||||
_uiState.update { it.copy(isBusy = false, isSupported = false, notice = QueueNotice.Disabled) }
|
||||
}
|
||||
|
||||
is QueueWriteOutcome.Rejected -> fail(QueueNotice.Rejected(outcome.message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(notice: QueueNotice) {
|
||||
_uiState.update { it.copy(isBusy = false, notice = notice) }
|
||||
}
|
||||
|
||||
private fun publish(snapshot: QueueSnapshot) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
depth = snapshot.length.coerceAtLeast(0),
|
||||
items = snapshot.items,
|
||||
isBusy = false,
|
||||
isItemsStale = false,
|
||||
notice = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A thrown failure → its notice. A 404 is a real state; everything else is "could not reach". */
|
||||
private fun noticeFor(error: Throwable): QueueNotice = when (error) {
|
||||
is ApiClientError.SessionNotFound -> QueueNotice.SessionGone
|
||||
else -> QueueNotice.Unreachable
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* May [text] be sent? Only EMPTY is refused — whitespace is legitimate keyboard input for a
|
||||
* shell, so trimming here would silently change what the user typed (invariant #9).
|
||||
*/
|
||||
public fun canSend(text: String): Boolean = text.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** The queue panel + badge snapshot. [items] is empty when nothing is queued OR nothing was read yet. */
|
||||
public data class QueueUiState(
|
||||
/** The server's pending depth — the "N queued" badge number. Never locally incremented. */
|
||||
val depth: Int = 0,
|
||||
/** The queued prompts in FIFO order, VERBATIM (rendered inert — untrusted round-tripped text). */
|
||||
val items: List<String> = emptyList(),
|
||||
/** A write/read is in flight (the send + cancel buttons disable). */
|
||||
val isBusy: Boolean = false,
|
||||
/** False after a 503 — the host has `QUEUE_ENABLED=0`, so hide the affordance entirely. */
|
||||
val isSupported: Boolean = true,
|
||||
/** [items] no longer matches [depth] (a live frame moved the queue) — re-read before trusting it. */
|
||||
val isItemsStale: Boolean = false,
|
||||
/** The last failure to report, or null. */
|
||||
val notice: QueueNotice? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* What went wrong on the last queue operation. Typed (not a status code) because each one demands
|
||||
* different UI behaviour; [Rejected] carries the server's own sanitized `error` string, which must be
|
||||
* rendered INERT (plain text, no linkify/markdown — plan §8).
|
||||
*/
|
||||
public sealed interface QueueNotice {
|
||||
/** 409 — the bounded FIFO is at `QUEUE_MAX_ITEMS`; cancel something first. */
|
||||
public data object Full : QueueNotice
|
||||
|
||||
/** 413 — over the server's per-item byte cap. */
|
||||
public data object TooLarge : QueueNotice
|
||||
|
||||
/** 503 — the queue is switched off on this host. */
|
||||
public data object Disabled : QueueNotice
|
||||
|
||||
/** 404 — the session exited or was killed. */
|
||||
public data object SessionGone : QueueNotice
|
||||
|
||||
/** 429 — the shared 20/min/IP bucket. Shown and NEVER auto-retried. */
|
||||
public data object RateLimited : QueueNotice
|
||||
|
||||
/** A transport failure / unparseable body — the queue state is unknown, the last-good view is kept. */
|
||||
public data object Unreachable : QueueNotice
|
||||
|
||||
/** Any other 4xx/5xx, with the server's inert message (null when the body was empty). */
|
||||
public data class Rejected(val message: String?) : QueueNotice
|
||||
}
|
||||
|
||||
/**
|
||||
* The three queue routes as a seam, so [QueueViewModel] is JVM-testable with no transport. Production is
|
||||
* [ApiClientQueueGateway] over the per-host [ApiClient]; tests script outcomes.
|
||||
*/
|
||||
public interface QueueGateway {
|
||||
/** `GET /live-sessions/:id/queue` — depth + the queued prompts. Throws for 404 / a garbled body. */
|
||||
public suspend fun snapshot(id: UUID): QueueSnapshot
|
||||
|
||||
/** `POST /live-sessions/:id/queue` — [text] passed through VERBATIM. */
|
||||
public suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome
|
||||
|
||||
/** `DELETE /live-sessions/:id/queue` — cancel-all (there is no per-item cancel). */
|
||||
public suspend fun clear(id: UUID): QueueWriteOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Production [QueueGateway] over one host's [ApiClient] (which stamps `Origin` on the two writes).
|
||||
*
|
||||
* [api] is a SUPPLIER and every call hops to [ioDispatcher], for the reason `ApiClientFactory` documents:
|
||||
* resolving the shared `HttpTransport` builds the mTLS `OkHttpClient` (AndroidKeyStore + Tink reads), and
|
||||
* this gateway is driven from a Composable's `rememberCoroutineScope()` — i.e. from `Main`. Building the
|
||||
* gateway therefore stays free, and no keystore work can land on the UI thread.
|
||||
*/
|
||||
public class ApiClientQueueGateway(
|
||||
private val api: () -> ApiClient,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : QueueGateway {
|
||||
override suspend fun snapshot(id: UUID): QueueSnapshot = withContext(ioDispatcher) { api().queue(id) }
|
||||
|
||||
override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome =
|
||||
withContext(ioDispatcher) { api().enqueueFollowup(id, text, appendEnter) }
|
||||
|
||||
override suspend fun clear(id: UUID): QueueWriteOutcome = withContext(ioDispatcher) { api().clearQueue(id) }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
import wang.yaojia.webterm.designsystem.DisplayStatus
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
|
||||
import wang.yaojia.webterm.session.TitleSanitizer
|
||||
import wang.yaojia.webterm.session.UnreadLedger
|
||||
import wang.yaojia.webterm.wire.Tunables
|
||||
@@ -25,9 +26,16 @@ import kotlin.time.Duration
|
||||
*
|
||||
* Folds `GET /live-sessions` for the ACTIVE host into a single [collectAsStateWithLifecycle-friendly]
|
||||
* [uiState] snapshot of [SessionRow]s (status badge · telemetry · thumbnail key · cols×rows · sanitized
|
||||
* title · unread dot), and owns the four list actions: STARTED-scoped [poll], [refresh]
|
||||
* (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open) and [killSession]
|
||||
* (optimistic swipe-to-kill). Ports the iOS session-list surface.
|
||||
* title · unread dot · W2 queue depth), and owns the list actions: STARTED-scoped [poll], [refresh]
|
||||
* (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open), [killSession]
|
||||
* (optimistic swipe-to-kill) and [removeActiveHost] (the confirm-gated un-pair). Ports the iOS
|
||||
* session-list surface.
|
||||
*
|
||||
* ### The unread dot is DURABLE
|
||||
* Watermarks live behind [UnreadWatermarkStore]; production binds [PersistedUnreadWatermarkStore] over
|
||||
* `:host-registry`'s DataStore store, so the dot survives process death — exactly when it matters, the
|
||||
* product premise being "walk away and come back". The reducer stays in `UnreadLedger` (`:session-core`):
|
||||
* this VM only decides WHERE a watermark lives, never how one is computed.
|
||||
*
|
||||
* ### Untrusted server boundary (plan §4 / §8)
|
||||
* The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` →
|
||||
@@ -45,14 +53,16 @@ import kotlin.time.Duration
|
||||
* ### Testability
|
||||
* A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time with
|
||||
* no `Dispatchers.Main`/Robolectric. Its collaborators are seams ([SessionListGatewayFactory],
|
||||
* [UnreadWatermarkStore]) so the fetch→row mapping, unread-dot logic and optimistic-kill path are all
|
||||
* JVM-tested; swipe/thumbnail/pull gestures are device-QA (plan §7).
|
||||
* [UnreadWatermarkStore], [HostRemovalGateway]) so the fetch→row mapping, unread-dot logic,
|
||||
* optimistic-kill path and the all-or-nothing un-pair are all JVM-tested; swipe/thumbnail/pull gestures
|
||||
* are device-QA (plan §7).
|
||||
*/
|
||||
public class SessionListViewModel(
|
||||
private val hostStore: HostStore,
|
||||
private val gatewayFactory: SessionListGatewayFactory,
|
||||
private val watermarkStore: UnreadWatermarkStore = InMemoryUnreadWatermarkStore(),
|
||||
watermarkStore: UnreadWatermarkStore? = null,
|
||||
private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL,
|
||||
removalGateway: HostRemovalGateway? = null,
|
||||
) {
|
||||
private val _uiState = MutableStateFlow(SessionListUiState())
|
||||
|
||||
@@ -68,10 +78,59 @@ public class SessionListViewModel(
|
||||
/** The active host id; sticky across polls, defaults to the first host until the user switches. */
|
||||
private var activeHostId: String? = null
|
||||
|
||||
/** Local unread watermarks (persisted through [watermarkStore]); loaded once, lazily. */
|
||||
/** Local unread watermarks (persisted through [watermarks]); loaded once, lazily. */
|
||||
private var ledger: UnreadLedger = UnreadLedger()
|
||||
private var ledgerLoaded = false
|
||||
|
||||
/**
|
||||
* Where the watermarks live. Defaults to memory-only so a test/preview needs no storage; production
|
||||
* installs the DataStore-backed [PersistedUnreadWatermarkStore] via [useWatermarkStore].
|
||||
*/
|
||||
private var watermarks: UnreadWatermarkStore = watermarkStore ?: InMemoryUnreadWatermarkStore()
|
||||
|
||||
/** True when the store came from the constructor — [useWatermarkStore] must never override it. */
|
||||
private var watermarkStoreInstalled: Boolean = watermarkStore != null
|
||||
|
||||
/** Un-pairing collaborator, installable post-construction for the same reason as [watermarks]. */
|
||||
private var removal: HostRemovalGateway? = removalGateway
|
||||
private var removalInstalled: Boolean = removalGateway != null
|
||||
|
||||
// ── Why the two collaborators above are installable AFTER construction ───────────────────────
|
||||
// Both are app-scoped Hilt singletons, but this VM is constructed by the nav layer, which does not
|
||||
// reach the Hilt graph; the SCREEN does (`SessionListScreen` resolves them through a Hilt
|
||||
// `@EntryPoint` — the same escape hatch `TerminalScreen` already uses for its palette store).
|
||||
// Constructor injection remains the preferred wiring and always WINS: passing either one makes the
|
||||
// matching installer a no-op, so a test (or a future nav-side wiring) is never overridden.
|
||||
|
||||
/**
|
||||
* Install the DURABLE watermark store, ONCE, before the ledger is first read.
|
||||
*
|
||||
* Refused (returning false, changing nothing) when a store is already installed OR the ledger has
|
||||
* already been loaded — swapping stores mid-flight could resurrect watermarks the live ledger already
|
||||
* dropped, or silently discard a `markSeen` that was persisted elsewhere.
|
||||
*
|
||||
* @return true when [store] became this VM's watermark home.
|
||||
*/
|
||||
public fun useWatermarkStore(store: UnreadWatermarkStore): Boolean {
|
||||
if (watermarkStoreInstalled || ledgerLoaded) return false
|
||||
watermarks = store
|
||||
watermarkStoreInstalled = true
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the host-removal collaborator, ONCE. Without it [removeActiveHost] is a no-op (which is the
|
||||
* correct behaviour for a preview/test that has no way to un-pair anything).
|
||||
*
|
||||
* @return true when [gateway] became this VM's removal path.
|
||||
*/
|
||||
public fun useRemovalGateway(gateway: HostRemovalGateway): Boolean {
|
||||
if (removalInstalled) return false
|
||||
removal = gateway
|
||||
removalInstalled = true
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* STARTED-scoped poll loop (plan §6.6): fetch the active host's list, wait [pollInterval], repeat.
|
||||
* Cancellation (app backgrounded) stops it cleanly. Run inside `repeatOnLifecycle(STARTED)`.
|
||||
@@ -107,7 +166,7 @@ public class SessionListViewModel(
|
||||
val row = _uiState.value.rows.firstOrNull { it.id == sessionId } ?: return
|
||||
val at = row.info.lastOutputAt ?: return
|
||||
ledger = ledger.record(sessionId.toString(), at)
|
||||
runCatching { watermarkStore.save(ledger) }
|
||||
runCatching { watermarks.save(ledger) }
|
||||
_uiState.update { it.copy(rows = it.rows.map { r -> r.withUnread(ledger) }) }
|
||||
}
|
||||
|
||||
@@ -134,6 +193,46 @@ public class SessionListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-pair the ACTIVE host: hand it (and the session ids currently listed for it) to the
|
||||
* [HostRemovalGateway], which is the ONE place that erases every trace keyed off that host —
|
||||
* the push registration on the host itself (`DELETE /push/fcm-token`, otherwise the phone keeps
|
||||
* receiving notifications for a host the user dropped), the paired record, the last-session pointer,
|
||||
* the `webterm_auth` cookie, and those sessions' unread watermarks.
|
||||
*
|
||||
* Only the ACTIVE host is removable, because the live session ids — and therefore the watermarks to
|
||||
* drop — are only known for the host currently listed. Switching first is one tap.
|
||||
*
|
||||
* ALL-OR-NOTHING at the UI level: a failure leaves the host exactly where it was and raises
|
||||
* [SessionListUiState.hasRemoveError], because a half-removed host is worse than none. On success the
|
||||
* list re-fetches, so the active host re-resolves to a survivor (or the empty pair-a-host state).
|
||||
*/
|
||||
public suspend fun removeActiveHost() {
|
||||
val gateway = removal ?: return
|
||||
val hostId = activeHostId ?: return
|
||||
if (!hostsById.containsKey(hostId)) return
|
||||
val sessionIds = _uiState.value.rows.map { it.id.toString() }
|
||||
|
||||
try {
|
||||
gateway.removeHost(hostId, sessionIds)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Throwable) {
|
||||
_uiState.update { it.copy(hasRemoveError = true) }
|
||||
return
|
||||
}
|
||||
// Forget the pointer so the next fetch resolves the first SURVIVING host instead of re-selecting
|
||||
// a host that no longer exists.
|
||||
activeHostId = null
|
||||
_uiState.update { it.copy(hasRemoveError = false) }
|
||||
fetch(markRefreshing = true)
|
||||
}
|
||||
|
||||
/** Dismiss the removal-failure banner (the host is still paired; nothing else changed). */
|
||||
public fun dismissRemoveError() {
|
||||
_uiState.update { it.copy(hasRemoveError = false) }
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun fetch(markRefreshing: Boolean) {
|
||||
@@ -202,7 +301,7 @@ public class SessionListViewModel(
|
||||
|
||||
private suspend fun ensureLedgerLoaded() {
|
||||
if (ledgerLoaded) return
|
||||
ledger = runCatching { watermarkStore.load() }.getOrDefault(UnreadLedger())
|
||||
ledger = runCatching { watermarks.load() }.getOrDefault(UnreadLedger())
|
||||
ledgerLoaded = true
|
||||
}
|
||||
}
|
||||
@@ -234,6 +333,11 @@ public data class SessionListUiState(
|
||||
val isRefreshing: Boolean = false,
|
||||
/** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */
|
||||
val hasLoadError: Boolean = false,
|
||||
/**
|
||||
* The last host removal failed, so the host is STILL paired (all-or-nothing). The screen shows an
|
||||
* inline notice; nothing was partially erased.
|
||||
*/
|
||||
val hasRemoveError: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -299,7 +403,7 @@ public interface UnreadWatermarkStore {
|
||||
public suspend fun save(ledger: UnreadLedger)
|
||||
}
|
||||
|
||||
/** In-memory [UnreadWatermarkStore] (default). Watermarks live for the process only until DataStore lands. */
|
||||
/** In-memory [UnreadWatermarkStore] — the test/preview double. Watermarks live for the process only. */
|
||||
public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()) : UnreadWatermarkStore {
|
||||
@Volatile private var current: UnreadLedger = initial
|
||||
override suspend fun load(): UnreadLedger = current
|
||||
@@ -307,3 +411,46 @@ public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()
|
||||
current = ledger
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The PRODUCTION [UnreadWatermarkStore]: the ledger-typed seam bridged onto `:host-registry`'s
|
||||
* DataStore-backed [SessionWatermarkStore], so the unread dot survives process death (the whole point —
|
||||
* the product premise is walking away and coming back).
|
||||
*
|
||||
* The two halves are deliberately typed differently and this adapter is the ONLY join:
|
||||
* - [UnreadLedger] (`:session-core`) owns the *reducer* — the monotonic `record`, the `isUnread`
|
||||
* comparison, the tie-broken cap;
|
||||
* - [SessionWatermarkStore] (`:host-registry`) owns *storage* — validation of ids at rest, the growth
|
||||
* cap, the JSON blob.
|
||||
* `:host-registry` may not depend on `:session-core` (nothing points sideways), so neither restates the
|
||||
* other; `:app` is the one module that sees both. Nothing about the reducer is reimplemented here.
|
||||
*
|
||||
* The store applies its own sanitize+cap pass, so [load] can legitimately return FEWER watermarks than
|
||||
* [save] was given (an invalid id at rest is dropped). That is the correct behaviour, not a bug: the
|
||||
* ledger simply treats a dropped session as never-seen.
|
||||
*/
|
||||
public class PersistedUnreadWatermarkStore(
|
||||
private val store: SessionWatermarkStore,
|
||||
) : UnreadWatermarkStore {
|
||||
override suspend fun load(): UnreadLedger = UnreadLedger(store.loadAll())
|
||||
|
||||
override suspend fun save(ledger: UnreadLedger) {
|
||||
store.replaceAll(ledger.watermarks)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-pairing a host, as ONE operation (plan §8 — a half-removed host is worse than none).
|
||||
*
|
||||
* A single seam rather than five calls from the ViewModel, because the steps span four modules
|
||||
* (`:api-client` push DELETE, `:host-registry` host/last-session/cookie/watermark stores,
|
||||
* `:transport-okhttp`'s live cookie jar) and the VM must stay JVM-testable with no transport.
|
||||
* Production is `HostRemover` in the composition root.
|
||||
*
|
||||
* @param sessionIds the host's currently-known live session ids, so their unread watermarks are dropped
|
||||
* too (watermarks are keyed by SESSION, not host — see `HostRemover`).
|
||||
*/
|
||||
public fun interface HostRemovalGateway {
|
||||
/** Erase every trace of [hostId]. Throws if anything essential failed (the UI then keeps the host). */
|
||||
public suspend fun removeHost(hostId: String, sessionIds: List<String>)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import android.util.Log
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.api.models.UiConfig
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.runPairingProbe
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.transport.AuthCookieJar
|
||||
import wang.yaojia.webterm.transport.AuthCookieSnapshot
|
||||
import wang.yaojia.webterm.transport.authCookieHostKey
|
||||
import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway
|
||||
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
|
||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
||||
import wang.yaojia.webterm.viewmodels.PersistedUnreadWatermarkStore
|
||||
import wang.yaojia.webterm.viewmodels.QueueGateway
|
||||
import wang.yaojia.webterm.viewmodels.TokenSubmission
|
||||
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
@@ -53,6 +70,10 @@ import javax.inject.Singleton
|
||||
* transports (lazy).
|
||||
* - [coldStartPolicy] — the cold-launch route seam (A29).
|
||||
* - the `webterm_auth` cookie jar + its durable store — the optional `WEBTERM_TOKEN` gate.
|
||||
* - [unreadWatermarkStore] — the DURABLE unread-dot watermarks (was memory-only; reset on process death).
|
||||
* - [uiConfigGate] — the host's `allowAutoMode` policy, fail-closed (`GET /config/ui`).
|
||||
* - [hostRemoval] — un-pair a host and erase every trace keyed off it, push registration included.
|
||||
* - [runCertificateRotationIfDue] / [rotationOutcomes] — silent device-certificate rotation.
|
||||
* Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its
|
||||
* [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder].
|
||||
*/
|
||||
@@ -81,6 +102,20 @@ public class AppEnvironment @Inject constructor(
|
||||
* under JVM unit tests).
|
||||
*/
|
||||
private val thumbnailRasterizerLazy: Lazy<ThumbnailRasterizer>,
|
||||
/**
|
||||
* The durable, DataStore-backed home of the unread watermarks (`di/StorageModule`). Exposed to the
|
||||
* session list through [unreadWatermarkStore]; without it the unread dot resets on process death.
|
||||
*/
|
||||
private val sessionWatermarkStore: SessionWatermarkStore,
|
||||
/**
|
||||
* Silent device-certificate rotation (`di/TlsModule`). `Lazy` for the same reason as the transports:
|
||||
* resolving it must not pull AndroidKeyStore/Tink work onto the injection (often `Main`) thread.
|
||||
*/
|
||||
private val rotationSchedulerLazy: Lazy<CertificateRotationScheduler>,
|
||||
/** Per-host push registration — the `DELETE /push/fcm-token` half is what [removeHost] finally calls. */
|
||||
private val pushUnregister: HostPushUnregister,
|
||||
/** Resolves this device's current FCM token, or null when push is unavailable/uninitialised. */
|
||||
private val pushTokenSource: PushTokenSource,
|
||||
) {
|
||||
/** One-shot cold-start restore of the persisted session cookie into the live jar (see [warmUp]). */
|
||||
private val authCookieHydrator = AuthCookieHydrator(
|
||||
@@ -142,6 +177,94 @@ public class AppEnvironment @Inject constructor(
|
||||
rasterizer = thumbnailRasterizerLazy.get(),
|
||||
)
|
||||
|
||||
// ── Unread watermarks (durable) ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The PRODUCTION unread-watermark store: DataStore-backed, so the session-list dot survives process
|
||||
* death. The session list installs it via `SessionListViewModel.useWatermarkStore`.
|
||||
*/
|
||||
public val unreadWatermarkStore: UnreadWatermarkStore =
|
||||
PersistedUnreadWatermarkStore(sessionWatermarkStore)
|
||||
|
||||
// ── W2 prompt queue ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The follow-up-queue seam for one host (`POST|GET|DELETE /live-sessions/:id/queue`). Per-host
|
||||
* because the two writes are `Origin`-guarded and the header is derived from the endpoint.
|
||||
*
|
||||
* Cheap + `Main`-safe to build: the `ApiClient` is minted by a SUPPLIER inside the gateway's own
|
||||
* `Dispatchers.IO` hop, so resolving the shared mTLS `OkHttpClient` never happens on the UI thread
|
||||
* (the same discipline `sessionThumbnailPipeline` follows).
|
||||
*/
|
||||
public fun buildQueueGateway(endpoint: HostEndpoint): QueueGateway =
|
||||
ApiClientQueueGateway(api = { apiClientFactory.create(endpoint) })
|
||||
|
||||
// ── GET /config/ui (allowAutoMode) ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The host-policy gate for `approve.mode="acceptEdits"`. One instance for the app so a host is asked
|
||||
* once per process; it FAILS CLOSED, so an unreachable host never widens what the phone may approve.
|
||||
*/
|
||||
public val uiConfigGate: UiConfigGate = UiConfigGate { endpoint ->
|
||||
apiClientFactory.create(endpoint).uiConfig()
|
||||
}
|
||||
|
||||
// ── Host removal ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Un-pair a host and erase EVERY trace keyed off it — including the `DELETE /push/fcm-token` that had
|
||||
* no caller at all, which is why a removed host kept pushing notifications to this device. See
|
||||
* [HostRemover] for the ordering rules.
|
||||
*/
|
||||
public val hostRemoval: HostRemovalGateway by lazy {
|
||||
// `by lazy` because the session-list screen reads this on `Main`. Resolving the auth-cookie
|
||||
// store here is safe by that module's documented contract — `TinkAuthCookieCipher` defers ALL
|
||||
// AndroidKeyStore/AEAD work to first use — and in practice `warmUp()` has already resolved it
|
||||
// off-`Main`. Nothing in this block does I/O; the removal itself runs in the caller's coroutine.
|
||||
HostRemover(
|
||||
hostStore = hostStore,
|
||||
lastSessionStore = lastSessionStore,
|
||||
authCookieStore = authCookieStoreLazy.get(),
|
||||
watermarkStore = sessionWatermarkStore,
|
||||
pushUnregister = pushUnregister,
|
||||
currentPushToken = { pushTokenSource.currentToken() },
|
||||
clearLiveCookies = { hostKey -> authCookieJarLazy.get().clear(hostKey) },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Silent certificate rotation ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* App-lifetime scope for the fire-and-forget rotation pass. Deliberately NOT the caller's scope:
|
||||
* [warmUp] is awaited by the terminal screen's readiness gate, and a renewal's network round-trip
|
||||
* must never be able to hold the UI behind a spinner.
|
||||
*/
|
||||
private val rotationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val rotationTrigger = CertificateRotationTrigger(
|
||||
scope = rotationScope,
|
||||
scheduler = { rotationSchedulerLazy.get() },
|
||||
onError = { error ->
|
||||
// Shape only: a rotation error can carry a control-plane URL or response body.
|
||||
Log.w(ROTATION_TAG, "certificate rotation could not start (${error::class.simpleName})")
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Run one silent certificate-rotation pass if one is due (the Android counterpart of iOS
|
||||
* `AppCoordinator.runCertificateRotationIfDue()`). Safe on launch AND on every foreground: the
|
||||
* scheduler is idempotent, self-coalescing, and `NotDue` costs one store read. Returns immediately.
|
||||
*/
|
||||
public fun runCertificateRotationIfDue() {
|
||||
rotationTrigger.fire()
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent rotation pass's outcome (null before the first pass). Observed by the device-cert
|
||||
* screen so a silently failing renewal becomes visible instead of only a log line.
|
||||
*/
|
||||
public fun rotationOutcomes(): StateFlow<RotationOutcome?> = rotationSchedulerLazy.get().lastOutcome
|
||||
|
||||
/**
|
||||
* Build the shared `OkHttpClient`, rehydrate the persisted session cookie and first-touch the device
|
||||
* identity OFF `Main` (FIX 3). Resolving either transport builds the one shared client (which reads
|
||||
@@ -159,6 +282,184 @@ public class AppEnvironment @Inject constructor(
|
||||
apiClientFactory.warm() // HttpTransport reuses the SAME already-built client
|
||||
runCatching { identityRepositoryLazy.get().hasInstalledIdentity() } // first AndroidKeyStore/Tink touch
|
||||
}
|
||||
// Rotate the device certificate if it is due. Fired LAST and fire-and-forget: it needs the warmed
|
||||
// mTLS stack, and its network round-trip must not be awaited by this function's callers (the
|
||||
// terminal screen blocks its readiness gate on warmUp). The AndroidKeyStore/Tink reads happen on
|
||||
// the scheduler's own IO dispatcher, never on `Main`.
|
||||
runCertificateRotationIfDue()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ROTATION_TAG: String = "WebTermRotation"
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /config/ui — the auto-mode host policy ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether a host permits `approve.mode="acceptEdits"` (`GET /config/ui` → `{ allowAutoMode }`).
|
||||
*
|
||||
* `ApiClient.uiConfig()` existed with no caller, so the plan-gate three-way sheet offered
|
||||
* "approve + auto-accept edits" even on a host whose operator had set `ALLOW_AUTO_MODE=0`. This is the
|
||||
* client half of that policy, and it **fails CLOSED**:
|
||||
* - a transport failure, a pre-`/config/ui` server, or an unparseable body all answer `false`;
|
||||
* - only a SUCCESSFUL fetch is cached, so a host that was merely down is re-asked rather than being
|
||||
* permanently marked one way or the other;
|
||||
* - `CancellationException` propagates — a cancelled scope must never be read as a policy answer.
|
||||
*
|
||||
* The read-only `GET` carries no `Origin` header (plan §4.3) and no credential of its own; the answer is
|
||||
* a single boolean, so nothing here has to be redacted.
|
||||
*/
|
||||
public class UiConfigGate(private val fetch: suspend (HostEndpoint) -> UiConfig) {
|
||||
private val mutex = Mutex()
|
||||
private var cache: Map<String, Boolean> = emptyMap()
|
||||
|
||||
/** `true` only when [endpoint] positively confirmed it. Never throws except for cancellation. */
|
||||
public suspend fun allowsAutoMode(endpoint: HostEndpoint): Boolean {
|
||||
mutex.withLock { cache[endpoint.baseUrl] }?.let { return it }
|
||||
val allowed = try {
|
||||
fetch(endpoint).allowAutoMode
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
return false // FAIL CLOSED — and deliberately NOT cached, so a recovered host is re-asked.
|
||||
}
|
||||
mutex.withLock { cache = cache + (endpoint.baseUrl to allowed) }
|
||||
return allowed
|
||||
}
|
||||
}
|
||||
|
||||
// ── Silent certificate rotation: the app-lifecycle trigger ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The lifecycle edge that CALLS [CertificateRotationScheduler] — the Android counterpart of iOS
|
||||
* `AppCoordinator.runCertificateRotationIfDue()`. Without a trigger the scheduler is dead code and a
|
||||
* device certificate simply expires, stranding the app behind an mTLS handshake it can no longer make.
|
||||
*
|
||||
* [fire] launches and RETURNS: the pass does AndroidKeyStore/Tink reads and possibly a control-plane
|
||||
* round-trip, and no UI path may wait on that. The scheduler is idempotent and self-coalescing, so
|
||||
* firing on launch and again on every foreground needs no external guard.
|
||||
*
|
||||
* Nothing escapes: the scheduler turns a rotation failure into `RotationOutcome.Failed` itself, and
|
||||
* [onError] only sees the (unexpected) case where the scheduler could not even be resolved. Both report
|
||||
* the error's SHAPE only — a rotation error can embed a control-plane URL or a response body.
|
||||
*/
|
||||
public class CertificateRotationTrigger(
|
||||
private val scope: CoroutineScope,
|
||||
private val scheduler: () -> CertificateRotationScheduler,
|
||||
private val onError: (Throwable) -> Unit = {},
|
||||
) {
|
||||
/** Start a rotation pass in the background. Idempotent by way of the scheduler's single-flight. */
|
||||
public fun fire() {
|
||||
scope.launch {
|
||||
try {
|
||||
scheduler().runIfDue()
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Host removal ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The `DELETE /push/fcm-token` half of [wang.yaojia.webterm.push.PushRegistrar], as a seam so
|
||||
* [HostRemover] is JVM-testable with no transport. `di/PushModule` binds it to
|
||||
* `PushRegistrar.unregisterHost` — whose FIRST caller this is.
|
||||
*/
|
||||
public fun interface HostPushUnregister {
|
||||
/** Best-effort: tell [host] to forget [token]. [token] is never logged (plan §8). */
|
||||
public suspend fun unregister(host: Host, token: String)
|
||||
}
|
||||
|
||||
/** This device's current FCM registration token, or null when push is unavailable. */
|
||||
public fun interface PushTokenSource {
|
||||
public suspend fun currentToken(): String?
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-pairing a host, as ONE ordered operation. A half-removed host is worse than none, so both the order
|
||||
* and the failure policy are load-bearing:
|
||||
*
|
||||
* 1. **`DELETE /push/fcm-token` FIRST**, while the host record and its `webterm_auth` cookie still
|
||||
* exist — the call cannot be addressed without the endpoint, nor authenticated without the cookie.
|
||||
* `PushRegistrar.unregisterHost` had ZERO call sites before this, which is why a dropped host kept
|
||||
* pushing notifications to the device forever. It is **best-effort**: a host that is switched off
|
||||
* must still be removable, and the local state is what the user asked us to forget.
|
||||
* 2. **Then the local state**, in the order that keeps a failure retryable: the session watermarks, the
|
||||
* last-session pointer, the persisted + live `webterm_auth` cookie, and the paired record LAST. Any
|
||||
* throw propagates with the host still paired, so the user can simply try again — the UI treats that
|
||||
* as "nothing was removed" ([HostRemovalGateway]).
|
||||
*
|
||||
* ### What is deliberately NOT removed
|
||||
* - **The device certificate / AndroidKeyStore identity** — it is ONE app-wide mTLS identity shared by
|
||||
* every host (`IdentityRepository`), not per-host state. Removing it while un-pairing one host would
|
||||
* silently break every other tunnel host. It has its own explicit affordance on the cert screen.
|
||||
* - **The quick-reply palette** — global user content, not host state.
|
||||
*
|
||||
* ### Watermarks are keyed by SESSION, not host
|
||||
* `SessionWatermarkStore` maps `sessionId → last-seen`, so there is no host-scoped sweep to run. The
|
||||
* caller passes the ids it can attribute to this host (its currently-listed sessions); anything it could
|
||||
* not see is bounded by the store's own 512-entry cap rather than leaking without limit.
|
||||
*/
|
||||
public class HostRemover(
|
||||
private val hostStore: HostStore,
|
||||
private val lastSessionStore: LastSessionStore,
|
||||
private val authCookieStore: AuthCookieStore,
|
||||
private val watermarkStore: SessionWatermarkStore,
|
||||
private val pushUnregister: HostPushUnregister,
|
||||
private val currentPushToken: suspend () -> String?,
|
||||
private val clearLiveCookies: (hostKey: String) -> Unit,
|
||||
private val log: (String) -> Unit = { Log.w(REMOVER_TAG, it) },
|
||||
/** Everything below is storage + network I/O; the caller is a Composable scope on `Main`. */
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : HostRemovalGateway {
|
||||
|
||||
override suspend fun removeHost(hostId: String, sessionIds: List<String>): Unit =
|
||||
withContext(ioDispatcher) {
|
||||
val host = hostStore.loadAll().firstOrNull { it.id == hostId }
|
||||
?: return@withContext // already gone → no-op
|
||||
|
||||
unregisterPushBestEffort(host)
|
||||
|
||||
// Local erasure. Ordered so `hostStore.remove` is LAST: an earlier failure leaves the host
|
||||
// paired, which the UI reports as "nothing removed" and the user can retry.
|
||||
sessionIds.forEach { watermarkStore.remove(it) }
|
||||
lastSessionStore.setLastSessionId(null, hostId)
|
||||
authCookieHostKey(host.endpoint.baseUrl)?.let { hostKey ->
|
||||
authCookieStore.removeHost(hostKey)
|
||||
clearLiveCookies(hostKey) // the in-memory jar too, or the credential rides the next dial
|
||||
}
|
||||
hostStore.remove(hostId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the host to forget this device's push token. Best-effort by design; the token is passed only
|
||||
* to the call and never logged (plan §8), and a failure is reported by SHAPE.
|
||||
*/
|
||||
private suspend fun unregisterPushBestEffort(host: Host) {
|
||||
val token = try {
|
||||
currentPushToken()
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
log("push token unavailable while removing a host (${error::class.simpleName})")
|
||||
null
|
||||
} ?: return
|
||||
try {
|
||||
pushUnregister.unregister(host, token)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
log("push de-registration failed for the removed host (${error::class.simpleName})")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REMOVER_TAG: String = "WebTermHostRemoval"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
|
||||
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
|
||||
import wang.yaojia.webterm.api.enroll.RotationDecision
|
||||
import wang.yaojia.webterm.api.enroll.RotationPolicy
|
||||
import wang.yaojia.webterm.tlsandroid.CertStore
|
||||
import wang.yaojia.webterm.tlsandroid.DeviceEnroller
|
||||
import wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot
|
||||
import wang.yaojia.webterm.tlsandroid.DeviceRotationStore
|
||||
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
|
||||
import wang.yaojia.webterm.transport.OkHttpClientFactory
|
||||
import wang.yaojia.webterm.transport.OkHttpHttpTransport
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/** Which step of a rotation pass an outcome refers to — so a failure names what actually failed. */
|
||||
public enum class RotationStage {
|
||||
/** Reading the installed identity's timing out of the encrypted stores. */
|
||||
READ_STATE,
|
||||
|
||||
/** `POST /device/:id/renew` over mTLS with the current (still valid) certificate. */
|
||||
RENEW,
|
||||
|
||||
/** `POST /device/:id/recover` over plain HTTPS with the lapsed certificate in the body. */
|
||||
RECOVER,
|
||||
}
|
||||
|
||||
/**
|
||||
* The total result of one rotation pass. Every case is safe to log and to render in the UI: none of
|
||||
* them can carry a private key, a certificate, a CSR or a token (see [RotationOutcome.Failed]).
|
||||
*/
|
||||
public sealed interface RotationOutcome {
|
||||
/** No enrolled device identity (fresh install / legacy `.p12`): nothing to rotate. */
|
||||
public data object NotEnrolled : RotationOutcome
|
||||
|
||||
/**
|
||||
* Enrolled, but no control-plane URL is persisted (a record written before it was recorded), so
|
||||
* there is no host to rotate against. Surfaced rather than guessed — a compiled-in default would
|
||||
* silently talk to the wrong control plane. A re-enroll records the URL and clears this.
|
||||
*/
|
||||
public data class NotConfigured(val deviceId: String) : RotationOutcome
|
||||
|
||||
/** The renew window has not opened yet — the cheap, common case. */
|
||||
public data class NotDue(val renewAfter: Instant?) : RotationOutcome
|
||||
|
||||
/** An attempt is due but the previous one failed too recently; the next try is at [retryAt]. */
|
||||
public data class BackingOff(val retryAt: Instant) : RotationOutcome
|
||||
|
||||
/** Renewed silently; the new leaf is presented on the next handshake. */
|
||||
public data object Renewed : RotationOutcome
|
||||
|
||||
/** An expired leaf was recovered over plain HTTPS; the new leaf is live. */
|
||||
public data object Recovered : RotationOutcome
|
||||
|
||||
/**
|
||||
* TERMINAL — the leaf expired [expiredFor] ago, past the recovery grace. No request can succeed;
|
||||
* only a fresh enroll can. Reported once per pass and never retried, so one real failure cannot
|
||||
* become thousands of identical warnings.
|
||||
*/
|
||||
public data class ReEnrollRequired(val expiredFor: Duration) : RotationOutcome
|
||||
|
||||
/**
|
||||
* The pass failed at [stage]. The PRIOR identity is fully live and unchanged (the atomic
|
||||
* live-pointer flip only happens after a successful re-issuance), so a transient failure is
|
||||
* recoverable on a later pass.
|
||||
*
|
||||
* [errorShape] is deliberately the error's SHAPE — its class name, or `Http(status,code)` for a
|
||||
* server rejection — and never `Throwable.message`, which can embed a URL, a response body or
|
||||
* credential material.
|
||||
*/
|
||||
public data class Failed(val stage: RotationStage, val errorShape: String) : RotationOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent device-certificate rotation: on a trigger (app launch / foreground) read the installed
|
||||
* identity's timing, ask the pure [RotationPolicy] what to do, and drive it. This is the
|
||||
* "one bootstrap enroll, then never again" half of zero-touch — the Android counterpart of iOS
|
||||
* `ClientTLS/CertificateRotationScheduler.swift`, which Android was missing entirely (so a device
|
||||
* certificate simply expired and stranded the app).
|
||||
*
|
||||
* It goes beyond the iOS version in one way that matters: iOS can only renew, and `/device/:id/renew`
|
||||
* is authenticated by the very certificate it renews — so an iOS device whose leaf lapses is stuck
|
||||
* needing a manual re-enroll. This driver routes a lapsed leaf to `/device/:id/recover` instead
|
||||
* ([RotationStage.RECOVER]), which takes the expired certificate in the request BODY over PLAIN
|
||||
* HTTPS because no TLS terminator will forward an expired client certificate at all.
|
||||
*
|
||||
* ### The one place allowed to know both halves
|
||||
* `:api-client` must never gain a `:client-tls-android` edge. This file is the composition point that
|
||||
* knows both: the pure policy/HTTP client on one side, the keystore-backed enroller on the other.
|
||||
*
|
||||
* ### Idempotent, single-flight, and honest
|
||||
* - Safe to call on EVERY foreground: `NotDue` is the cheap common case and costs no I/O beyond one
|
||||
* store read.
|
||||
* - A second trigger while a pass is in flight COALESCES onto it (an `AtomicReference` holding the
|
||||
* in-flight result), so two foregrounds cannot stampede two renewals into a control plane that
|
||||
* rate-limits them.
|
||||
* - A failure never throws out of [runIfDue]: it is recorded as [RotationOutcome.Failed], published
|
||||
* on [lastOutcome], and used to throttle the next pass. The prior identity stays fully live —
|
||||
* `IdentityRepository`'s atomic live-pointer flip guarantees that, and nothing here weakens it.
|
||||
* - A cancelled pass releases the in-flight slot, so cancellation cannot wedge rotation for the
|
||||
* lifetime of the process.
|
||||
*
|
||||
* ### Threading
|
||||
* The class itself is dispatcher-free (so every branch is unit-testable under virtual time); the
|
||||
* store/network hop is inside the seams [create] builds, which move the AndroidKeyStore + Tink reads
|
||||
* off `Dispatchers.Main`.
|
||||
*
|
||||
* ### Step-up policy does NOT apply here
|
||||
* The relay's step-up gate lives in `relay-auth`'s `enforce/onUpgrade.ts` — the terminal-session
|
||||
* WebSocket upgrade. Renewal and recovery are plain control-plane HTTPS routes and consult no
|
||||
* step-up policy, so a step-up-required host does not block certificate rotation.
|
||||
*/
|
||||
public class CertificateRotationScheduler(
|
||||
/** Current rotation timing + target, or null when nothing is enrolled. May throw on a store fault. */
|
||||
private val readState: suspend () -> DeviceRotationSnapshot?,
|
||||
/** `POST /device/:id/renew` over mTLS against the given control-plane base URL. */
|
||||
private val renew: suspend (controlPlaneUrl: String) -> Unit,
|
||||
/** `POST /device/:id/recover` over PLAIN HTTPS against the given control-plane base URL. */
|
||||
private val recover: suspend (controlPlaneUrl: String) -> Unit,
|
||||
private val now: () -> Instant = { Instant.now() },
|
||||
private val expiredRecoveryGrace: Duration = RotationPolicy.DEFAULT_EXPIRED_RECOVERY_GRACE,
|
||||
private val failureBackoff: Duration = RotationPolicy.DEFAULT_FAILURE_BACKOFF,
|
||||
private val log: (String) -> Unit = { Log.i(TAG, it) },
|
||||
) {
|
||||
private val outcomes = MutableStateFlow<RotationOutcome?>(null)
|
||||
|
||||
/**
|
||||
* The most recent pass's outcome, or null before the first pass. Observable so a silently failing
|
||||
* renewal becomes visible state (iOS surfaces the same as `isCertificateRenewalFailing`) instead
|
||||
* of only a log line.
|
||||
*/
|
||||
public val lastOutcome: StateFlow<RotationOutcome?> = outcomes.asStateFlow()
|
||||
|
||||
/** Written only by the in-flight pass (single-flight); volatile so any dispatcher sees it. */
|
||||
@Volatile
|
||||
private var lastFailureAt: Instant? = null
|
||||
|
||||
/** Non-null while a pass is running — the shared result concurrent triggers coalesce onto. */
|
||||
private val inFlight = AtomicReference<CompletableDeferred<RotationOutcome>?>(null)
|
||||
|
||||
/**
|
||||
* Run one pass: read timing → decide → renew/recover if due. Idempotent and safe on every
|
||||
* foreground; never throws for a rotation failure (that is [RotationOutcome.Failed]). Only
|
||||
* cancellation propagates.
|
||||
*/
|
||||
public suspend fun runIfDue(): RotationOutcome {
|
||||
while (true) {
|
||||
inFlight.get()?.let { return it.await() }
|
||||
val pass = CompletableDeferred<RotationOutcome>()
|
||||
if (!inFlight.compareAndSet(null, pass)) continue // lost the race — join the winner
|
||||
try {
|
||||
val outcome = runPass()
|
||||
outcomes.value = outcome
|
||||
inFlight.set(null) // release BEFORE completing so a later trigger starts a fresh pass
|
||||
pass.complete(outcome)
|
||||
return outcome
|
||||
} catch (throwable: Throwable) {
|
||||
// Cancellation (the only thing that reaches here) must not leave a slot that no
|
||||
// future pass can ever get past.
|
||||
inFlight.set(null)
|
||||
pass.completeExceptionally(throwable)
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runPass(): RotationOutcome {
|
||||
val snapshot = try {
|
||||
readState()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
return recordFailure(RotationStage.READ_STATE, error)
|
||||
} ?: return RotationOutcome.NotEnrolled
|
||||
|
||||
val state = snapshot.renewalState
|
||||
val at = now()
|
||||
val failedAt = lastFailureAt
|
||||
val decision = RotationPolicy.decide(
|
||||
state = state,
|
||||
now = at,
|
||||
lastFailureAt = failedAt,
|
||||
expiredRecoveryGrace = expiredRecoveryGrace,
|
||||
failureBackoff = failureBackoff,
|
||||
)
|
||||
return when (decision) {
|
||||
RotationDecision.NOT_DUE -> RotationOutcome.NotDue(state.renewAfter)
|
||||
RotationDecision.BACKING_OFF ->
|
||||
// The policy only answers BACKING_OFF when a failure was recorded, so `failedAt` is
|
||||
// present; fall back to `at` rather than assert, so a logic change cannot crash here.
|
||||
RotationOutcome.BackingOff((failedAt ?: at).plus(failureBackoff))
|
||||
RotationDecision.RENEW ->
|
||||
attempt(RotationStage.RENEW, snapshot, renew, RotationOutcome.Renewed)
|
||||
RotationDecision.RECOVER ->
|
||||
attempt(RotationStage.RECOVER, snapshot, recover, RotationOutcome.Recovered)
|
||||
RotationDecision.RE_ENROLL_REQUIRED -> reEnrollRequired(snapshot, at)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one re-issuance [operation] against the persisted control plane. The operation is passed in
|
||||
* rather than selected from [stage] so there is no unreachable "stage that is not an attempt"
|
||||
* branch to get wrong.
|
||||
*/
|
||||
private suspend fun attempt(
|
||||
stage: RotationStage,
|
||||
snapshot: DeviceRotationSnapshot,
|
||||
operation: suspend (controlPlaneUrl: String) -> Unit,
|
||||
success: RotationOutcome,
|
||||
): RotationOutcome {
|
||||
val deviceId = snapshot.renewalState.deviceId
|
||||
val controlPlaneUrl = snapshot.controlPlaneUrl.trim()
|
||||
if (controlPlaneUrl.isEmpty()) {
|
||||
log("rotation: device $deviceId has no persisted control-plane URL; re-enroll to record it")
|
||||
return RotationOutcome.NotConfigured(deviceId)
|
||||
}
|
||||
return try {
|
||||
operation(controlPlaneUrl)
|
||||
lastFailureAt = null
|
||||
log("rotation: device $deviceId certificate ${stage.name.lowercase()} succeeded")
|
||||
success
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
recordFailure(stage, error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reEnrollRequired(snapshot: DeviceRotationSnapshot, at: Instant): RotationOutcome {
|
||||
val notAfter = snapshot.renewalState.notAfter
|
||||
val expiredFor = if (notAfter == null) Duration.ZERO else Duration.between(notAfter, at)
|
||||
log(
|
||||
"rotation: device ${snapshot.renewalState.deviceId} certificate expired ${expiredFor.toDays()}d ago, " +
|
||||
"past the ${expiredRecoveryGrace.toDays()}d recovery window; a fresh enroll is required",
|
||||
)
|
||||
return RotationOutcome.ReEnrollRequired(expiredFor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record + report a failure. The prior identity is untouched, so the next pass can succeed. Only
|
||||
* the error's SHAPE is kept — never `message`, which can embed a URL, a body or a credential.
|
||||
*/
|
||||
private fun recordFailure(stage: RotationStage, error: Exception): RotationOutcome {
|
||||
lastFailureAt = now()
|
||||
val shape = errorShapeOf(error)
|
||||
log("rotation: ${stage.name.lowercase()} failed ($shape); the current certificate stays live")
|
||||
return RotationOutcome.Failed(stage, shape)
|
||||
}
|
||||
|
||||
public companion object {
|
||||
private const val TAG = "CertRotation"
|
||||
|
||||
/**
|
||||
* The error's non-secret shape. A server rejection keeps its status + uniform `error` code
|
||||
* (401 = expired beyond grace / untrusted, 403 = revoked or mismatched, 429 = rate limited),
|
||||
* which is exactly the diagnostic value needed; everything else degrades to the class name.
|
||||
*/
|
||||
private fun errorShapeOf(error: Exception): String = when (error) {
|
||||
is DeviceEnrollmentError.Http ->
|
||||
"Http(${error.status}${error.code?.let { ",$it" } ?: ""})"
|
||||
else -> error.javaClass.simpleName
|
||||
}
|
||||
|
||||
/**
|
||||
* Production wiring — the single composition point, so a DI module needs one provider.
|
||||
*
|
||||
* [mtlsTransport] is the app's shared REST transport, which presents the current device
|
||||
* certificate; [plainTransport] builds a SEPARATE client with NO client identity, used only by
|
||||
* the recovery path. That separation is load-bearing: an expired leaf presented on a handshake
|
||||
* makes nginx answer a bare 400 (it will not forward an expired client cert in any
|
||||
* `ssl_verify_client` mode), which is the deadlock recovery exists to escape. The plain client
|
||||
* is built lazily, so the common (renew) path never pays for it.
|
||||
*
|
||||
* Store reads and both HTTP calls are hopped onto [ioDispatcher] — the first identity touch
|
||||
* does synchronous AndroidKeyStore + Tink work and must never run on `Dispatchers.Main`.
|
||||
*
|
||||
* [sharedClient] / [mtlsTransport] / [plainTransport] are SUPPLIERS, not values, for the same
|
||||
* reason [EnrollmentFlowFactory] takes `dagger.Lazy`: resolving the shared client builds the
|
||||
* mTLS `SSLSocketFactory` (keystore I/O). As suppliers they are invoked only inside the
|
||||
* [ioDispatcher] hop, so a DI provider for this scheduler is itself I/O-free and can be
|
||||
* resolved from the main thread without risking an ANR.
|
||||
*/
|
||||
public fun create(
|
||||
certStore: CertStore,
|
||||
recordStore: EnrollmentRecordStore,
|
||||
cacheRefresher: IdentityCacheRefresher,
|
||||
sharedClient: () -> OkHttpClient,
|
||||
mtlsTransport: () -> HttpTransport,
|
||||
plainTransport: () -> HttpTransport = { OkHttpHttpTransport(OkHttpClientFactory.create()) },
|
||||
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
now: () -> Instant = { Instant.now() },
|
||||
log: (String) -> Unit = { Log.i(TAG, it) },
|
||||
): CertificateRotationScheduler {
|
||||
val rotationStore = DeviceRotationStore(certStore, recordStore)
|
||||
fun enroller(controlPlaneUrl: String, withRecovery: Boolean): DeviceEnroller =
|
||||
DeviceEnroller(
|
||||
client = DeviceEnrollmentClient(controlPlaneUrl, mtlsTransport()),
|
||||
certStore = certStore,
|
||||
recordStore = recordStore,
|
||||
sharedClient = sharedClient(),
|
||||
cacheRefresher = cacheRefresher,
|
||||
recoveryClient =
|
||||
if (withRecovery) DeviceEnrollmentClient(controlPlaneUrl, plainTransport()) else null,
|
||||
)
|
||||
return CertificateRotationScheduler(
|
||||
readState = { withContext(ioDispatcher) { rotationStore.read() } },
|
||||
renew = { url -> withContext(ioDispatcher) { enroller(url, withRecovery = false).renew() } },
|
||||
recover = { url -> withContext(ioDispatcher) { enroller(url, withRecovery = true).recover() } },
|
||||
now = now,
|
||||
log = log,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.session.Adopted
|
||||
import wang.yaojia.webterm.session.Exited
|
||||
import wang.yaojia.webterm.session.Gate
|
||||
import wang.yaojia.webterm.session.GateState
|
||||
import wang.yaojia.webterm.session.Queued
|
||||
import wang.yaojia.webterm.session.SessionEvent
|
||||
import wang.yaojia.webterm.wire.ApproveMode
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
import wang.yaojia.webterm.wire.GateKind
|
||||
import wang.yaojia.webterm.wiring.TerminalSessionController
|
||||
|
||||
/**
|
||||
* [GateViewModel] — the `GET /config/ui` `allowAutoMode` gate plus the two cockpit signals the VM used
|
||||
* to DROP on the floor (`queue` depth and the adopted session id).
|
||||
*
|
||||
* The `allowAutoMode` gate is security-relevant: `approve.mode="acceptEdits"` puts Claude into
|
||||
* auto-accept-edits, and an operator who set `ALLOW_AUTO_MODE=0` on the host must not be overridden from
|
||||
* a phone. The gate **fails CLOSED** — auto is refused until a successful fetch says otherwise — because
|
||||
* the permissive default is the dangerous one.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class GateAutoModeTest {
|
||||
|
||||
private class FakeController : TerminalSessionController {
|
||||
private val channel = Channel<SessionEvent>(Channel.UNLIMITED)
|
||||
val decisions = mutableListOf<Pair<Int, ClientMessage>>()
|
||||
|
||||
override fun controlEvents(): Flow<SessionEvent> = channel.receiveAsFlow()
|
||||
override val output: Flow<ByteArray> = emptyFlow()
|
||||
override fun start() = Unit
|
||||
override fun sendInput(data: String) = Unit
|
||||
override fun resize(cols: Int, rows: Int) = Unit
|
||||
override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit
|
||||
override fun decideGate(epoch: Int, message: ClientMessage) {
|
||||
decisions += epoch to message
|
||||
}
|
||||
|
||||
override fun close() = Unit
|
||||
fun emit(event: SessionEvent) {
|
||||
channel.trySend(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun planGate(epoch: Int) = Gate(GateState(kind = GateKind.PLAN, detail = "plan", epoch = epoch))
|
||||
|
||||
// ── allowAutoMode fails CLOSED ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `auto-accept is refused until the host says it is allowed`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
fake.emit(planGate(1))
|
||||
|
||||
assertFalse(vm.uiState.value.allowAutoMode) // fail-closed default
|
||||
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1)
|
||||
|
||||
assertTrue(fake.decisions.isEmpty()) // NOTHING was sent
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the other two plan affordances still work while auto is disabled`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
fake.emit(planGate(1))
|
||||
|
||||
vm.decide(GateDecision.APPROVE, epoch = 1)
|
||||
vm.decide(GateDecision.REJECT, epoch = 1)
|
||||
|
||||
assertEquals(2, fake.decisions.size)
|
||||
assertEquals(
|
||||
ClientMessage.Approve(mode = ApproveMode.DEFAULT),
|
||||
fake.decisions[0].second,
|
||||
)
|
||||
assertEquals(ClientMessage.Reject, fake.decisions[1].second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto-accept is sent once the host allows it`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
vm.setAutoModeAllowed(true)
|
||||
fake.emit(planGate(7))
|
||||
|
||||
assertTrue(vm.uiState.value.allowAutoMode)
|
||||
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 7)
|
||||
|
||||
assertEquals(
|
||||
listOf(7 to ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS)),
|
||||
fake.decisions.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `revoking auto mode drops a later auto decision`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
vm.setAutoModeAllowed(true)
|
||||
vm.setAutoModeAllowed(false)
|
||||
fake.emit(planGate(3))
|
||||
|
||||
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 3)
|
||||
|
||||
assertTrue(fake.decisions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the epoch stale-guard still outranks an allowed auto decision`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
vm.setAutoModeAllowed(true)
|
||||
fake.emit(planGate(2))
|
||||
|
||||
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1) // the gate on screen was epoch 1
|
||||
|
||||
assertTrue(fake.decisions.isEmpty())
|
||||
}
|
||||
|
||||
// ── The `queue` frame (previously dropped) ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the queue frame publishes the pending depth`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
assertNull(vm.uiState.value.queueDepth) // unknown before the first frame
|
||||
|
||||
fake.emit(Queued(3))
|
||||
assertEquals(3, vm.uiState.value.queueDepth)
|
||||
|
||||
fake.emit(Queued(0))
|
||||
assertEquals(0, vm.uiState.value.queueDepth)
|
||||
}
|
||||
|
||||
// ── The adopted session id (the queue routes need it) ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the adopted session id is published so a fresh spawn can be queued to`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
assertNull(vm.uiState.value.sessionId)
|
||||
|
||||
fake.emit(Adopted("11111111-2222-4333-8444-555555555555"))
|
||||
assertEquals("11111111-2222-4333-8444-555555555555", vm.uiState.value.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exit clears the queue depth along with the gate`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
fake.emit(planGate(1))
|
||||
fake.emit(Queued(2))
|
||||
|
||||
fake.emit(Exited(code = 0))
|
||||
|
||||
assertNull(vm.uiState.value.gate)
|
||||
assertNull(vm.uiState.value.queueDepth) // an exited session has no queue to show
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,9 @@ class GateViewModelTest {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
// `acceptEdits` is gated by the host's `GET /config/ui` `allowAutoMode`, which fails CLOSED — so a
|
||||
// test about the WIRE MAPPING has to opt in explicitly. The gate itself is `GateAutoModeTest`.
|
||||
vm.setAutoModeAllowed(true)
|
||||
fake.emit(planGate(3))
|
||||
|
||||
vm.decide(GateDecision.APPROVE, epoch = 3) // default review
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.QueueSnapshot
|
||||
import wang.yaojia.webterm.api.models.QueueWriteOutcome
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* [QueueViewModel] (W2 follow-up queue) — the write/read side of `POST|GET|DELETE
|
||||
* /live-sessions/:id/queue`.
|
||||
*
|
||||
* The load-bearing properties asserted here are the ones a screenshot cannot show:
|
||||
* - the prompt reaches the gateway **VERBATIM** (no trim, no normalisation, no client-side `\r`) and
|
||||
* `appendEnter` stays a FLAG, so the SERVER materialises the carriage return (invariant #9);
|
||||
* - every [QueueWriteOutcome] maps to its own UI notice, and a **429 is never auto-retried**;
|
||||
* - a 503 permanently hides the affordance for the host (`isSupported = false`);
|
||||
* - the authoritative depth comes from the server (`Ok(length)` / the `queue` WS frame), never from a
|
||||
* local increment.
|
||||
* The panel's Compose presentation is device-QA (plan §7).
|
||||
*/
|
||||
class QueueViewModelTest {
|
||||
|
||||
private companion object {
|
||||
val SESSION: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
}
|
||||
|
||||
/** Records every call and replays scripted outcomes in order (the last one repeats). */
|
||||
private class FakeGateway(
|
||||
private val snapshots: List<Any> = listOf(QueueSnapshot()),
|
||||
private val writes: List<QueueWriteOutcome> = listOf(QueueWriteOutcome.Ok(1)),
|
||||
) : QueueGateway {
|
||||
val enqueued = mutableListOf<Triple<UUID, String, Boolean>>()
|
||||
val cleared = mutableListOf<UUID>()
|
||||
var snapshotCalls: Int = 0
|
||||
|
||||
override suspend fun snapshot(id: UUID): QueueSnapshot {
|
||||
val next = snapshots[minOf(snapshotCalls, snapshots.lastIndex)]
|
||||
snapshotCalls += 1
|
||||
if (next is Throwable) throw next
|
||||
return next as QueueSnapshot
|
||||
}
|
||||
|
||||
override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome {
|
||||
enqueued += Triple(id, text, appendEnter)
|
||||
return writes[minOf(enqueued.size - 1, writes.lastIndex)]
|
||||
}
|
||||
|
||||
override suspend fun clear(id: UUID): QueueWriteOutcome {
|
||||
cleared += id
|
||||
return writes[minOf(cleared.size - 1, writes.lastIndex)]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Verbatim discipline (invariant #9) ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the prompt reaches the gateway byte-for-byte with appendEnter as a flag`() = runTest {
|
||||
val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Ok(2)))
|
||||
val vm = QueueViewModel(gateway)
|
||||
|
||||
val raw = " run the tests\nthen commit "
|
||||
vm.enqueue(SESSION, raw)
|
||||
|
||||
assertEquals(1, gateway.enqueued.size)
|
||||
val (id, text, appendEnter) = gateway.enqueued.single()
|
||||
assertEquals(SESSION, id)
|
||||
assertEquals(raw, text) // no trim, no normalisation
|
||||
assertFalse(text.contains('\r')) // the CR is the SERVER's job
|
||||
assertTrue(appendEnter)
|
||||
assertEquals(2, vm.uiState.value.depth) // authoritative depth from Ok(length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty prompt is never sent`() = runTest {
|
||||
val gateway = FakeGateway()
|
||||
val vm = QueueViewModel(gateway)
|
||||
|
||||
vm.enqueue(SESSION, "")
|
||||
|
||||
assertTrue(gateway.enqueued.isEmpty())
|
||||
assertFalse(QueueViewModel.canSend(""))
|
||||
// whitespace IS legitimate shell input — only EMPTY is refused
|
||||
assertTrue(QueueViewModel.canSend(" "))
|
||||
}
|
||||
|
||||
// ── Outcome → UI notice ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a full queue is reported and nothing is retried`() = runTest {
|
||||
val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Full))
|
||||
val vm = QueueViewModel(gateway)
|
||||
|
||||
vm.enqueue(SESSION, "one more")
|
||||
|
||||
assertEquals(QueueNotice.Full, vm.uiState.value.notice)
|
||||
assertEquals(1, gateway.enqueued.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rate limit is surfaced and NEVER auto-retried`() = runTest {
|
||||
val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.RateLimited))
|
||||
val vm = QueueViewModel(gateway)
|
||||
|
||||
vm.enqueue(SESSION, "prompt")
|
||||
|
||||
assertEquals(QueueNotice.RateLimited, vm.uiState.value.notice)
|
||||
assertEquals(1, gateway.enqueued.size) // exactly one attempt — a retry burns the shared bucket
|
||||
assertTrue(vm.uiState.value.isSupported) // 429 is transient, not a capability answer
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 503 hides the affordance for this host`() = runTest {
|
||||
val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Disabled))
|
||||
val vm = QueueViewModel(gateway)
|
||||
|
||||
vm.enqueue(SESSION, "prompt")
|
||||
|
||||
assertFalse(vm.uiState.value.isSupported)
|
||||
assertEquals(QueueNotice.Disabled, vm.uiState.value.notice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an oversized prompt asks for a shorter one`() = runTest {
|
||||
val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.TooLarge)))
|
||||
vm.enqueue(SESSION, "x".repeat(64))
|
||||
assertEquals(QueueNotice.TooLarge, vm.uiState.value.notice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gone session is reported as gone`() = runTest {
|
||||
val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.SessionGone)))
|
||||
vm.enqueue(SESSION, "prompt")
|
||||
assertEquals(QueueNotice.SessionGone, vm.uiState.value.notice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server rejection carries the server's own inert message`() = runTest {
|
||||
val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.Rejected(403, "forbidden"))))
|
||||
vm.enqueue(SESSION, "prompt")
|
||||
assertEquals(QueueNotice.Rejected("forbidden"), vm.uiState.value.notice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transport failure surfaces as unreachable and keeps the last-good queue`() = runTest {
|
||||
val vm = QueueViewModel(
|
||||
object : QueueGateway {
|
||||
override suspend fun snapshot(id: UUID): QueueSnapshot = QueueSnapshot(2, listOf("a", "b"))
|
||||
override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome =
|
||||
throw java.io.IOException("boom")
|
||||
|
||||
override suspend fun clear(id: UUID): QueueWriteOutcome = QueueWriteOutcome.Ok(0)
|
||||
},
|
||||
)
|
||||
|
||||
vm.refresh(SESSION)
|
||||
assertEquals(listOf("a", "b"), vm.uiState.value.items)
|
||||
|
||||
vm.enqueue(SESSION, "prompt")
|
||||
assertEquals(QueueNotice.Unreachable, vm.uiState.value.notice)
|
||||
assertEquals(listOf("a", "b"), vm.uiState.value.items) // last-good kept
|
||||
}
|
||||
|
||||
// ── Read side + cancel-all ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `refresh publishes the queued prompts verbatim`() = runTest {
|
||||
val queued = listOf("first prompt", " second\twith tabs")
|
||||
val vm = QueueViewModel(FakeGateway(snapshots = listOf(QueueSnapshot(2, queued))))
|
||||
|
||||
vm.refresh(SESSION)
|
||||
|
||||
assertEquals(2, vm.uiState.value.depth)
|
||||
assertEquals(queued, vm.uiState.value.items)
|
||||
assertNull(vm.uiState.value.notice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 404 on refresh reports the session gone`() = runTest {
|
||||
val vm = QueueViewModel(FakeGateway(snapshots = listOf(ApiClientError.SessionNotFound)))
|
||||
vm.refresh(SESSION)
|
||||
assertEquals(QueueNotice.SessionGone, vm.uiState.value.notice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel-all empties the queue and drives the depth from the server`() = runTest {
|
||||
val gateway = FakeGateway(
|
||||
snapshots = listOf(QueueSnapshot(2, listOf("a", "b"))),
|
||||
writes = listOf(QueueWriteOutcome.Ok(0)),
|
||||
)
|
||||
val vm = QueueViewModel(gateway)
|
||||
vm.refresh(SESSION)
|
||||
|
||||
vm.clearAll(SESSION)
|
||||
|
||||
assertEquals(listOf(SESSION), gateway.cleared)
|
||||
assertEquals(0, vm.uiState.value.depth)
|
||||
assertTrue(vm.uiState.value.items.isEmpty())
|
||||
}
|
||||
|
||||
// ── The live `queue` WS frame ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the queue frame is the authoritative depth and invalidates stale items`() = runTest {
|
||||
val gateway = FakeGateway(snapshots = listOf(QueueSnapshot(1, listOf("a"))))
|
||||
val vm = QueueViewModel(gateway)
|
||||
vm.refresh(SESSION)
|
||||
|
||||
vm.onServerDepth(3) // the server drained/accepted entries we did not send
|
||||
|
||||
assertEquals(3, vm.uiState.value.depth)
|
||||
// The items list is now known-stale: it must NOT claim 1 entry while the depth says 3.
|
||||
assertTrue(vm.uiState.value.isItemsStale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-depth frame clears the item list outright`() = runTest {
|
||||
val vm = QueueViewModel(FakeGateway(snapshots = listOf(QueueSnapshot(1, listOf("a")))))
|
||||
vm.refresh(SESSION)
|
||||
|
||||
vm.onServerDepth(0)
|
||||
|
||||
assertEquals(0, vm.uiState.value.depth)
|
||||
assertTrue(vm.uiState.value.items.isEmpty())
|
||||
assertFalse(vm.uiState.value.isItemsStale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissing a notice leaves the queue itself untouched`() = runTest {
|
||||
val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.Full)))
|
||||
vm.refresh(SESSION)
|
||||
vm.enqueue(SESSION, "prompt")
|
||||
assertEquals(QueueNotice.Full, vm.uiState.value.notice)
|
||||
|
||||
vm.dismissNotice()
|
||||
|
||||
assertNull(vm.uiState.value.notice)
|
||||
assertEquals(0, vm.uiState.value.depth)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore
|
||||
import wang.yaojia.webterm.session.UnreadLedger
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* [SessionListViewModel] — the two things that make it stop lying across a process death and a host
|
||||
* removal:
|
||||
* 1. the DURABLE watermark seam ([PersistedUnreadWatermarkStore] over `:host-registry`'s
|
||||
* `SessionWatermarkStore`), including the one-shot [SessionListViewModel.useWatermarkStore] install
|
||||
* the composition root uses and the rule that it can never swap a store out from under a loaded
|
||||
* ledger;
|
||||
* 2. host removal — which must reach the [HostRemovalGateway] with the removed host's live session ids
|
||||
* (so its watermarks go too) and must NOT drop the row on failure.
|
||||
*
|
||||
* The reducer itself ([UnreadLedger]) is NOT retested here — this is only about WHERE the watermark
|
||||
* lives.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SessionListPersistenceTest {
|
||||
|
||||
private companion object {
|
||||
const val BASE_A = "http://a:3000"
|
||||
const val BASE_B = "http://b:3000"
|
||||
val ID_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
val ID_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")
|
||||
|
||||
fun host(id: String, name: String, baseUrl: String): Host =
|
||||
Host.create(id = id, name = name, baseUrl = baseUrl)!!
|
||||
|
||||
fun session(id: UUID, lastOutputAt: Long? = null) = LiveSessionInfo(
|
||||
id = id,
|
||||
createdAt = 1_700_000_000_000L,
|
||||
clientCount = 1,
|
||||
status = ClaudeStatus.WORKING,
|
||||
exited = false,
|
||||
cwd = null,
|
||||
title = null,
|
||||
cols = 80,
|
||||
rows = 24,
|
||||
telemetry = null,
|
||||
lastOutputAt = lastOutputAt,
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeHostStore(initial: List<Host>) : HostStore {
|
||||
var hosts: List<Host> = initial
|
||||
override suspend fun loadAll(): List<Host> = hosts
|
||||
override suspend fun upsert(host: Host): List<Host> = hosts
|
||||
override suspend fun remove(id: String): List<Host> {
|
||||
hosts = hosts.filterNot { it.id == id }
|
||||
return hosts
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeGateway(private val list: List<LiveSessionInfo>) : SessionListGateway {
|
||||
override suspend fun liveSessions(): List<LiveSessionInfo> = list
|
||||
override suspend fun killSession(id: UUID) = Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the production `HostRemover`: it OWNS the removal, so it drops the host from the
|
||||
* store exactly like the real one does (a fake that only records would let the VM's re-resolve pass
|
||||
* against a host list no device would ever show).
|
||||
*/
|
||||
private class FakeRemovalGateway(
|
||||
private val hosts: FakeHostStore? = null,
|
||||
private val error: Throwable? = null,
|
||||
) : HostRemovalGateway {
|
||||
val calls = mutableListOf<Pair<String, List<String>>>()
|
||||
override suspend fun removeHost(hostId: String, sessionIds: List<String>) {
|
||||
calls += hostId to sessionIds
|
||||
error?.let { throw it }
|
||||
hosts?.remove(hostId)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The durable watermark seam ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a persisted watermark survives a new view model over the same store`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backing = InMemorySessionWatermarkStore()
|
||||
val store = PersistedUnreadWatermarkStore(backing)
|
||||
val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A)))
|
||||
val gateway = FakeGateway(listOf(session(ID_1, lastOutputAt = 100L)))
|
||||
|
||||
val first = SessionListViewModel(hostStore = hosts, gatewayFactory = { gateway }, watermarkStore = store)
|
||||
first.refresh()
|
||||
assertTrue(first.uiState.value.rows.single().isUnread)
|
||||
first.markSeen(ID_1)
|
||||
assertFalse(first.uiState.value.rows.single().isUnread)
|
||||
|
||||
// A fresh VM over the SAME durable store == the process restarted.
|
||||
val second = SessionListViewModel(hostStore = hosts, gatewayFactory = { gateway }, watermarkStore = store)
|
||||
second.refresh()
|
||||
assertFalse(second.uiState.value.rows.single().isUnread)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the adapter round-trips the ledger map through the host-registry store`() = runTest {
|
||||
val backing = InMemorySessionWatermarkStore()
|
||||
val store = PersistedUnreadWatermarkStore(backing)
|
||||
|
||||
store.save(UnreadLedger(mapOf(ID_1.toString() to 42L)))
|
||||
|
||||
assertEquals(mapOf(ID_1.toString() to 42L), store.load().watermarks)
|
||||
assertEquals(mapOf(ID_1.toString() to 42L), backing.loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the adapter drops ids the durable store refuses`() = runTest {
|
||||
val store = PersistedUnreadWatermarkStore(InMemorySessionWatermarkStore())
|
||||
|
||||
// "not-a-uuid" fails the frozen v4 session-id validation inside the store.
|
||||
store.save(UnreadLedger(mapOf(ID_1.toString() to 7L, "not-a-uuid" to 9L)))
|
||||
|
||||
assertEquals(mapOf(ID_1.toString() to 7L), store.load().watermarks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `useWatermarkStore installs the durable store before the first load`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backing = InMemorySessionWatermarkStore()
|
||||
backing.replaceAll(mapOf(ID_1.toString() to 100L)) // already seen up to 100
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
|
||||
)
|
||||
|
||||
assertTrue(vm.useWatermarkStore(PersistedUnreadWatermarkStore(backing)))
|
||||
vm.refresh()
|
||||
|
||||
assertFalse(vm.uiState.value.rows.single().isUnread) // the persisted watermark was honoured
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `useWatermarkStore never overrides an explicitly injected store`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val injected = InMemoryUnreadWatermarkStore(UnreadLedger(mapOf(ID_1.toString() to 100L)))
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
|
||||
watermarkStore = injected,
|
||||
)
|
||||
|
||||
assertFalse(vm.useWatermarkStore(InMemoryUnreadWatermarkStore()))
|
||||
vm.refresh()
|
||||
|
||||
assertFalse(vm.uiState.value.rows.single().isUnread) // still the injected store's watermark
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `useWatermarkStore is refused once the ledger has loaded`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
|
||||
)
|
||||
vm.refresh() // loads the ledger from the default in-memory store
|
||||
|
||||
assertFalse(vm.useWatermarkStore(InMemoryUnreadWatermarkStore()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a durable store that throws degrades to no watermarks instead of crashing`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val exploding = object : UnreadWatermarkStore {
|
||||
override suspend fun load(): UnreadLedger = throw IllegalStateException("keystore hiccup")
|
||||
override suspend fun save(ledger: UnreadLedger) = throw IllegalStateException("disk full")
|
||||
}
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
|
||||
watermarkStore = exploding,
|
||||
)
|
||||
|
||||
vm.refresh()
|
||||
assertTrue(vm.uiState.value.rows.single().isUnread) // no watermarks → unread, never a crash
|
||||
vm.markSeen(ID_1) // must not throw either
|
||||
}
|
||||
|
||||
// ── Host removal ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `removing the active host hands the gateway its live session ids`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A), host("h2", "desktop", BASE_B)))
|
||||
val removal = FakeRemovalGateway(hosts)
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = hosts,
|
||||
gatewayFactory = { FakeGateway(listOf(session(ID_1), session(ID_2))) },
|
||||
removalGateway = removal,
|
||||
)
|
||||
vm.refresh()
|
||||
|
||||
vm.removeActiveHost()
|
||||
|
||||
assertEquals(
|
||||
listOf("h1" to listOf(ID_1.toString(), ID_2.toString())),
|
||||
removal.calls.toList(),
|
||||
)
|
||||
// The list re-resolves onto the surviving host.
|
||||
assertEquals("h2", vm.uiState.value.activeHostId)
|
||||
assertEquals(listOf("h2"), vm.uiState.value.hosts.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing the only host lands on the empty pair-a-host state`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A)))
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = hosts,
|
||||
gatewayFactory = { FakeGateway(listOf(session(ID_1))) },
|
||||
removalGateway = FakeRemovalGateway(hosts),
|
||||
)
|
||||
vm.refresh()
|
||||
|
||||
vm.removeActiveHost()
|
||||
|
||||
assertNull(vm.uiState.value.activeHostId)
|
||||
assertTrue(vm.uiState.value.rows.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed removal leaves the host in place rather than half-removing it`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A)))
|
||||
val removal = FakeRemovalGateway(hosts, error = IllegalStateException("push DELETE failed"))
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = hosts,
|
||||
gatewayFactory = { FakeGateway(listOf(session(ID_1))) },
|
||||
removalGateway = removal,
|
||||
)
|
||||
vm.refresh()
|
||||
|
||||
vm.removeActiveHost()
|
||||
|
||||
assertEquals(1, removal.calls.size)
|
||||
assertEquals("h1", vm.uiState.value.activeHostId)
|
||||
assertTrue(vm.uiState.value.hasRemoveError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `useRemovalGateway enables removal for a view model built without one`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A)))
|
||||
val removal = FakeRemovalGateway(hosts)
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = hosts,
|
||||
gatewayFactory = { FakeGateway(listOf(session(ID_1))) },
|
||||
)
|
||||
vm.refresh()
|
||||
|
||||
vm.removeActiveHost() // no gateway installed yet → no-op
|
||||
assertTrue(removal.calls.isEmpty())
|
||||
assertEquals(1, hosts.hosts.size)
|
||||
|
||||
assertTrue(vm.useRemovalGateway(removal))
|
||||
assertFalse(vm.useRemovalGateway(FakeRemovalGateway(hosts))) // one-shot
|
||||
vm.removeActiveHost()
|
||||
|
||||
assertEquals(listOf("h1" to listOf(ID_1.toString())), removal.calls.toList())
|
||||
assertTrue(hosts.hosts.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeActiveHost with no active host is a no-op`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val removal = FakeRemovalGateway()
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(emptyList()),
|
||||
gatewayFactory = { FakeGateway(emptyList()) },
|
||||
removalGateway = removal,
|
||||
)
|
||||
vm.refresh()
|
||||
|
||||
vm.removeActiveHost()
|
||||
|
||||
assertTrue(removal.calls.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.components.queueNoticeText
|
||||
import wang.yaojia.webterm.screens.copyResultText
|
||||
import wang.yaojia.webterm.screens.rotationStatusText
|
||||
import wang.yaojia.webterm.terminalview.TerminalCopyResult
|
||||
import wang.yaojia.webterm.wiring.RotationOutcome
|
||||
import wang.yaojia.webterm.wiring.RotationStage
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* The pure copy mappings behind three surfaces whose PIXELS are device-QA but whose *choice of what to
|
||||
* say* is a correctness property:
|
||||
* - [queueNoticeText] — every queue failure has its own honest line, and a server-supplied message is
|
||||
* never allowed to render as the literal "null";
|
||||
* - [rotationStatusText] — a silent renewal stays SILENT, and only the three actionable outcomes speak;
|
||||
* - [copyResultText] — a refused clipboard write says so instead of pretending it copied.
|
||||
* None of them may leak a secret: no copied text, no `Throwable.message`, no credential.
|
||||
*/
|
||||
class SurfaceCopyMappingTest {
|
||||
|
||||
// ── Queue notices ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `every queue notice has its own non-blank line`() {
|
||||
val notices = listOf(
|
||||
QueueNotice.Full,
|
||||
QueueNotice.TooLarge,
|
||||
QueueNotice.Disabled,
|
||||
QueueNotice.SessionGone,
|
||||
QueueNotice.RateLimited,
|
||||
QueueNotice.Unreachable,
|
||||
QueueNotice.Rejected("forbidden"),
|
||||
)
|
||||
val texts = notices.map(::queueNoticeText)
|
||||
|
||||
assertTrue(texts.all { it.isNotBlank() })
|
||||
assertEquals(notices.size, texts.distinct().size, "each notice needs its own copy: $texts")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the rate-limit line states the shared limit and offers no retry`() {
|
||||
val text = queueNoticeText(QueueNotice.RateLimited)
|
||||
assertTrue(text.contains("20"), text) // the shared 20/min/IP bucket, named honestly
|
||||
assertTrue(text.contains("手动"), text) // any retry is the USER's, never automatic
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rejection with no server message falls back to app copy`() {
|
||||
val text = queueNoticeText(QueueNotice.Rejected(null))
|
||||
assertTrue(text.isNotBlank())
|
||||
assertFalse(text.contains("null"), text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server message is surfaced verbatim`() {
|
||||
assertTrue(queueNoticeText(QueueNotice.Rejected("worktree is dirty")).contains("worktree is dirty"))
|
||||
}
|
||||
|
||||
// ── Rotation status ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a healthy or in-progress rotation says nothing`() {
|
||||
assertNull(rotationStatusText(null))
|
||||
assertNull(rotationStatusText(RotationOutcome.NotEnrolled))
|
||||
assertNull(rotationStatusText(RotationOutcome.Renewed))
|
||||
assertNull(rotationStatusText(RotationOutcome.Recovered))
|
||||
assertNull(rotationStatusText(RotationOutcome.NotDue(Instant.EPOCH)))
|
||||
assertNull(rotationStatusText(RotationOutcome.BackingOff(Instant.EPOCH)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the three actionable outcomes all speak`() {
|
||||
assertNotNull(rotationStatusText(RotationOutcome.ReEnrollRequired(Duration.ofDays(40))))
|
||||
assertNotNull(rotationStatusText(RotationOutcome.NotConfigured("device-1")))
|
||||
assertNotNull(rotationStatusText(RotationOutcome.Failed(RotationStage.RENEW, "Http(429)")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure line carries the error SHAPE and says the current cert still works`() {
|
||||
val text = rotationStatusText(RotationOutcome.Failed(RotationStage.RECOVER, "Http(403,revoked)"))
|
||||
assertNotNull(text)
|
||||
assertTrue(text!!.contains("Http(403,revoked)"), text)
|
||||
assertTrue(text.contains("recover"), text) // which STEP failed
|
||||
assertTrue(text.contains("仍然有效"), text) // the prior identity is untouched
|
||||
}
|
||||
|
||||
// ── Copy-out ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `every copy outcome is reported distinctly and a refusal is not called success`() {
|
||||
val texts = TerminalCopyResult.entries.map(::copyResultText) + copyResultText(null)
|
||||
|
||||
assertTrue(texts.all { it.isNotBlank() })
|
||||
assertEquals(texts.size, texts.distinct().size, "each copy outcome needs its own copy: $texts")
|
||||
// A refused clipboard write must NOT read like a success.
|
||||
assertFalse(copyResultText(TerminalCopyResult.CLIPBOARD_UNAVAILABLE).contains("已复制"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
|
||||
import wang.yaojia.webterm.api.enroll.DeviceRenewalState
|
||||
import wang.yaojia.webterm.api.enroll.RotationPolicy
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.tlsandroid.CertStore
|
||||
import wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot
|
||||
import wang.yaojia.webterm.tlsandroid.EnrollmentRecord
|
||||
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
|
||||
import wang.yaojia.webterm.tlsandroid.StoredIdentityMetadata
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* The silent-rotation DRIVER: it reads the installed identity's timing, asks the pure `RotationPolicy`
|
||||
* what to do, and drives the matching call. iOS ships `CertificateRotationScheduler`; Android had no
|
||||
* counterpart at all, so a device certificate simply died.
|
||||
*
|
||||
* The behaviours under test here are the ones that make it safe to fire on every app foreground:
|
||||
* - it is IDEMPOTENT and single-flight — a second trigger while one pass is in flight coalesces,
|
||||
* - a failure is SURFACED (observable outcome) and leaves the prior identity fully live,
|
||||
* - a failure THROTTLES the next attempt instead of hammering a rate-limited control plane, and
|
||||
* - nothing it logs or surfaces can carry a credential.
|
||||
*/
|
||||
class CertificateRotationSchedulerTest {
|
||||
private companion object {
|
||||
val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z")
|
||||
val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z")
|
||||
const val CP_URL = "https://cp.terminal.yaojia.wang"
|
||||
|
||||
/** A base64-looking string standing in for credential material in an error message. */
|
||||
const val LEAKY_SECRET = "MIIBfzCCASWgAwIBAgIUSECRETCSRBYTES"
|
||||
}
|
||||
|
||||
private val logLines = mutableListOf<String>()
|
||||
private var clock: Instant = RENEW_AFTER
|
||||
private var snapshot: DeviceRotationSnapshot? = snapshot()
|
||||
private var readFault: Exception? = null
|
||||
private val renewUrls = mutableListOf<String>()
|
||||
private val recoverUrls = mutableListOf<String>()
|
||||
private var renewFault: Exception? = null
|
||||
private var recoverFault: Exception? = null
|
||||
|
||||
/** Optional gate a test can arm to hold `renew` suspended (single-flight coalescing). */
|
||||
private var renewGate: CompletableDeferred<Unit>? = null
|
||||
|
||||
private fun snapshot(
|
||||
notAfter: Instant? = NOT_AFTER,
|
||||
renewAfter: Instant? = RENEW_AFTER,
|
||||
controlPlaneUrl: String = CP_URL,
|
||||
): DeviceRotationSnapshot = DeviceRotationSnapshot(
|
||||
renewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter),
|
||||
controlPlaneUrl = controlPlaneUrl,
|
||||
)
|
||||
|
||||
private fun scheduler(): CertificateRotationScheduler = CertificateRotationScheduler(
|
||||
readState = {
|
||||
readFault?.let { throw it }
|
||||
snapshot
|
||||
},
|
||||
renew = { url ->
|
||||
renewUrls += url
|
||||
renewGate?.await()
|
||||
renewFault?.let { throw it }
|
||||
},
|
||||
recover = { url ->
|
||||
recoverUrls += url
|
||||
recoverFault?.let { throw it }
|
||||
},
|
||||
now = { clock },
|
||||
log = { logLines += it },
|
||||
)
|
||||
|
||||
// ── the wait branches ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun nothingEnrolledDoesNoWorkAtAll() = runTest {
|
||||
snapshot = null
|
||||
|
||||
assertEquals(RotationOutcome.NotEnrolled, scheduler().runIfDue())
|
||||
|
||||
assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCertificateInsideItsRenewWindowIsLeftAlone() = runTest {
|
||||
clock = RENEW_AFTER.minusSeconds(1)
|
||||
|
||||
assertEquals(RotationOutcome.NotDue(RENEW_AFTER), scheduler().runIfDue())
|
||||
|
||||
assertTrue(renewUrls.isEmpty(), "the common case must be free")
|
||||
}
|
||||
|
||||
// ── renew ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun aDueCertificateRenewsAgainstThePersistedControlPlane() = runTest {
|
||||
val subject = scheduler()
|
||||
|
||||
assertEquals(RotationOutcome.Renewed, subject.runIfDue())
|
||||
|
||||
assertEquals(listOf(CP_URL), renewUrls, "renew targets the control plane the device enrolled with")
|
||||
assertTrue(recoverUrls.isEmpty())
|
||||
assertEquals(RotationOutcome.Renewed, subject.lastOutcome.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBlankControlPlaneUrlRefusesToGuessAHost() = runTest {
|
||||
// A record written before the URL was persisted has no rotation target. Renewing against a
|
||||
// compiled-in default would talk to somebody else's control plane.
|
||||
snapshot = snapshot(controlPlaneUrl = " ")
|
||||
|
||||
assertEquals(RotationOutcome.NotConfigured("dev-1"), scheduler().runIfDue())
|
||||
|
||||
assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty(), "no request without a known host")
|
||||
}
|
||||
|
||||
// ── recover ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun anExpiredCertificateRecoversAndNeverAttemptsAnMtlsRenew() = runTest {
|
||||
clock = NOT_AFTER.plus(Duration.ofDays(8))
|
||||
|
||||
assertEquals(RotationOutcome.Recovered, scheduler().runIfDue())
|
||||
|
||||
// An expired leaf cannot authenticate its own renewal, so a renew attempt here is not merely
|
||||
// useless — it is the production deadlock this whole path exists to break.
|
||||
assertTrue(renewUrls.isEmpty(), "an expired leaf must never be sent to the mTLS renew route")
|
||||
assertEquals(listOf(CP_URL), recoverUrls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCertificateExpiredBeyondTheGraceWindowIsTerminalAndSilent() = runTest {
|
||||
clock = NOT_AFTER.plus(Duration.ofDays(31))
|
||||
|
||||
val outcome = scheduler().runIfDue()
|
||||
|
||||
assertEquals(RotationOutcome.ReEnrollRequired(Duration.ofDays(31)), outcome)
|
||||
assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty(), "no request can succeed — make none")
|
||||
}
|
||||
|
||||
// ── failure posture ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun aFailedRenewIsSurfacedAndTheStageIsIdentified() = runTest {
|
||||
renewFault = DeviceEnrollmentError.Http(429, "rate_limited")
|
||||
val subject = scheduler()
|
||||
|
||||
val outcome = subject.runIfDue()
|
||||
|
||||
assertEquals(RotationOutcome.Failed(RotationStage.RENEW, "Http(429,rate_limited)"), outcome)
|
||||
// Observable, not only a log line — iOS surfaces the same as `isCertificateRenewalFailing`.
|
||||
assertEquals(outcome, subject.lastOutcome.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedRecoveryIsSurfacedSeparatelyFromAFailedRenew() = runTest {
|
||||
clock = NOT_AFTER.plus(Duration.ofDays(8))
|
||||
recoverFault = IOException("connection reset")
|
||||
|
||||
assertEquals(
|
||||
RotationOutcome.Failed(RotationStage.RECOVER, "IOException"),
|
||||
scheduler().runIfDue(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStateReadFaultIsSurfacedRatherThanReportedAsNotEnrolled() = runTest {
|
||||
// Degrading a Tink/keystore fault to "nothing enrolled" would disable rotation forever.
|
||||
readFault = IllegalStateException("keystore unavailable")
|
||||
|
||||
assertEquals(
|
||||
RotationOutcome.Failed(RotationStage.READ_STATE, "IllegalStateException"),
|
||||
scheduler().runIfDue(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailureThrottlesTheNextPassInsteadOfHammering() = runTest {
|
||||
renewFault = IOException("connection reset")
|
||||
val subject = scheduler()
|
||||
subject.runIfDue()
|
||||
|
||||
clock = RENEW_AFTER.plus(Duration.ofMinutes(1))
|
||||
val second = subject.runIfDue()
|
||||
|
||||
assertEquals(
|
||||
RotationOutcome.BackingOff(RENEW_AFTER.plus(RotationPolicy.DEFAULT_FAILURE_BACKOFF)),
|
||||
second,
|
||||
)
|
||||
assertEquals(1, renewUrls.size, "the control plane rate-limits renewals — one failure, one call")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSuccessClearsTheThrottle() = runTest {
|
||||
renewFault = IOException("connection reset")
|
||||
val subject = scheduler()
|
||||
subject.runIfDue()
|
||||
|
||||
renewFault = null
|
||||
clock = RENEW_AFTER.plus(RotationPolicy.DEFAULT_FAILURE_BACKOFF)
|
||||
assertEquals(RotationOutcome.Renewed, subject.runIfDue())
|
||||
// Still due (the fake state does not change), so a following pass attempts again immediately —
|
||||
// proving the recorded failure was cleared rather than throttling forever.
|
||||
assertEquals(RotationOutcome.Renewed, subject.runIfDue())
|
||||
assertEquals(3, renewUrls.size)
|
||||
}
|
||||
|
||||
// ── single-flight / idempotence ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun aSecondTriggerWhileOneIsInFlightCoalescesIntoTheSamePass() = runTest {
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
renewGate = gate
|
||||
val subject = scheduler()
|
||||
|
||||
val first = async { subject.runIfDue() }
|
||||
val second = async { subject.runIfDue() }
|
||||
// Let both actually start: the first parks inside `renew`, the second must then find the pass
|
||||
// already in flight and park on ITS result. Without this the first pass would finish before
|
||||
// the second even began, and the test would prove nothing.
|
||||
testScheduler.runCurrent()
|
||||
gate.complete(Unit)
|
||||
|
||||
assertEquals(RotationOutcome.Renewed, first.await())
|
||||
assertEquals(RotationOutcome.Renewed, second.await())
|
||||
assertEquals(1, renewUrls.size, "two foreground triggers must not stampede two renewals")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCancelledPassDoesNotWedgeTheScheduler() = runTest {
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
renewGate = gate
|
||||
val subject = scheduler()
|
||||
|
||||
val cancelled = launch { subject.runIfDue() }
|
||||
cancelled.cancel()
|
||||
cancelled.join()
|
||||
|
||||
// The in-flight slot must have been released, or every later pass would await a deferred that
|
||||
// can never complete — rotation dead until the process restarts.
|
||||
renewGate = null
|
||||
assertEquals(RotationOutcome.Renewed, subject.runIfDue())
|
||||
}
|
||||
|
||||
// ── credential hygiene ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun neitherTheLogNorTheOutcomeCanCarryACredential() = runTest {
|
||||
// A real exception message can embed a URL, a body or, as here, cert/CSR material. The driver
|
||||
// must report the error SHAPE only — never the message.
|
||||
renewFault = IllegalStateException("csr=$LEAKY_SECRET token=hunter2")
|
||||
val subject = scheduler()
|
||||
|
||||
val outcome = subject.runIfDue()
|
||||
|
||||
val rendered = "$outcome ${logLines.joinToString(" ")}"
|
||||
assertFalse(rendered.contains(LEAKY_SECRET), "no certificate/CSR material may be logged or surfaced")
|
||||
assertFalse(rendered.contains("hunter2"), "no token/passphrase may be logged or surfaced")
|
||||
assertFalse(rendered.contains("csr="), "not even the message fragment")
|
||||
assertEquals(RotationOutcome.Failed(RotationStage.RENEW, "IllegalStateException"), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSuccessLogNamesTheDeviceAndNothingElse() = runTest {
|
||||
scheduler().runIfDue()
|
||||
|
||||
assertTrue(logLines.isNotEmpty(), "a silent rotation must still leave a trace")
|
||||
val rendered = logLines.joinToString(" ")
|
||||
assertTrue(rendered.contains("dev-1"), "the non-secret device id is what makes the line useful")
|
||||
assertFalse(rendered.contains("MII"), "no DER/PEM material")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLastOutcomeStartsEmpty() = runTest {
|
||||
assertNull(scheduler().lastOutcome.value, "nothing is claimed before a pass has run")
|
||||
}
|
||||
|
||||
// ── the composition root ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun createBuildsAWorkingSchedulerOverTheRealCollaborators() = runTest {
|
||||
// `create` is the ONE place that knows both halves (:api-client + :client-tls-android). This
|
||||
// smoke test runs it over the real DeviceRotationStore / DeviceEnroller types with empty
|
||||
// stores, so a broken composition root fails here rather than silently at runtime.
|
||||
val scheduler = CertificateRotationScheduler.create(
|
||||
certStore = EmptyCertStore,
|
||||
recordStore = EmptyRecordStore,
|
||||
cacheRefresher = IdentityCacheRefresher {},
|
||||
sharedClient = { OkHttpClient() },
|
||||
mtlsTransport = { FakeHttpTransport() },
|
||||
plainTransport = { FakeHttpTransport() },
|
||||
ioDispatcher = StandardTestDispatcher(testScheduler),
|
||||
now = { clock },
|
||||
log = { logLines += it },
|
||||
)
|
||||
|
||||
assertEquals(RotationOutcome.NotEnrolled, scheduler.runIfDue())
|
||||
}
|
||||
|
||||
private object EmptyCertStore : CertStore {
|
||||
override fun save(metadata: StoredIdentityMetadata): Unit = error("not used")
|
||||
|
||||
override fun load(): StoredIdentityMetadata? = null
|
||||
|
||||
override fun clear(): Unit = error("not used")
|
||||
}
|
||||
|
||||
private object EmptyRecordStore : EnrollmentRecordStore {
|
||||
override fun save(record: EnrollmentRecord): Unit = error("not used")
|
||||
|
||||
override fun load(): EnrollmentRecord? = null
|
||||
|
||||
override fun clear(): Unit = error("not used")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* [CertificateRotationTrigger] — the app-lifecycle edge that finally CALLS
|
||||
* [CertificateRotationScheduler]. Without it the scheduler is dead code and a device certificate simply
|
||||
* expires, stranding the app (the iOS `AppCoordinator.runCertificateRotationIfDue()` counterpart).
|
||||
*
|
||||
* The trigger is deliberately fire-and-forget: `AppEnvironment.warmUp()` is awaited by the terminal
|
||||
* screen's readiness gate, so a rotation's NETWORK call must never be able to hold the UI. The
|
||||
* properties asserted here are the ones that make that safe — it launches rather than suspends, it never
|
||||
* lets a failure escape, and a scheduler that cannot even be constructed does not crash the app.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CertificateRotationTriggerTest {
|
||||
|
||||
/** A scheduler whose store read is scripted; `null` state ⇒ NotEnrolled (no network at all). */
|
||||
private fun scheduler(
|
||||
readState: suspend () -> wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot? = { null },
|
||||
) = CertificateRotationScheduler(
|
||||
readState = readState,
|
||||
renew = { },
|
||||
recover = { },
|
||||
now = { Instant.parse("2026-07-30T00:00:00Z") },
|
||||
log = { },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `firing runs one rotation pass and publishes its outcome`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val live = scheduler()
|
||||
val trigger = CertificateRotationTrigger(scope = this, scheduler = { live })
|
||||
|
||||
trigger.fire()
|
||||
|
||||
assertEquals(RotationOutcome.NotEnrolled, live.lastOutcome.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a store fault becomes a Failed outcome, never a thrown exception`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val live = scheduler(readState = { throw IllegalStateException("keystore unavailable") })
|
||||
val trigger = CertificateRotationTrigger(scope = this, scheduler = { live })
|
||||
|
||||
trigger.fire() // must not throw
|
||||
|
||||
val outcome = live.lastOutcome.value
|
||||
assertTrue(outcome is RotationOutcome.Failed, "expected Failed, got $outcome")
|
||||
assertEquals(RotationStage.READ_STATE, (outcome as RotationOutcome.Failed).stage)
|
||||
// Only the SHAPE is kept — never a message that could embed a URL or credential material.
|
||||
assertEquals("IllegalStateException", outcome.errorShape)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a scheduler that cannot be resolved is reported, not crashed`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val errors = mutableListOf<Throwable>()
|
||||
val trigger = CertificateRotationTrigger(
|
||||
scope = this,
|
||||
scheduler = { throw IllegalStateException("graph not ready") },
|
||||
onError = { errors += it },
|
||||
)
|
||||
|
||||
trigger.fire()
|
||||
|
||||
assertEquals(1, errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fire never suspends the caller on the rotation itself`() = runTest {
|
||||
// A scheduler whose read blocks forever: `fire()` must still return immediately. If it awaited the
|
||||
// pass, this test would hang instead of completing (runTest would report the coroutine as stuck).
|
||||
val live = scheduler(readState = { kotlinx.coroutines.awaitCancellation() })
|
||||
val scope = TestScope(UnconfinedTestDispatcher(testScheduler))
|
||||
val trigger = CertificateRotationTrigger(scope = scope, scheduler = { live })
|
||||
|
||||
trigger.fire()
|
||||
|
||||
assertNull(live.lastOutcome.value) // still in flight, and we got control back
|
||||
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* [HostRemover] — un-pairing a host must erase EVERY trace keyed off it, in the one order that works.
|
||||
*
|
||||
* The bug this closes: `PushRegistrar.unregisterHost` had zero call sites, so a dropped host kept the
|
||||
* device's FCM token forever and kept pushing notifications for a host the user had removed.
|
||||
*
|
||||
* Two rules are asserted because getting either wrong is worse than not removing at all:
|
||||
* - **the remote DELETE happens FIRST**, while the host record and its `webterm_auth` cookie still
|
||||
* exist — erase them first and the DELETE can neither be addressed nor authenticated;
|
||||
* - **local erasure is all-or-nothing and `HostStore.remove` is LAST**, so a failure part-way leaves the
|
||||
* host paired and the operation retryable, never a ghost with orphaned cookies.
|
||||
* The remote DELETE itself is BEST-EFFORT: a host that is switched off must still be removable.
|
||||
*/
|
||||
class HostRemoverTest {
|
||||
|
||||
private companion object {
|
||||
const val BASE = "http://laptop.local:3000"
|
||||
val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
val SESSION_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")
|
||||
|
||||
fun host(id: String = "h1"): Host = Host.create(id = id, name = "laptop", baseUrl = BASE)!!
|
||||
}
|
||||
|
||||
private class RecordingHostStore(initial: List<Host>) : HostStore {
|
||||
var hosts: List<Host> = initial
|
||||
var removeError: Throwable? = null
|
||||
override suspend fun loadAll(): List<Host> = hosts
|
||||
override suspend fun upsert(host: Host): List<Host> = hosts
|
||||
override suspend fun remove(id: String): List<Host> {
|
||||
removeError?.let { throw it }
|
||||
hosts = hosts.filterNot { it.id == id }
|
||||
return hosts
|
||||
}
|
||||
}
|
||||
|
||||
/** Records the (host, token) pairs handed to the push DELETE — and whether it was called at all. */
|
||||
private class RecordingPush(private val error: Throwable? = null) : HostPushUnregister {
|
||||
val calls = mutableListOf<Pair<String, String>>()
|
||||
var hostStillPairedAtCallTime: Boolean? = null
|
||||
var probe: (() -> Boolean)? = null
|
||||
|
||||
override suspend fun unregister(host: Host, token: String) {
|
||||
hostStillPairedAtCallTime = probe?.invoke()
|
||||
calls += host.id to token
|
||||
error?.let { throw it }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fixture(
|
||||
hosts: RecordingHostStore = RecordingHostStore(listOf(host())),
|
||||
push: RecordingPush = RecordingPush(),
|
||||
token: String? = "fcm-token",
|
||||
cookieJarClears: MutableList<String> = mutableListOf(),
|
||||
): Triple<HostRemover, RecordingPush, Quad> {
|
||||
val lastSession = InMemoryLastSessionStore()
|
||||
val cookies = InMemoryAuthCookieStore()
|
||||
val watermarks = InMemorySessionWatermarkStore()
|
||||
lastSession.setLastSessionId(SESSION_1.toString(), "h1")
|
||||
cookies.upsert(
|
||||
AuthCookieRecord(
|
||||
hostKey = "http://laptop.local:3000",
|
||||
name = "webterm_auth",
|
||||
value = "secret",
|
||||
domain = "laptop.local",
|
||||
path = "/",
|
||||
expiresAtEpochMillis = Long.MAX_VALUE,
|
||||
secure = false,
|
||||
httpOnly = true,
|
||||
hostOnly = true,
|
||||
),
|
||||
)
|
||||
watermarks.replaceAll(mapOf(SESSION_1.toString() to 10L, SESSION_2.toString() to 20L))
|
||||
val remover = HostRemover(
|
||||
hostStore = hosts,
|
||||
lastSessionStore = lastSession,
|
||||
authCookieStore = cookies,
|
||||
watermarkStore = watermarks,
|
||||
pushUnregister = push,
|
||||
currentPushToken = { token },
|
||||
clearLiveCookies = { key -> cookieJarClears += key },
|
||||
log = { }, // android.util.Log is not mocked under JVM unit tests
|
||||
)
|
||||
return Triple(remover, push, Quad(hosts, lastSession, cookies, watermarks))
|
||||
}
|
||||
|
||||
private data class Quad(
|
||||
val hosts: RecordingHostStore,
|
||||
val lastSession: InMemoryLastSessionStore,
|
||||
val cookies: InMemoryAuthCookieStore,
|
||||
val watermarks: InMemorySessionWatermarkStore,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `removing a host erases every trace keyed off it`() = runTest {
|
||||
val jarClears = mutableListOf<String>()
|
||||
val (remover, push, stores) = fixture(cookieJarClears = jarClears)
|
||||
|
||||
remover.removeHost("h1", listOf(SESSION_1.toString(), SESSION_2.toString()))
|
||||
|
||||
assertEquals(listOf("h1" to "fcm-token"), push.calls.toList()) // the DELETE finally happens
|
||||
assertTrue(stores.hosts.hosts.isEmpty())
|
||||
assertNull(stores.lastSession.lastSessionId("h1"))
|
||||
assertTrue(stores.cookies.loadAll().isEmpty())
|
||||
assertEquals(listOf("http://laptop.local:3000"), jarClears) // live jar too, not just at rest
|
||||
assertTrue(stores.watermarks.loadAll().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the remote DELETE runs while the host is still paired`() = runTest {
|
||||
val hosts = RecordingHostStore(listOf(host()))
|
||||
val push = RecordingPush()
|
||||
push.probe = { hosts.hosts.isNotEmpty() }
|
||||
val (remover, _, _) = fixture(hosts = hosts, push = push)
|
||||
|
||||
remover.removeHost("h1", emptyList())
|
||||
|
||||
assertTrue(push.hostStillPairedAtCallTime == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreachable host is still removable`() = runTest {
|
||||
val push = RecordingPush(error = java.io.IOException("host is off"))
|
||||
val (remover, _, stores) = fixture(push = push)
|
||||
|
||||
remover.removeHost("h1", listOf(SESSION_1.toString()))
|
||||
|
||||
assertTrue(stores.hosts.hosts.isEmpty()) // best-effort DELETE must not block the removal
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with no push token the local state is still fully erased`() = runTest {
|
||||
val push = RecordingPush()
|
||||
val (remover, _, stores) = fixture(push = push, token = null)
|
||||
|
||||
remover.removeHost("h1", listOf(SESSION_1.toString()))
|
||||
|
||||
assertTrue(push.calls.isEmpty()) // nothing to DELETE
|
||||
assertTrue(stores.hosts.hosts.isEmpty())
|
||||
assertNull(stores.lastSession.lastSessionId("h1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing local erase leaves the host paired so the user can retry`() = runTest {
|
||||
val hosts = RecordingHostStore(listOf(host()))
|
||||
hosts.removeError = IllegalStateException("datastore write failed")
|
||||
val (remover, _, stores) = fixture(hosts = hosts)
|
||||
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
kotlinx.coroutines.runBlocking { remover.removeHost("h1", listOf(SESSION_1.toString())) }
|
||||
}
|
||||
|
||||
assertEquals(1, stores.hosts.hosts.size) // still paired → retryable, never a ghost
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown host id is a no-op that touches nothing`() = runTest {
|
||||
val push = RecordingPush()
|
||||
val (remover, _, stores) = fixture(push = push)
|
||||
|
||||
remover.removeHost("nope", listOf(SESSION_1.toString()))
|
||||
|
||||
assertTrue(push.calls.isEmpty())
|
||||
assertEquals(1, stores.hosts.hosts.size)
|
||||
assertFalse(stores.watermarks.loadAll().isEmpty()) // watermarks untouched
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the named sessions lose their watermarks`() = runTest {
|
||||
val (remover, _, stores) = fixture()
|
||||
|
||||
remover.removeHost("h1", listOf(SESSION_1.toString()))
|
||||
|
||||
assertEquals(mapOf(SESSION_2.toString() to 20L), stores.watermarks.loadAll())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.UiConfig
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* [UiConfigGate] — the client half of `GET /config/ui`'s `allowAutoMode`.
|
||||
*
|
||||
* `ApiClient.uiConfig()` was implemented and never called, so the plan-gate sheet offered
|
||||
* "approve + auto-accept edits" even on a host whose operator had switched `ALLOW_AUTO_MODE` off. The
|
||||
* gate closes that, and the ONE rule that matters is that **failure fails CLOSED**: an unreachable host,
|
||||
* a pre-`/config/ui` server, or a garbled body must all mean "auto is not allowed", because the
|
||||
* permissive default is the dangerous one.
|
||||
*/
|
||||
class UiConfigGateTest {
|
||||
|
||||
private companion object {
|
||||
val HOST_A: HostEndpoint = HostEndpoint.fromBaseUrl("http://a:3000")!!
|
||||
val HOST_B: HostEndpoint = HostEndpoint.fromBaseUrl("http://b:3000")!!
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto mode is allowed when the host says so`() = runTest {
|
||||
var calls = 0
|
||||
val gate = UiConfigGate { calls += 1; UiConfig(allowAutoMode = true) }
|
||||
|
||||
assertTrue(gate.allowsAutoMode(HOST_A))
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto mode is refused when the host disabled it`() = runTest {
|
||||
val gate = UiConfigGate { UiConfig(allowAutoMode = false) }
|
||||
assertFalse(gate.allowsAutoMode(HOST_A))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed fetch fails CLOSED`() = runTest {
|
||||
val gate = UiConfigGate { throw java.io.IOException("host unreachable") }
|
||||
assertFalse(gate.allowsAutoMode(HOST_A))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful answer is cached per host`() = runTest {
|
||||
val seen = mutableListOf<String>()
|
||||
val gate = UiConfigGate { endpoint ->
|
||||
seen += endpoint.baseUrl
|
||||
UiConfig(allowAutoMode = endpoint == HOST_A)
|
||||
}
|
||||
|
||||
assertTrue(gate.allowsAutoMode(HOST_A))
|
||||
assertTrue(gate.allowsAutoMode(HOST_A)) // served from cache
|
||||
assertFalse(gate.allowsAutoMode(HOST_B)) // a DIFFERENT host is fetched, not inherited
|
||||
|
||||
assertEquals(listOf(HOST_A.baseUrl, HOST_B.baseUrl), seen.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure is NOT cached so a host that comes back is re-asked`() = runTest {
|
||||
var attempt = 0
|
||||
val gate = UiConfigGate {
|
||||
attempt += 1
|
||||
if (attempt == 1) throw java.io.IOException("down") else UiConfig(allowAutoMode = true)
|
||||
}
|
||||
|
||||
assertFalse(gate.allowsAutoMode(HOST_A)) // fail closed
|
||||
assertTrue(gate.allowsAutoMode(HOST_A)) // recovered → now allowed
|
||||
assertEquals(2, attempt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancellation propagates instead of being read as a policy answer`() = runTest {
|
||||
val gate = UiConfigGate { throw CancellationException("scope cancelled") }
|
||||
assertThrows(CancellationException::class.java) {
|
||||
kotlinx.coroutines.runBlocking { gate.allowsAutoMode(HOST_A) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,17 @@ public class DeviceEnroller(
|
||||
// AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no
|
||||
// process restart. Optional so the JVM orchestration tests can construct the enroller without it.
|
||||
private val cacheRefresher: IdentityCacheRefresher? = null,
|
||||
/**
|
||||
* A SECOND client over a PLAIN (non-mTLS) transport, used ONLY by [recover]. It must not be the
|
||||
* same client as [client]: an expired leaf cannot authenticate its own re-issuance, and an mTLS
|
||||
* transport would present that expired leaf, which nginx refuses to forward at all (bare 400).
|
||||
* Null ⇒ recovery is unavailable and [recover] throws rather than falling back to mTLS.
|
||||
*
|
||||
* It MUST target the same control plane as [client] — the enrollment record stores [client]'s
|
||||
* base URL as the rotation target, so a recovery pointed elsewhere would rewrite the identity
|
||||
* while leaving the record naming the original host.
|
||||
*/
|
||||
private val recoveryClient: DeviceEnrollmentClient? = null,
|
||||
) {
|
||||
private val commitMutex = Mutex()
|
||||
|
||||
@@ -97,6 +108,40 @@ public class DeviceEnroller(
|
||||
summaryOf(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover an EXPIRED leaf: re-CSR from the SAME hardware key and POST it together with the lapsed
|
||||
* certificate to `POST /device/:id/recover` over the PLAIN (non-mTLS) [recoveryClient].
|
||||
*
|
||||
* This exists because `/device/:id/renew` is authenticated by the very certificate it renews, so a
|
||||
* lapsed leaf deadlocks: no TLS terminator will even forward an expired client certificate (nginx
|
||||
* answers a bare 400 under `ssl_verify_client optional`, and `optional_no_ca` tolerates only CHAIN
|
||||
* errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). Without this call an expired device certificate
|
||||
* means a full manual re-enroll. Possession is still proven — the CSR is self-signed by the same
|
||||
* non-exportable key and the control plane enforces CSR proof-of-possession plus
|
||||
* `CSR key == registered key` — and revocation, account/`:id` consistency and `notBefore` are all
|
||||
* still enforced server-side; only `notAfter` is graced (30 days).
|
||||
*
|
||||
* Failure posture: everything before [commitIdentity]'s cert-store save is read-only, so a rejected
|
||||
* or unreachable recovery leaves the prior identity and its key exactly as they were.
|
||||
*
|
||||
* Throws [EnrollmentStateException] when there is no [recoveryClient], no enrollment record, no
|
||||
* stored certificate to present, or no hardware key — none of which a request could fix.
|
||||
*/
|
||||
public suspend fun recover(): CertificateSummary = commitMutex.withLock {
|
||||
val recovery = recoveryClient
|
||||
?: throw EnrollmentStateException("no plain-HTTPS recovery client — refusing to recover over mTLS")
|
||||
val record = recordStore.load()
|
||||
?: throw EnrollmentStateException("no enrollment record — nothing to recover")
|
||||
val expiredLeaf = certStore.load()?.leafCertificate
|
||||
?: throw EnrollmentStateException("no stored device certificate — a fresh enroll is required")
|
||||
val key = keyProvider.load(record.keyStoreAlias)
|
||||
?: throw EnrollmentStateException("device key missing — a fresh enroll is required")
|
||||
val csr = CertificateSigningRequest.der(record.deviceName, key)
|
||||
val result = recovery.recover(record.deviceId, expiredLeaf.encoded, csr)
|
||||
commitIdentity(result, record.deviceName, record.keyStoreAlias)
|
||||
summaryOf(result)
|
||||
}
|
||||
|
||||
/** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */
|
||||
public suspend fun remove(): Unit = commitMutex.withLock {
|
||||
certStore.clear()
|
||||
@@ -120,7 +165,13 @@ public class DeviceEnroller(
|
||||
deviceId = result.deviceId,
|
||||
deviceName = deviceName,
|
||||
keyStoreAlias = alias,
|
||||
// Only an enroll 201 carries renewAfter; a renew/recover 201 does not, so this is 0
|
||||
// after a rotation. That is fine — DeviceRotationStore derives the live timing from
|
||||
// the leaf certificate, never from this advisory field.
|
||||
renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L,
|
||||
// Persist WHICH control plane issued this identity so silent rotation (which has no UI
|
||||
// to ask on) renews against the same one instead of a compiled-in default.
|
||||
controlPlaneUrl = client.baseUrl,
|
||||
),
|
||||
)
|
||||
certStore.save(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import wang.yaojia.webterm.api.enroll.DeviceRenewalState
|
||||
import wang.yaojia.webterm.api.enroll.RotationPolicy
|
||||
|
||||
/**
|
||||
* Everything a rotation pass needs to know, read from storage: the pure [renewalState] the decision
|
||||
* table consumes plus [controlPlaneUrl], the host to rotate against.
|
||||
*
|
||||
* Carries no credential: only the non-secret device id, the leaf's timing, and a URL. Safe to log.
|
||||
*/
|
||||
public data class DeviceRotationSnapshot(
|
||||
val renewalState: DeviceRenewalState,
|
||||
val controlPlaneUrl: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* The READ side of silent certificate rotation — the Android counterpart of iOS
|
||||
* `KeychainClientIdentityStore.renewalState()`, composing the two persisted stores into one snapshot.
|
||||
*
|
||||
* ### Why the timing comes from the LEAF, not the record
|
||||
* `POST /device/:id/renew` and `POST /device/:id/recover` answer `{cert, caChain, notAfter}` — neither
|
||||
* returns a `renewAfter` (only `/device/enroll` does). A driver that trusted
|
||||
* [EnrollmentRecord.renewAfterEpochSeconds] would therefore see "unknown" after the very first
|
||||
* rotation and report not-due until the certificate died — exactly once-and-never-again rotation. The
|
||||
* live leaf's own `notBefore`/`notAfter` are always present and are what the TLS stack actually
|
||||
* enforces, so they are the source of truth; [RotationPolicy.renewAfterFor] re-derives the advisory
|
||||
* instant from them with the control plane's own 2/3-of-lifetime fraction.
|
||||
*
|
||||
* Deliberately I/O-only and framework-free (no `android.*` import), so the whole read path is JVM
|
||||
* unit-tested. A store fault PROPAGATES rather than degrading to "nothing enrolled": silently
|
||||
* reporting not-enrolled would disable rotation forever, whereas a surfaced failure is retried.
|
||||
*/
|
||||
public class DeviceRotationStore(
|
||||
private val certStore: CertStore,
|
||||
private val recordStore: EnrollmentRecordStore,
|
||||
) {
|
||||
/**
|
||||
* The current rotation snapshot, or null when there is nothing to rotate — no enrollment record
|
||||
* (fresh install / legacy `.p12`-only identity) or no live certificate (a renew has nothing to
|
||||
* re-CSR for and a recovery has no lapsed leaf to present, so only a fresh enroll can help).
|
||||
*
|
||||
* Performs AndroidKeyStore/Tink-backed reads via the injected stores: call it OFF the main thread.
|
||||
*/
|
||||
public fun read(): DeviceRotationSnapshot? {
|
||||
val record = recordStore.load() ?: return null
|
||||
val leaf = certStore.load()?.leafCertificate ?: return null
|
||||
return DeviceRotationSnapshot(
|
||||
renewalState = DeviceRenewalState(
|
||||
deviceId = record.deviceId,
|
||||
notAfter = leaf.notAfter.toInstant(),
|
||||
renewAfter = RotationPolicy.renewAfterFor(
|
||||
notBefore = leaf.notBefore.toInstant(),
|
||||
notAfter = leaf.notAfter.toInstant(),
|
||||
),
|
||||
),
|
||||
controlPlaneUrl = record.controlPlaneUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,23 @@ import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.EOFException
|
||||
|
||||
/**
|
||||
* B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted
|
||||
* [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR
|
||||
* subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign
|
||||
* with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler.
|
||||
* with, [renewAfterEpochSeconds] (0 = unknown) as issued at enroll time, and [controlPlaneUrl] —
|
||||
* WHICH control plane to rotate against.
|
||||
*
|
||||
* [controlPlaneUrl] exists because rotation is silent: there is no screen to re-type the URL on, and
|
||||
* falling back to a compiled-in default would renew against the wrong host. It is not a credential
|
||||
* (iOS keeps the same value in plain `UserDefaults`); a record written before the field existed
|
||||
* decodes to `""`, which the rotation driver treats as "cannot rotate" rather than guessing.
|
||||
*
|
||||
* [renewAfterEpochSeconds] is only ever ADVISORY and only ever set by an enroll: neither
|
||||
* `/device/:id/renew` nor `/device/:id/recover` returns a `renewAfter`. The live timing therefore
|
||||
* comes from the leaf certificate itself — see [DeviceRotationStore].
|
||||
*
|
||||
* This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert
|
||||
* identity is what the handshake presents; this record only exists so renew can find the device and
|
||||
@@ -24,6 +35,8 @@ public data class EnrollmentRecord(
|
||||
val deviceName: String,
|
||||
val keyStoreAlias: String,
|
||||
val renewAfterEpochSeconds: Long,
|
||||
/** Control-plane base URL this device enrolled against; `""` when unknown (a legacy record). */
|
||||
val controlPlaneUrl: String = "",
|
||||
) {
|
||||
init {
|
||||
require(deviceId.isNotBlank()) { "deviceId must not be blank" }
|
||||
@@ -31,7 +44,7 @@ public data class EnrollmentRecord(
|
||||
}
|
||||
}
|
||||
|
||||
/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */
|
||||
/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — four UTF strings + one long). */
|
||||
public object EnrollmentRecordCodec {
|
||||
public fun encode(record: EnrollmentRecord): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
@@ -40,6 +53,7 @@ public object EnrollmentRecordCodec {
|
||||
data.writeUTF(record.deviceName)
|
||||
data.writeUTF(record.keyStoreAlias)
|
||||
data.writeLong(record.renewAfterEpochSeconds)
|
||||
data.writeUTF(record.controlPlaneUrl)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
@@ -53,11 +67,25 @@ public object EnrollmentRecordCodec {
|
||||
deviceName = data.readUTF(),
|
||||
keyStoreAlias = data.readUTF(),
|
||||
renewAfterEpochSeconds = data.readLong(),
|
||||
controlPlaneUrl = readTrailingUtfOrBlank(data),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the trailing [EnrollmentRecord.controlPlaneUrl], tolerating its ABSENCE (a blob written
|
||||
* before the field existed) as `""`. Only end-of-blob is tolerated: a record that ends exactly
|
||||
* after the long is a valid older layout, whereas a mangled one still fails the reads above and is
|
||||
* reported corrupt. Declaring an existing enrollment corrupt would strand a working device.
|
||||
*/
|
||||
private fun readTrailingUtfOrBlank(data: DataInputStream): String =
|
||||
try {
|
||||
data.readUTF()
|
||||
} catch (_: EOFException) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,15 +5,13 @@ import okhttp3.OkHttpClient
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
|
||||
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.interfaces.ECPublicKey
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
|
||||
/**
|
||||
* B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs
|
||||
@@ -30,53 +28,32 @@ class DeviceEnrollerTest {
|
||||
const val BASE = "https://cp.terminal.yaojia.wang"
|
||||
const val ALIAS = "test-device-key"
|
||||
|
||||
// Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory /
|
||||
// CertificateSummaryReader parse them exactly as they parse a server-issued leaf.
|
||||
const val LEAF_CN = "t1-device"
|
||||
const val CA_CN = "webterm-device-ca"
|
||||
const val LEAF_B64 =
|
||||
"MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" +
|
||||
"ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" +
|
||||
"WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" +
|
||||
"cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" +
|
||||
"HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" +
|
||||
"ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" +
|
||||
"cdoleWDlqcMKU"
|
||||
const val CA_B64 =
|
||||
"MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" +
|
||||
"dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" +
|
||||
"YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" +
|
||||
"e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" +
|
||||
"4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" +
|
||||
"Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" +
|
||||
"YloHuEAg+kngzA33m52aWtublai4L+eybg=="
|
||||
// Real self-signed P-256 X.509 certs + a software key double, shared with the rotation tests.
|
||||
const val LEAF_CN = RotationTestFixtures.LEAF_CN
|
||||
const val CA_CN = RotationTestFixtures.CA_CN
|
||||
|
||||
fun loginBody(): ByteArray =
|
||||
"""{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()
|
||||
fun loginBody(): ByteArray = RotationTestFixtures.loginBody()
|
||||
|
||||
fun enrollBody(deviceId: String = "dev-1"): ByteArray =
|
||||
"""
|
||||
{"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"],
|
||||
"notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z",
|
||||
"renewAfter":"2026-09-05T00:00:00.000Z"}
|
||||
""".trimIndent().toByteArray()
|
||||
fun enrollBody(deviceId: String = "dev-1"): ByteArray = RotationTestFixtures.enrollBody(deviceId)
|
||||
|
||||
fun softwareKey(alias: String): HardwareBackedKey {
|
||||
val kpg = KeyPairGenerator.getInstance("EC")
|
||||
kpg.initialize(ECGenParameterSpec("secp256r1"))
|
||||
val kp = kpg.generateKeyPair()
|
||||
return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey)
|
||||
}
|
||||
fun softwareKey(alias: String): HardwareBackedKey = RotationTestFixtures.softwareKey(alias)
|
||||
}
|
||||
|
||||
private val events = mutableListOf<String>()
|
||||
private val transport = FakeHttpTransport()
|
||||
|
||||
/**
|
||||
* The recovery transport is a SEPARATE double on purpose: `/device/:id/recover` must ride a PLAIN
|
||||
* (non-mTLS) client, because no TLS terminator forwards an expired client certificate. Using two
|
||||
* transports is what lets these tests prove the routing, not just the request shape.
|
||||
*/
|
||||
private val recoveryTransport = FakeHttpTransport()
|
||||
private val certStore = RecordingCertStore(events)
|
||||
private val recordStore = RecordingRecordStore(events)
|
||||
private val keyProvider = RecordingKeyProvider(events)
|
||||
private val refresher = RecordingRefresher(events)
|
||||
|
||||
private fun enroller(): DeviceEnroller =
|
||||
private fun enroller(withRecovery: Boolean = true): DeviceEnroller =
|
||||
DeviceEnroller(
|
||||
client = DeviceEnrollmentClient(BASE, transport),
|
||||
certStore = certStore,
|
||||
@@ -85,6 +62,7 @@ class DeviceEnrollerTest {
|
||||
keyAlias = ALIAS,
|
||||
keyProvider = keyProvider,
|
||||
cacheRefresher = refresher,
|
||||
recoveryClient = if (withRecovery) DeviceEnrollmentClient(BASE, recoveryTransport) else null,
|
||||
)
|
||||
|
||||
// ── enroll: commit sequencing (the security-critical invariant) ────────────────────────────
|
||||
@@ -221,6 +199,166 @@ class DeviceEnrollerTest {
|
||||
assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollPersistsTheControlPlaneUrlSoRotationKnowsWhereToRenew() = runTest {
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
|
||||
|
||||
enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
|
||||
|
||||
// Silent rotation happens with no UI, so the control plane the device enrolled against must be
|
||||
// persisted — a rotation that fell back to a compiled-in default would renew somewhere else.
|
||||
assertEquals(BASE, recordStore.saved!!.controlPlaneUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renewDecodesTheRealReissueResponseThatCarriesNoDeviceId() = runTest {
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
|
||||
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||
// The real /device/:id/renew 201 body — {cert, caChain, notAfter} with NO deviceId.
|
||||
transport.queueSuccess(
|
||||
HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = RotationTestFixtures.reissueBody(),
|
||||
)
|
||||
|
||||
val summary = enroller().renew()
|
||||
|
||||
assertEquals(LEAF_CN, summary.subjectCommonName)
|
||||
assertEquals("dev-1", recordStore.saved!!.deviceId, "the device id survives the re-issue")
|
||||
assertEquals(BASE, recordStore.saved!!.controlPlaneUrl)
|
||||
}
|
||||
|
||||
// ── recover: the EXPIRED-leaf escape hatch (plain HTTPS, cert in the body) ──────────────────
|
||||
|
||||
@Test
|
||||
fun recoverPostsTheStoredLeafAndAFreshCsrOnThePlainTransportNotTheMtlsOne() = runTest {
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
|
||||
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||
certStore.seed(storedIdentity())
|
||||
recoveryTransport.queueSuccess(
|
||||
HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(),
|
||||
)
|
||||
|
||||
val summary = enroller().recover()
|
||||
|
||||
// The request went out on the PLAIN transport. If it had gone on the mTLS one it would have
|
||||
// presented the expired leaf and nginx would have answered a bare 400 — the production deadlock.
|
||||
assertTrue(transport.recordedRequests.isEmpty(), "recovery must NEVER ride the mTLS transport")
|
||||
val request = recoveryTransport.recordedRequests.single()
|
||||
assertEquals("$BASE/device/dev-1/recover", request.url)
|
||||
assertNull(request.headers["Authorization"], "recovery carries no bearer")
|
||||
val body = request.body!!.decodeToString()
|
||||
assertTrue(body.contains("\"cert\":"), "the lapsed leaf travels in the BODY")
|
||||
assertTrue(body.contains(RotationTestFixtures.LEAF_B64.take(32)), "the body carries the stored leaf")
|
||||
assertTrue(body.contains("\"csr\":"), "possession is proven by a fresh CSR over the same key")
|
||||
assertEquals(LEAF_CN, summary.subjectCommonName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoverCommitsWithTheSameSequencingAsEnrollAndRenew() = runTest {
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
|
||||
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||
certStore.seed(storedIdentity())
|
||||
recoveryTransport.queueSuccess(
|
||||
HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(),
|
||||
)
|
||||
|
||||
enroller().recover()
|
||||
|
||||
assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"), "record before the pointer flip")
|
||||
assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh after the commit")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoverReusesTheSameHardwareKeyAndNeverGeneratesANewOne() = runTest {
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
|
||||
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||
certStore.seed(storedIdentity())
|
||||
recoveryTransport.queueSuccess(
|
||||
HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(),
|
||||
)
|
||||
|
||||
enroller().recover()
|
||||
|
||||
// The server enforces `CSR key == registered key`; generating a key here would guarantee a 403.
|
||||
assertTrue(keyProvider.generatedAliases.isEmpty(), "recovery re-CSRs from the EXISTING key")
|
||||
assertTrue(keyProvider.deletedAliases.isEmpty(), "a successful recovery deletes nothing")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoverThrowsWhenNoPlainRecoveryClientIsConfigured() = runTest {
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
|
||||
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||
certStore.seed(storedIdentity())
|
||||
|
||||
val error = runCatching { enroller(withRecovery = false).recover() }.exceptionOrNull()
|
||||
|
||||
assertTrue(error is DeviceEnroller.EnrollmentStateException)
|
||||
assertTrue(recoveryTransport.recordedRequests.isEmpty())
|
||||
assertTrue(transport.recordedRequests.isEmpty(), "and it must not silently fall back to mTLS")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoverThrowsWhenThereIsNoStoredLeafToPresent() = runTest {
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
|
||||
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||
// No cert in the store → nothing to put in the body → a fresh enroll is the only way out.
|
||||
val error = runCatching { enroller().recover() }.exceptionOrNull()
|
||||
|
||||
assertTrue(error is DeviceEnroller.EnrollmentStateException)
|
||||
assertTrue(recoveryTransport.recordedRequests.isEmpty(), "no pointless round-trip")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoverThrowsWhenNothingIsEnrolled() = runTest {
|
||||
val error = runCatching { enroller().recover() }.exceptionOrNull()
|
||||
assertTrue(error is DeviceEnroller.EnrollmentStateException)
|
||||
assertTrue(recoveryTransport.recordedRequests.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedRecoveryLeavesThePriorIdentityUntouched() = runTest {
|
||||
val prior = storedIdentity()
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
|
||||
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||
certStore.seed(prior)
|
||||
// Beyond the 30-day grace the control plane answers a uniform 401.
|
||||
recoveryTransport.queueSuccess(
|
||||
HttpMethod.POST, "$BASE/device/dev-1/recover", 401, body = """{"error":"rejected"}""".toByteArray(),
|
||||
)
|
||||
|
||||
val error = runCatching { enroller().recover() }.exceptionOrNull()
|
||||
|
||||
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
|
||||
assertSame(prior, certStore.load(), "a failed recovery must not touch the live cert pointer")
|
||||
assertFalse(events.contains("cert.save"), "no pointer flip on failure")
|
||||
assertFalse(events.contains("cache.refresh"), "and nothing is published to the in-memory cache")
|
||||
assertTrue(keyProvider.deletedAliases.isEmpty(), "the key that still works is NEVER deleted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theRecoveryFailureNeverCarriesTheCertificateBytes() = runTest {
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
|
||||
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||
certStore.seed(storedIdentity())
|
||||
recoveryTransport.queueSuccess(
|
||||
HttpMethod.POST, "$BASE/device/dev-1/recover", 403, body = """{"error":"rejected"}""".toByteArray(),
|
||||
)
|
||||
|
||||
val error = runCatching { enroller().recover() }.exceptionOrNull()!!
|
||||
|
||||
val rendered = "${error.message} $error"
|
||||
assertFalse(rendered.contains(RotationTestFixtures.LEAF_B64.take(32)), "no cert bytes in the error")
|
||||
assertFalse(rendered.contains("MII"), "no DER/PEM material in a loggable rendering")
|
||||
}
|
||||
|
||||
private fun storedIdentity(): StoredIdentityMetadata =
|
||||
StoredIdentityMetadata(
|
||||
alias = ALIAS,
|
||||
keyAlgorithm = "EC",
|
||||
keyStoreAlias = ALIAS,
|
||||
certificateChain = listOf(RotationTestFixtures.leafCertificate(), RotationTestFixtures.caCertificate()),
|
||||
)
|
||||
|
||||
// ── remove: full teardown ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -240,17 +378,25 @@ class DeviceEnrollerTest {
|
||||
private class RecordingCertStore(private val events: MutableList<String>) : CertStore {
|
||||
var saved: StoredIdentityMetadata? = null
|
||||
var cleared = false
|
||||
private var current: StoredIdentityMetadata? = null
|
||||
|
||||
/** Pre-existing on-disk identity (the lapsed leaf the recovery path must present). */
|
||||
fun seed(metadata: StoredIdentityMetadata) {
|
||||
current = metadata
|
||||
}
|
||||
|
||||
override fun save(metadata: StoredIdentityMetadata) {
|
||||
saved = metadata
|
||||
current = metadata
|
||||
events += "cert.save"
|
||||
}
|
||||
|
||||
override fun load(): StoredIdentityMetadata? = saved
|
||||
override fun load(): StoredIdentityMetadata? = current
|
||||
|
||||
override fun clear() {
|
||||
cleared = true
|
||||
saved = null
|
||||
current = null
|
||||
events += "cert.clear"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
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 java.io.IOException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* [DeviceRotationStore] is the read side of silent rotation: it composes the two persisted stores into
|
||||
* the timing snapshot the pure `RotationPolicy` consumes. The load-bearing behaviour is that the
|
||||
* timing comes from the LIVE LEAF CERTIFICATE, not from the enrollment record — a record written by a
|
||||
* renew/recover commit has no `renewAfter` at all (the server does not return one), so a store that
|
||||
* trusted the record would rotate once and then report "not due" until the cert died.
|
||||
*/
|
||||
class DeviceRotationStoreTest {
|
||||
private companion object {
|
||||
const val ALIAS = "test-device-key"
|
||||
const val CP_URL = "https://cp.terminal.yaojia.wang"
|
||||
}
|
||||
|
||||
private val certStore = FakeCertStore()
|
||||
private val recordStore = FakeRecordStore()
|
||||
private val store = DeviceRotationStore(certStore, recordStore)
|
||||
|
||||
/** JUnit5's `assertNotNull` returns Unit, so assert-and-unwrap in one place. */
|
||||
private fun readSnapshot(): DeviceRotationSnapshot {
|
||||
val snapshot = store.read()
|
||||
assertNotNull(snapshot, "expected an enrolled rotation snapshot")
|
||||
return snapshot!!
|
||||
}
|
||||
|
||||
private fun seedEnrolled(
|
||||
controlPlaneUrl: String = CP_URL,
|
||||
renewAfterEpochSeconds: Long = 0L,
|
||||
) {
|
||||
recordStore.current = EnrollmentRecord(
|
||||
deviceId = "dev-1",
|
||||
deviceName = "Alice Pixel",
|
||||
keyStoreAlias = ALIAS,
|
||||
renewAfterEpochSeconds = renewAfterEpochSeconds,
|
||||
controlPlaneUrl = controlPlaneUrl,
|
||||
)
|
||||
certStore.current = StoredIdentityMetadata(
|
||||
alias = ALIAS,
|
||||
keyAlgorithm = "EC",
|
||||
keyStoreAlias = ALIAS,
|
||||
certificateChain = listOf(RotationTestFixtures.leafCertificate(), RotationTestFixtures.caCertificate()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readReturnsNullWhenNothingIsEnrolled() {
|
||||
assertNull(store.read(), "a fresh install has nothing to rotate")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readReturnsNullWhenTheCertLivePointerIsAbsent() {
|
||||
recordStore.current = EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, CP_URL)
|
||||
// A record with no live cert cannot be renewed OR recovered (recovery needs the lapsed leaf to
|
||||
// put in the body), so this is "nothing to rotate", not a failure.
|
||||
assertNull(store.read())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readTakesTheExpiryFromTheLeafCertificate() {
|
||||
seedEnrolled()
|
||||
val snapshot = readSnapshot()
|
||||
assertEquals("dev-1", snapshot.renewalState.deviceId)
|
||||
assertEquals(
|
||||
Instant.parse(RotationTestFixtures.LEAF_NOT_AFTER),
|
||||
snapshot.renewalState.notAfter,
|
||||
"notAfter is the leaf's own hard expiry — the value the TLS stack actually enforces",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readDerivesRenewAfterFromTheLeafAndIgnoresTheStaleRecordValue() {
|
||||
// A record committed by a renew carries renewAfterEpochSeconds = 0 (unknown) because the
|
||||
// renew 201 has no renewAfter; a record committed by an enroll carries a value that is stale
|
||||
// the moment the leaf is replaced. Either way the LEAF is the source of truth.
|
||||
seedEnrolled(renewAfterEpochSeconds = Instant.parse("1999-01-01T00:00:00Z").epochSecond)
|
||||
val notBefore = Instant.parse(RotationTestFixtures.LEAF_NOT_BEFORE)
|
||||
val notAfter = Instant.parse(RotationTestFixtures.LEAF_NOT_AFTER)
|
||||
// The control plane's own formula: notBefore + 2/3 · lifetime.
|
||||
val expected = notBefore.plus(Duration.between(notBefore, notAfter).multipliedBy(2).dividedBy(3))
|
||||
|
||||
val snapshot = readSnapshot()
|
||||
|
||||
assertEquals(expected, snapshot.renewalState.renewAfter)
|
||||
assertTrue(snapshot.renewalState.renewAfter!!.isAfter(notBefore))
|
||||
assertTrue(snapshot.renewalState.renewAfter!!.isBefore(notAfter))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readCarriesTheControlPlaneUrlSoRotationTargetsTheRightHost() {
|
||||
seedEnrolled(controlPlaneUrl = "https://cp.example.test")
|
||||
assertEquals("https://cp.example.test", readSnapshot().controlPlaneUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readReportsABlankControlPlaneUrlRatherThanGuessingOne() {
|
||||
// A record written before the URL was persisted decodes to "". Renewing against a DEFAULT host
|
||||
// would silently talk to the wrong control plane, so the blank is surfaced verbatim and the
|
||||
// caller must refuse to rotate.
|
||||
seedEnrolled(controlPlaneUrl = "")
|
||||
assertEquals("", readSnapshot().controlPlaneUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readPropagatesAStoreFaultInsteadOfReportingNotEnrolled() {
|
||||
seedEnrolled()
|
||||
certStore.fault = IOException("tink blob unreadable")
|
||||
// Degrading a decrypt fault to "nothing enrolled" would silently disable rotation forever; the
|
||||
// caller surfaces it as a failure instead.
|
||||
assertTrue(runCatching { store.read() }.exceptionOrNull() is IOException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSnapshotCarriesNoCredentialInItsToString() {
|
||||
seedEnrolled()
|
||||
val rendered = readSnapshot().toString()
|
||||
assertTrue(rendered.contains("dev-1"))
|
||||
assertTrue(rendered.contains("cp.terminal.yaojia.wang"))
|
||||
assertFalse(rendered.contains("MIIB"), "no certificate DER may reach a loggable rendering")
|
||||
assertFalse(rendered.contains(RotationTestFixtures.LEAF_B64), "no leaf bytes in the snapshot")
|
||||
}
|
||||
|
||||
// ── doubles ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private class FakeCertStore : CertStore {
|
||||
var current: StoredIdentityMetadata? = null
|
||||
var fault: Exception? = null
|
||||
|
||||
override fun save(metadata: StoredIdentityMetadata) {
|
||||
current = metadata
|
||||
}
|
||||
|
||||
override fun load(): StoredIdentityMetadata? {
|
||||
fault?.let { throw it }
|
||||
return current
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
current = null
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeRecordStore : EnrollmentRecordStore {
|
||||
var current: EnrollmentRecord? = null
|
||||
|
||||
override fun save(record: EnrollmentRecord) {
|
||||
current = record
|
||||
}
|
||||
|
||||
override fun load(): EnrollmentRecord? = current
|
||||
|
||||
override fun clear() {
|
||||
current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataOutputStream
|
||||
|
||||
/**
|
||||
* [EnrollmentRecordCodec] round-trip, plus the one compatibility rule that matters: a blob written
|
||||
* BEFORE `controlPlaneUrl` existed must still decode (to a blank URL) rather than being reported as
|
||||
* corrupt — a corrupt-blob verdict on an existing enrollment would strand a working device.
|
||||
*/
|
||||
class EnrollmentRecordCodecTest {
|
||||
|
||||
@Test
|
||||
fun roundTripsEveryField() {
|
||||
val record = EnrollmentRecord(
|
||||
deviceId = "dev-1",
|
||||
deviceName = "Alice Pixel",
|
||||
keyStoreAlias = "webterm-device-key",
|
||||
renewAfterEpochSeconds = 1_790_000_000L,
|
||||
controlPlaneUrl = "https://cp.terminal.yaojia.wang",
|
||||
)
|
||||
assertEquals(record, EnrollmentRecordCodec.decode(EnrollmentRecordCodec.encode(record)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBlobWrittenBeforeTheControlPlaneUrlFieldDecodesToABlankUrl() {
|
||||
// Exactly the four-field layout the previous codec version emitted.
|
||||
val legacy = ByteArrayOutputStream().also { out ->
|
||||
DataOutputStream(out).use { data ->
|
||||
data.writeUTF("dev-1")
|
||||
data.writeUTF("Alice Pixel")
|
||||
data.writeUTF("webterm-device-key")
|
||||
data.writeLong(0L)
|
||||
}
|
||||
}.toByteArray()
|
||||
|
||||
val decoded = EnrollmentRecordCodec.decode(legacy)
|
||||
|
||||
assertEquals("dev-1", decoded.deviceId)
|
||||
assertEquals("", decoded.controlPlaneUrl, "an absent trailing field is unknown, not corrupt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTruncatedBlobIsStillReportedAsCorrupt() {
|
||||
// Tolerating the ABSENT trailing field must not turn into tolerating a mangled record.
|
||||
assertThrows(CorruptStoredIdentityException::class.java) {
|
||||
EnrollmentRecordCodec.decode(byteArrayOf(0x00, 0x05, 0x64))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.interfaces.ECPublicKey
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* Shared JVM test fixtures for the enroll / rotate / recover orchestration: REAL self-signed P-256
|
||||
* X.509 certificates (base64 DER) so `CertificateFactory`, `CertificateSummaryReader` and the
|
||||
* rotation-timing reader parse them exactly as they parse a server-issued leaf — and a software P-256
|
||||
* key double standing in for the non-exportable AndroidKeyStore key (which needs a device).
|
||||
*/
|
||||
internal object RotationTestFixtures {
|
||||
const val LEAF_CN: String = "t1-device"
|
||||
const val CA_CN: String = "webterm-device-ca"
|
||||
|
||||
/** Validity window baked into [LEAF_B64] (read back from the DER, asserted in the store tests). */
|
||||
const val LEAF_NOT_BEFORE: String = "2026-07-18T11:21:11Z"
|
||||
const val LEAF_NOT_AFTER: String = "2126-06-24T11:21:11Z"
|
||||
|
||||
const val LEAF_B64: String =
|
||||
"MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" +
|
||||
"ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" +
|
||||
"WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" +
|
||||
"cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" +
|
||||
"HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" +
|
||||
"ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" +
|
||||
"cdoleWDlqcMKU"
|
||||
|
||||
const val CA_B64: String =
|
||||
"MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" +
|
||||
"dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" +
|
||||
"YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" +
|
||||
"e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" +
|
||||
"4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" +
|
||||
"Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" +
|
||||
"YloHuEAg+kngzA33m52aWtublai4L+eybg=="
|
||||
|
||||
fun leafDer(): ByteArray = Base64.getDecoder().decode(LEAF_B64)
|
||||
|
||||
fun leafCertificate(): X509Certificate = parse(leafDer())
|
||||
|
||||
fun caCertificate(): X509Certificate = parse(Base64.getDecoder().decode(CA_B64))
|
||||
|
||||
/** The enroll 201 body — the ONLY route that returns deviceId/notBefore/renewAfter. */
|
||||
fun enrollBody(deviceId: String = "dev-1"): ByteArray =
|
||||
"""
|
||||
{"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"],
|
||||
"notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z",
|
||||
"renewAfter":"2026-09-05T00:00:00.000Z"}
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
/**
|
||||
* The REAL `/device/:id/renew` and `/device/:id/recover` 201 body: `{cert, caChain, notAfter}` —
|
||||
* no deviceId, no notBefore, no renewAfter (control-plane/src/api/renew.ts).
|
||||
*/
|
||||
fun reissueBody(): ByteArray =
|
||||
"""
|
||||
{"cert":"$LEAF_B64","caChain":["$CA_B64"],"notAfter":"2126-06-24T11:21:11.000Z"}
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
fun loginBody(): ByteArray =
|
||||
"""{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()
|
||||
|
||||
/** A software P-256 key standing in for the non-exportable AndroidKeyStore key (§7: no emulator). */
|
||||
fun softwareKey(alias: String): HardwareBackedKey {
|
||||
val generator = KeyPairGenerator.getInstance("EC")
|
||||
generator.initialize(ECGenParameterSpec("secp256r1"))
|
||||
val pair = generator.generateKeyPair()
|
||||
return HardwareBackedKey(alias, pair.private, pair.public as ECPublicKey)
|
||||
}
|
||||
|
||||
private fun parse(der: ByteArray): X509Certificate =
|
||||
CertificateFactory.getInstance("X.509").generateCertificate(der.inputStream()) as X509Certificate
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Instrumented (device) contract tests for [DataStoreSessionWatermarkStore] — the storage half
|
||||
* of the module, which needs a real Context/file (plan §7).
|
||||
*
|
||||
* The load-bearing one is [survives_a_new_store_instance_over_the_same_file]: that is the
|
||||
* process-death simulation this whole class exists for. The rest mirror the JVM transform tests
|
||||
* at the storage boundary (garbage at rest is dropped, the cap holds, an emptied map removes the
|
||||
* key instead of leaving `{}` behind).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DataStoreSessionWatermarkStoreTest {
|
||||
|
||||
private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
|
||||
private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302"
|
||||
|
||||
/**
|
||||
* A fresh, uniquely-named Preferences file per test (no cross-test bleed). [scope] is
|
||||
* injectable because DataStore throws if two instances are live on ONE file: releasing the
|
||||
* file means cancelling the owning scope, which is how process death is simulated below.
|
||||
*/
|
||||
private fun newDataStore(
|
||||
name: String = "watermarks-test-${UUID.randomUUID()}",
|
||||
scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
): DataStore<Preferences> {
|
||||
val ctx = ApplicationProvider.getApplicationContext<Context>()
|
||||
return PreferenceDataStoreFactory.create(
|
||||
scope = scope,
|
||||
produceFile = { ctx.preferencesDataStoreFile(name) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun an_empty_store_loads_an_empty_map() = runBlocking {
|
||||
assertTrue(DataStoreSessionWatermarkStore(newDataStore()).loadAll().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceAll_then_loadAll_returns_the_same_watermarks() = runBlocking {
|
||||
val store = DataStoreSessionWatermarkStore(newDataStore())
|
||||
|
||||
store.replaceAll(mapOf(idA to 10L, idB to 20L))
|
||||
|
||||
assertEquals(mapOf(idA to 10L, idB to 20L), store.loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun survives_a_new_store_instance_over_the_same_file() = runBlocking {
|
||||
val fileName = "watermarks-persist-${UUID.randomUUID()}"
|
||||
val firstScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
// replaceAll suspends until edit() has committed to disk.
|
||||
DataStoreSessionWatermarkStore(newDataStore(fileName, firstScope))
|
||||
.replaceAll(mapOf(idA to 1_700_000_000_000L))
|
||||
|
||||
// Cancelling the owning scope releases the file — DataStore refuses two live instances
|
||||
// on one file, and letting go of it is exactly what process death does.
|
||||
firstScope.coroutineContext.job.cancelAndJoin()
|
||||
|
||||
// A brand-new store over the same file = what a cold start after process death sees.
|
||||
val reopened = DataStoreSessionWatermarkStore(newDataStore(fileName))
|
||||
|
||||
assertEquals(mapOf(idA to 1_700_000_000_000L), reopened.loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceAll_is_a_wholesale_swap_not_a_merge() = runBlocking {
|
||||
val store = DataStoreSessionWatermarkStore(newDataStore())
|
||||
store.replaceAll(mapOf(idA to 10L))
|
||||
|
||||
store.replaceAll(mapOf(idB to 20L))
|
||||
|
||||
assertEquals(mapOf(idB to 20L), store.loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remove_drops_one_session_and_keeps_the_rest() = runBlocking {
|
||||
val store = DataStoreSessionWatermarkStore(newDataStore())
|
||||
store.replaceAll(mapOf(idA to 10L, idB to 20L))
|
||||
|
||||
val remaining = store.remove(idA)
|
||||
|
||||
assertEquals(mapOf(idB to 20L), remaining)
|
||||
assertEquals(remaining, store.loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removing_an_unknown_session_is_a_no_op() = runBlocking {
|
||||
val store = DataStoreSessionWatermarkStore(newDataStore())
|
||||
store.replaceAll(mapOf(idA to 10L))
|
||||
|
||||
assertEquals(mapOf(idA to 10L), store.remove(idB))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptying_the_map_removes_the_stored_key_instead_of_writing_an_empty_blob() = runBlocking {
|
||||
val dataStore = newDataStore()
|
||||
val store = DataStoreSessionWatermarkStore(dataStore)
|
||||
store.replaceAll(mapOf(idA to 10L))
|
||||
|
||||
store.remove(idA)
|
||||
|
||||
assertNull(dataStore.data.first()[stringPreferencesKey(WATERMARKS_KEY_NAME)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun garbage_stored_at_rest_reads_back_as_no_watermarks() = runBlocking {
|
||||
val dataStore = newDataStore()
|
||||
dataStore.edit { it[stringPreferencesKey(WATERMARKS_KEY_NAME)] = "{not json" }
|
||||
|
||||
assertTrue(DataStoreSessionWatermarkStore(dataStore).loadAll().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_non_v4_id_stored_at_rest_is_dropped_on_read() = runBlocking {
|
||||
val dataStore = newDataStore()
|
||||
// A v1 UUID: parses as a UUID (the DataStoreLastSessionStore precedent bug) but is not
|
||||
// a valid wire session id, so Validation.isValidSessionId must reject it.
|
||||
val v1 = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
|
||||
dataStore.edit { it[stringPreferencesKey(WATERMARKS_KEY_NAME)] = """{"$idA":10,"$v1":20}""" }
|
||||
|
||||
assertEquals(mapOf(idA to 10L), DataStoreSessionWatermarkStore(dataStore).loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun the_stored_map_is_capped_so_it_cannot_grow_without_bound() = runBlocking {
|
||||
val store = DataStoreSessionWatermarkStore(newDataStore())
|
||||
val oversized = (0 until MAX_WATERMARKS + 5).associate { sessionId(it) to (it + 1).toLong() }
|
||||
|
||||
val stored = store.replaceAll(oversized)
|
||||
|
||||
assertEquals(MAX_WATERMARKS, stored.size)
|
||||
assertEquals(MAX_WATERMARKS, store.loadAll().size)
|
||||
assertFalse(store.loadAll().containsKey(sessionId(0))) // oldest-seen dropped first
|
||||
}
|
||||
|
||||
@Test
|
||||
fun the_watermark_key_is_disjoint_from_the_other_stores_over_one_file() = runBlocking {
|
||||
// ONE Preferences DataStore is shared app-wide (:app StorageModule) — this is the
|
||||
// regression test for "never collide with a sibling store's key".
|
||||
val dataStore = newDataStore()
|
||||
val hosts = DataStoreHostStore(dataStore)
|
||||
val lastSession = DataStoreLastSessionStore(dataStore)
|
||||
val watermarks = DataStoreSessionWatermarkStore(dataStore)
|
||||
val host = Host.create(id = "host-1", name = "mac", baseUrl = "http://10.0.2.2:3000")!!
|
||||
|
||||
hosts.upsert(host)
|
||||
lastSession.setLastSessionId(idB, hostId = "host-1")
|
||||
watermarks.replaceAll(mapOf(idA to 10L))
|
||||
|
||||
assertEquals(listOf(host), hosts.loadAll())
|
||||
assertEquals(idB, lastSession.lastSessionId("host-1"))
|
||||
assertEquals(mapOf(idA to 10L), watermarks.loadAll())
|
||||
}
|
||||
|
||||
/** A distinct valid v4 id per [index] (the last 4 hex digits carry the index). */
|
||||
private fun sessionId(index: Int): String =
|
||||
"3f2504e0-4f89-41d3-9a0c-0305e82c%04x".format(index)
|
||||
|
||||
private companion object {
|
||||
/** Must match `DataStoreSessionWatermarkStore.WATERMARKS_KEY` (private there, asserted here). */
|
||||
const val WATERMARKS_KEY_NAME = "unreadWatermarks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* Preferences-DataStore-backed [SessionWatermarkStore]. The whole watermark map is ONE JSON
|
||||
* string under [WATERMARKS_KEY] ([SessionWatermarkCodec]) — the same shape [DataStoreHostStore]
|
||||
* uses for `"hosts"`, and for the same reason: the cap and the sanitize pass have to apply to
|
||||
* the map as a whole, which a per-session key prefix (the [DataStoreLastSessionStore] shape)
|
||||
* cannot do without scanning every key on every write and still leaking orphaned keys.
|
||||
*
|
||||
* The key name mirrors the iOS `UserDefaults` key `"unreadWatermarks"`. It is **disjoint** from
|
||||
* every other key over this file (`"hosts"`, `"lastSessionId.<hostId>"`, `"authCookiesSealed"`),
|
||||
* because ONE Preferences DataStore instance is shared app-wide (`:app` `StorageModule`) — a
|
||||
* second instance over the same file would corrupt it, so never open one.
|
||||
*
|
||||
* Reads decode then [forStorage] (validate + cap): the blob is untrusted at rest. Writes are an
|
||||
* atomic read-modify-write via [DataStore.edit], and an empty result REMOVES the key rather than
|
||||
* writing `{}`, so the file shrinks back to nothing.
|
||||
*
|
||||
* The [DataStore] is injected (constructed from a Context in `:app` DI) so this class has no
|
||||
* Android-framework surface of its own; the on-device behaviour — including survival across a
|
||||
* fresh store instance, i.e. process death — is verified instrumented (androidTest, plan §7).
|
||||
* Not encrypted, deliberately: see the posture note on [SessionWatermarkStore].
|
||||
*/
|
||||
public class DataStoreSessionWatermarkStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : SessionWatermarkStore {
|
||||
|
||||
override suspend fun loadAll(): Map<String, Long> =
|
||||
SessionWatermarkCodec.decode(dataStore.data.first()[WATERMARKS_KEY]).forStorage()
|
||||
|
||||
override suspend fun replaceAll(watermarks: Map<String, Long>): Map<String, Long> =
|
||||
writeTransform { watermarks.forStorage() }
|
||||
|
||||
override suspend fun remove(sessionId: String): Map<String, Long> =
|
||||
writeTransform { it.removingWatermark(sessionId) }
|
||||
|
||||
private suspend fun writeTransform(
|
||||
transform: (Map<String, Long>) -> Map<String, Long>,
|
||||
): Map<String, Long> {
|
||||
lateinit var updated: Map<String, Long>
|
||||
dataStore.edit { prefs ->
|
||||
// Decoding through forStorage() means every write also garbage-collects invalid
|
||||
// rows and re-applies the cap, even when the transform itself only removes.
|
||||
val current = SessionWatermarkCodec.decode(prefs[WATERMARKS_KEY]).forStorage()
|
||||
updated = transform(current)
|
||||
if (updated.isEmpty()) {
|
||||
prefs.remove(WATERMARKS_KEY)
|
||||
} else {
|
||||
prefs[WATERMARKS_KEY] = SessionWatermarkCodec.encode(updated)
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The one stored value: `SessionWatermarkCodec.encode(watermarks)`. */
|
||||
val WATERMARKS_KEY = stringPreferencesKey("unreadWatermarks")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* In-memory [SessionWatermarkStore]. Lives in `main` (not `test`) on purpose — it stands in for
|
||||
* the DataStore store in this module's contract tests AND in the AW4 ViewModel tests, exactly
|
||||
* like [InMemoryHostStore] / [InMemoryAuthCookieStore].
|
||||
*
|
||||
* It applies the SAME [forStorage] rules (validation + cap) as [DataStoreSessionWatermarkStore];
|
||||
* a laxer double would let a ViewModel test pass against behaviour no device exhibits.
|
||||
*
|
||||
* A [Mutex] serialises each read-modify-write; the state is a value snapshot replaced wholesale
|
||||
* on every change (no in-place mutation of a shared reference).
|
||||
*/
|
||||
public class InMemorySessionWatermarkStore(
|
||||
initial: Map<String, Long> = emptyMap(),
|
||||
) : SessionWatermarkStore {
|
||||
private val mutex = Mutex()
|
||||
|
||||
// forStorage() always builds a fresh map, so a mutable map handed to the ctor cannot alias state.
|
||||
private var stored: Map<String, Long> = initial.forStorage()
|
||||
|
||||
override suspend fun loadAll(): Map<String, Long> = mutex.withLock { stored }
|
||||
|
||||
override suspend fun replaceAll(watermarks: Map<String, Long>): Map<String, Long> =
|
||||
write { watermarks.forStorage() }
|
||||
|
||||
override suspend fun remove(sessionId: String): Map<String, Long> =
|
||||
write { it.removingWatermark(sessionId) }
|
||||
|
||||
private suspend fun write(
|
||||
transform: (Map<String, Long>) -> Map<String, Long>,
|
||||
): Map<String, Long> = mutex.withLock {
|
||||
val updated = transform(stored)
|
||||
stored = updated
|
||||
updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Pure JSON codec for the persisted watermark map (shared by [DataStoreSessionWatermarkStore],
|
||||
* JVM-testable without a Context). Mirrors [HostCodec]: encode is total, decode is defensive —
|
||||
* a corrupt/undecodable blob reads back as "no watermarks" so a cold start can never crash on
|
||||
* a bad row.
|
||||
*
|
||||
* Deliberately dumb about *meaning*: it does not validate session ids or instants. That is
|
||||
* [sanitizedWatermarks]' job, so exactly one place owns the trust decision (and the in-memory
|
||||
* double gets the identical treatment without touching JSON).
|
||||
*/
|
||||
internal object SessionWatermarkCodec {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
fun encode(watermarks: Map<String, Long>): String = json.encodeToString(watermarks)
|
||||
|
||||
fun decode(raw: String?): Map<String, Long> {
|
||||
if (raw.isNullOrBlank()) return emptyMap()
|
||||
return try {
|
||||
json.decodeFromString<Map<String, Long>>(raw)
|
||||
} catch (_: Exception) {
|
||||
emptyMap() // corrupted blob at rest → start clean, never crash
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import wang.yaojia.webterm.wire.Validation
|
||||
|
||||
/**
|
||||
* Durable storage for the unread **watermarks** — `sessionId → last-seen instant (ms since
|
||||
* epoch)` — that decide the session-list unread dot. Ports the iOS
|
||||
* `UnreadWatermarkStore` / `UserDefaultsUnreadWatermarkStore` pair (plan §2 lists "unread
|
||||
* watermarks" under *Persistence (non-secret)*).
|
||||
*
|
||||
* Implementations: [DataStoreSessionWatermarkStore] (real, Preferences-DataStore-backed) and
|
||||
* [InMemorySessionWatermarkStore] (in-`main` double, exactly like [InMemoryHostStore] /
|
||||
* [InMemoryAuthCookieStore]).
|
||||
*
|
||||
* ## Why this exists at all
|
||||
* Without it the dot resets whenever the process dies — which is precisely when it matters:
|
||||
* the product premise is walking away and coming back, and a dot that only survives while
|
||||
* the app is warm answers a question the user never asked.
|
||||
*
|
||||
* ## Division of labour with `UnreadLedger` (do not duplicate it)
|
||||
* The *reducer* — monotonic `record`, the `isUnread` comparison, the tie-broken cap — lives
|
||||
* in `:session-core`'s `UnreadLedger`, which is deliberately persistence-agnostic. This
|
||||
* module cannot see it (`:host-registry` depends only on `:wire-protocol`; nothing points
|
||||
* sideways — see `android/README.md`), and must not restate it. So the contract here is
|
||||
* plain-map I/O, like the iOS store's `[UUID: Int]`: `:app` owns the ledger, hands its
|
||||
* `watermarks` map to [replaceAll], and rebuilds a ledger from [loadAll] on cold start.
|
||||
*
|
||||
* ## At-rest posture — no Tink here, deliberately
|
||||
* The neighbouring [AuthCookieStore] blob IS Tink-AEAD-sealed, so a reader will reasonably
|
||||
* ask why this one is not. Because it is not a credential and not private data: a watermark
|
||||
* is a UUID the device already stores in the clear (the host list, the last-session id) plus
|
||||
* a coarse timestamp of when the user last *looked* at a session. Sealing it would buy
|
||||
* nothing an attacker with app-private read access does not already have, while adding a
|
||||
* fail-closed dependency on `AndroidKeyStore` — i.e. a keystore hiccup would silently reset
|
||||
* unread state. Plan §2's split is explicit: secrets get the Tink/AndroidKeyStore tier,
|
||||
* non-secret UI state gets plain Preferences DataStore. This is the second tier, same as
|
||||
* [HostStore] and [LastSessionStore]. (`android:allowBackup="false"` still applies to the
|
||||
* whole file.)
|
||||
*
|
||||
* ## Untrusted at rest
|
||||
* Persisted ids are re-validated on every read with the frozen
|
||||
* [Validation.isValidSessionId] — the SAME v4-specific guard the wire codec applies to
|
||||
* server-issued ids, **not** a format-only `UUID.fromString` (that mistake was already made
|
||||
* once in [DataStoreLastSessionStore] and had to be corrected; do not repeat it).
|
||||
*
|
||||
* Immutable style throughout, like [HostStore]: mutations RETURN the new map instead of
|
||||
* mutating shared state, an unknown id is an explicit no-op, and encounter order is
|
||||
* preserved (only the cap may reorder — see [cappedWatermarks]).
|
||||
*/
|
||||
public interface SessionWatermarkStore {
|
||||
/**
|
||||
* Every stored watermark, already sanitized and capped ([forStorage]). Empty when
|
||||
* nothing is stored or the blob no longer decodes — never throws.
|
||||
*/
|
||||
public suspend fun loadAll(): Map<String, Long>
|
||||
|
||||
/**
|
||||
* Swap the whole map for [watermarks] — the write `:app` drives after a `markSeen`
|
||||
* ("this is the ledger now"). A wholesale swap, NOT a merge: the in-memory `UnreadLedger`
|
||||
* is the source of truth, so a merge here could resurrect an entry the ledger dropped.
|
||||
* Returns exactly what was stored (i.e. [watermarks].[forStorage]), which may be a
|
||||
* subset of the argument.
|
||||
*/
|
||||
public suspend fun replaceAll(watermarks: Map<String, Long>): Map<String, Long>
|
||||
|
||||
/**
|
||||
* Forget [sessionId]'s watermark — the eager GC hook for a session that ended (`exit`
|
||||
* frame / a kill), matching how [LastSessionStore] is cleared on `.exited`. An unknown
|
||||
* or malformed id is an explicit no-op returning the unchanged map, never a throw.
|
||||
*/
|
||||
public suspend fun remove(sessionId: String): Map<String, Long>
|
||||
}
|
||||
|
||||
// ── Pure immutable transforms shared by every SessionWatermarkStore ───────────────────────
|
||||
// (DRY, mirrors HostStore.kt / AuthCookieStore.kt). Never mutate the receiver — always
|
||||
// return a fresh map. `internal` so both stores and the same-module JVM tests can use them
|
||||
// without widening the public surface.
|
||||
|
||||
/**
|
||||
* Hard ceiling on stored watermarks. **This is what bounds growth** (see the GC note below)
|
||||
* and mirrors `UnreadLedger.MAX_ENTRIES` in `:session-core`, which cannot be imported here
|
||||
* (no sideways module edge). Keep the two numbers equal: the ledger caps what `:app` holds,
|
||||
* this caps what reaches the disk, and a store that trusted the caller would be no bound at
|
||||
* all.
|
||||
*
|
||||
* ### GC rule (the decision, stated explicitly)
|
||||
* Sessions are ephemeral, so a watermark can outlive its session forever. Growth is bounded
|
||||
* by **three** rules, in order of preference:
|
||||
* 1. **Eager** — [SessionWatermarkStore.remove] on a session that exits or is killed. This
|
||||
* is the only rule that frees an entry the moment it is provably dead.
|
||||
* 2. **Structural** — this count cap. Over [MAX_WATERMARKS], the OLDEST-seen entries are
|
||||
* dropped ([cappedWatermarks]), so the file can never exceed ~512 · ~50 B ≈ 25 KB no
|
||||
* matter how many sessions a device sees or how many exits it misses (force-stop, crash,
|
||||
* a host killed from another device). This is the backstop that actually guarantees the
|
||||
* bound.
|
||||
* 3. **Hygiene** — [sanitizedWatermarks] drops rows that no longer validate, so a format
|
||||
* change or a corrupted row cannot accumulate either.
|
||||
*
|
||||
* Deliberately NOT a rule: pruning to the ids returned by the latest `GET /live-sessions`.
|
||||
* That looks tempting and is wrong — the fetch covers ONE host, so it would wipe every other
|
||||
* host's watermarks on a host switch, and an offline/erroring host returns nothing at all
|
||||
* (iOS records the same decision in `SessionListViewModel`).
|
||||
*
|
||||
* Also deliberately NOT a rule: a staleness/TTL sweep. Dropping an old watermark re-lights
|
||||
* the dot for a session that is still alive and still unchanged — a *false* unread, which is
|
||||
* worse than 25 KB.
|
||||
*/
|
||||
internal const val MAX_WATERMARKS: Int = 512
|
||||
|
||||
/**
|
||||
* Drop everything untrustworthy: ids that are not valid wire session ids
|
||||
* ([Validation.isValidSessionId]) and non-positive instants (`<= 0` means "never seen", which
|
||||
* is already the default for a missing entry — storing it is pure waste). Valid ids are
|
||||
* canonicalised to lower case and case-variant spellings of the same id collapse to their
|
||||
* newest instant (they are the same session; `Validation` is case-insensitive, `UUID.toString`
|
||||
* is lower case, and merging by max can never lower a watermark). Encounter order is kept.
|
||||
*/
|
||||
internal fun Map<String, Long>.sanitizedWatermarks(): Map<String, Long> {
|
||||
val out = LinkedHashMap<String, Long>(size)
|
||||
for ((id, atMs) in this) {
|
||||
if (atMs <= 0L || !Validation.isValidSessionId(id)) continue
|
||||
val key = id.lowercase()
|
||||
val existing = out[key]
|
||||
out[key] = if (existing == null) atMs else maxOf(existing, atMs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep at most the [MAX_WATERMARKS] NEWEST watermarks, tie-broken by session id so the
|
||||
* outcome is deterministic across runs (same rule as `UnreadLedger.capped`). Under the cap
|
||||
* the receiver's own order is returned untouched; over it the result is newest-first, since a
|
||||
* map that had to be truncated has no meaningful insertion order left to preserve.
|
||||
*/
|
||||
internal fun Map<String, Long>.cappedWatermarks(): Map<String, Long> {
|
||||
if (size <= MAX_WATERMARKS) return this
|
||||
return entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, Long>> { it.value }.thenBy { it.key })
|
||||
.take(MAX_WATERMARKS)
|
||||
.associate { it.key to it.value }
|
||||
}
|
||||
|
||||
/** A new map without [sessionId]'s watermark (case-insensitive). Unknown id → unchanged contents. */
|
||||
internal fun Map<String, Long>.removingWatermark(sessionId: String): Map<String, Long> {
|
||||
val target = sessionId.lowercase()
|
||||
return filterKeys { it.lowercase() != target }
|
||||
}
|
||||
|
||||
/** The single write/read gate: sanitize, then cap. Everything crossing storage goes through it. */
|
||||
internal fun Map<String, Long>.forStorage(): Map<String, Long> =
|
||||
sanitizedWatermarks().cappedWatermarks()
|
||||
@@ -0,0 +1,77 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Contract tests for the in-`main` [InMemorySessionWatermarkStore] double (the same role
|
||||
* [InMemoryHostStore] / [InMemoryAuthCookieStore] play: it stands in for the DataStore
|
||||
* store in the AW4 ViewModel tests). The double must enforce the SAME at-rest rules as
|
||||
* the real store, otherwise a ViewModel test can pass against behaviour the device never
|
||||
* exhibits.
|
||||
*/
|
||||
class InMemorySessionWatermarkStoreTest {
|
||||
|
||||
private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
|
||||
private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302"
|
||||
|
||||
@Test
|
||||
fun `an empty store loads an empty map`() = runTest {
|
||||
assertTrue(InMemorySessionWatermarkStore().loadAll().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceAll then loadAll returns the same watermarks`() = runTest {
|
||||
val store = InMemorySessionWatermarkStore()
|
||||
|
||||
store.replaceAll(mapOf(idA to 10L, idB to 20L))
|
||||
|
||||
assertEquals(mapOf(idA to 10L, idB to 20L), store.loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceAll returns exactly what was stored`() = runTest {
|
||||
val store = InMemorySessionWatermarkStore()
|
||||
|
||||
val stored = store.replaceAll(mapOf(idA to 10L, "garbage" to 20L))
|
||||
|
||||
assertEquals(mapOf(idA to 10L), stored)
|
||||
assertEquals(stored, store.loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceAll is a wholesale swap, not a merge`() = runTest {
|
||||
val store = InMemorySessionWatermarkStore(mapOf(idA to 10L))
|
||||
|
||||
store.replaceAll(mapOf(idB to 20L))
|
||||
|
||||
assertEquals(mapOf(idB to 20L), store.loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an initial map is sanitized too`() = runTest {
|
||||
val store = InMemorySessionWatermarkStore(mapOf(idA to 10L, "garbage" to 20L))
|
||||
|
||||
assertEquals(mapOf(idA to 10L), store.loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove drops one session and returns the new map`() = runTest {
|
||||
val store = InMemorySessionWatermarkStore(mapOf(idA to 10L, idB to 20L))
|
||||
|
||||
val remaining = store.remove(idA)
|
||||
|
||||
assertEquals(mapOf(idB to 20L), remaining)
|
||||
assertEquals(remaining, store.loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing an unknown session is a no-op`() = runTest {
|
||||
val store = InMemorySessionWatermarkStore(mapOf(idA to 10L))
|
||||
|
||||
assertEquals(mapOf(idA to 10L), store.remove(idB))
|
||||
assertEquals(mapOf(idA to 10L), store.loadAll())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* At-rest codec tests (mirrors `HostCodecTest`): encode is total, decode is defensive —
|
||||
* the blob is untrusted at rest, so anything undecodable reads back as "no watermarks"
|
||||
* rather than crashing the session list on cold start.
|
||||
*/
|
||||
class SessionWatermarkCodecTest {
|
||||
|
||||
private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
|
||||
private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302"
|
||||
|
||||
@Test
|
||||
fun `round-trips a watermark map`() {
|
||||
val map = mapOf(idA to 1_700_000_000_000L, idB to 42L)
|
||||
|
||||
assertEquals(map, SessionWatermarkCodec.decode(SessionWatermarkCodec.encode(map)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty map round-trips to an empty map`() {
|
||||
assertTrue(SessionWatermarkCodec.decode(SessionWatermarkCodec.encode(emptyMap())).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null reads back as empty`() {
|
||||
assertTrue(SessionWatermarkCodec.decode(null).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank reads back as empty`() {
|
||||
assertTrue(SessionWatermarkCodec.decode(" ").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a corrupt blob reads back as empty instead of throwing`() {
|
||||
assertTrue(SessionWatermarkCodec.decode("{not json").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrong-shaped blob reads back as empty`() {
|
||||
// A JSON array where a map was written (e.g. a hand-edited or superseded format).
|
||||
assertTrue(SessionWatermarkCodec.decode("""["$idA"]""").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-numeric instant reads back as empty`() {
|
||||
assertTrue(SessionWatermarkCodec.decode("""{"$idA":"yesterday"}""").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode does not itself validate ids - the store transforms do`() {
|
||||
// Keeping the codec dumb keeps ONE place (sanitizedWatermarks) responsible for trust.
|
||||
assertEquals(mapOf("garbage" to 5L), SessionWatermarkCodec.decode("""{"garbage":5}"""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Pure-transform tests for the watermark map shared by [InMemorySessionWatermarkStore] and
|
||||
* [DataStoreSessionWatermarkStore] (mirrors `HostStoreTransformsTest` /
|
||||
* `AuthCookieStoreTransformsTest`). These are the JVM-testable half of the storage split
|
||||
* (plan §3: "the logic half of :host-registry" is in the Kover gate).
|
||||
*
|
||||
* Covers the three at-rest defences and the GC bound:
|
||||
* - every stored session id is re-validated with the frozen `Validation.isValidSessionId`;
|
||||
* - non-positive instants are dropped (a watermark of 0 means "never seen" anyway);
|
||||
* - case-variant spellings of one id collapse via max (they are the same session);
|
||||
* - the map is capped at [MAX_WATERMARKS], newest kept — what bounds growth.
|
||||
*/
|
||||
class SessionWatermarkTransformsTest {
|
||||
|
||||
private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
|
||||
private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302"
|
||||
|
||||
// ── sanitizedWatermarks() ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a valid map survives sanitizing unchanged`() {
|
||||
val map = mapOf(idA to 10L, idB to 20L)
|
||||
|
||||
assertEquals(map, map.sanitizedWatermarks())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-v4 session id is dropped`() {
|
||||
// UUID v1 (version nibble 1) parses as a UUID but is not a valid wire session id.
|
||||
val v1 = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
|
||||
val map = mapOf(idA to 10L, v1 to 20L, "abc123" to 30L, "" to 40L)
|
||||
|
||||
assertEquals(mapOf(idA to 10L), map.sanitizedWatermarks())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-positive instants are dropped`() {
|
||||
val map = mapOf(idA to 0L, idB to -5L)
|
||||
|
||||
assertTrue(map.sanitizedWatermarks().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case-variant spellings of one id collapse to the newest`() {
|
||||
val map = mapOf(idA to 10L, idA.uppercase() to 99L)
|
||||
|
||||
assertEquals(mapOf(idA to 99L), map.sanitizedWatermarks())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sanitizing preserves encounter order`() {
|
||||
val map = linkedMapOf(idB to 20L, idA to 10L)
|
||||
|
||||
assertEquals(listOf(idB, idA), map.sanitizedWatermarks().keys.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sanitizing never mutates the receiver`() {
|
||||
val map = linkedMapOf(idA to 10L, "abc123" to 20L)
|
||||
|
||||
map.sanitizedWatermarks()
|
||||
|
||||
assertEquals(linkedMapOf(idA to 10L, "abc123" to 20L), map)
|
||||
}
|
||||
|
||||
// ── cappedWatermarks() ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a map at the cap is returned unchanged`() {
|
||||
val map = watermarks(MAX_WATERMARKS)
|
||||
|
||||
assertEquals(map, map.cappedWatermarks())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `over the cap the newest entries are kept`() {
|
||||
val map = watermarks(MAX_WATERMARKS + 10)
|
||||
|
||||
val capped = map.cappedWatermarks()
|
||||
|
||||
assertEquals(MAX_WATERMARKS, capped.size)
|
||||
// Instants ascend with the index, so the 10 oldest (lowest) must be the dropped ones.
|
||||
assertFalse(capped.containsKey(sessionId(0)))
|
||||
assertTrue(capped.containsKey(sessionId(MAX_WATERMARKS + 9)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equal instants break the tie deterministically by session id`() {
|
||||
val map = (0 until MAX_WATERMARKS + 2).associate { sessionId(it) to 7L }
|
||||
|
||||
val capped = map.cappedWatermarks()
|
||||
|
||||
assertEquals(MAX_WATERMARKS, capped.size)
|
||||
assertEquals(map.cappedWatermarks().keys, capped.keys) // stable across runs
|
||||
// Lowest ids win the tie-break, so the two highest ids are the dropped ones.
|
||||
assertFalse(capped.containsKey(sessionId(MAX_WATERMARKS + 1)))
|
||||
}
|
||||
|
||||
// ── removingWatermark() ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `removing drops just that session`() {
|
||||
val map = mapOf(idA to 10L, idB to 20L)
|
||||
|
||||
assertEquals(mapOf(idB to 20L), map.removingWatermark(idA))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing an unknown session is a no-op`() {
|
||||
val map = mapOf(idA to 10L)
|
||||
|
||||
assertEquals(map, map.removingWatermark(idB))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing is case-insensitive like the id itself`() {
|
||||
val map = mapOf(idA to 10L)
|
||||
|
||||
assertTrue(map.removingWatermark(idA.uppercase()).isEmpty())
|
||||
}
|
||||
|
||||
// ── forStorage() = sanitize + cap ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `forStorage sanitizes and caps in one pass`() {
|
||||
val map = watermarks(MAX_WATERMARKS + 1) + mapOf("garbage" to 999_999L)
|
||||
|
||||
val stored = map.forStorage()
|
||||
|
||||
assertEquals(MAX_WATERMARKS, stored.size)
|
||||
assertFalse(stored.containsKey("garbage"))
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** A distinct valid v4 id per [index] (the last 4 hex digits carry the index). */
|
||||
private fun sessionId(index: Int): String =
|
||||
"3f2504e0-4f89-41d3-9a0c-0305e82c%04x".format(index)
|
||||
|
||||
/** [count] valid watermarks whose instants ascend with the index (1-based, so all positive). */
|
||||
private fun watermarks(count: Int): Map<String, Long> =
|
||||
(0 until count).associate { sessionId(it) to (it + 1).toLong() }
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.util.TypedValue
|
||||
import android.view.ActionMode
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
@@ -49,8 +51,11 @@ import kotlin.math.roundToInt
|
||||
* 4. **text selection** — `startTextSelectionMode` arms an `ActionMode` whose Copy
|
||||
* (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100) is
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and whose Paste (offsets 126-136) is
|
||||
* `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true,
|
||||
* which is a **recorded deviation from plan §6.5** (see that method for the full rationale).
|
||||
* `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true so
|
||||
* the mode can never arm, and the press starts the **first-party** selection layer instead
|
||||
* ([TerminalSelection] for the design; [beginSelectionAt] / [copySelection] here). Only the stock
|
||||
* controller is replaced — the affordance is still an Android `ActionMode` over `ClipboardManager`, as
|
||||
* plan §6.5 specifies.
|
||||
*
|
||||
* ### Stock-method audit (verified with `javap -p -c`; keep it current when the pinned version moves)
|
||||
* | stock member | reachable here? | verdict |
|
||||
@@ -68,6 +73,7 @@ import kotlin.math.roundToInt
|
||||
* | `updateSize` | no | early-returns without a session; [TerminalResizeDriver] replaces it |
|
||||
* | `attachSession` | no | never called — it is the process-forking path we replace |
|
||||
* | `startTextSelectionMode` / `TextSelectionCursorController` | **no** | long press consumed (4) |
|
||||
* | `mDefaultSelectors` (the renderer's own highlight channel) | no (package-private) | our overlay draws it |
|
||||
* | `setTerminalCursorBlinker*` | never invoked | cosmetic gap vs stock: the cursor does not blink |
|
||||
*
|
||||
* ### The latent renderer crash this also fixes
|
||||
@@ -96,7 +102,10 @@ import kotlin.math.roundToInt
|
||||
* delegates to ([TerminalKeyDecoder], [TerminalInputBytes], [TerminalResizeDriver], [TerminalScrollGesture])
|
||||
* is what the JVM tests pin.
|
||||
*/
|
||||
public class RemoteTerminalHostView internal constructor(context: Context) : FrameLayout(context) {
|
||||
public class RemoteTerminalHostView internal constructor(
|
||||
context: Context,
|
||||
clipboard: TerminalClipboard = SystemTerminalClipboard(context),
|
||||
) : FrameLayout(context) {
|
||||
|
||||
/** The stock Termux view. Rendering is entirely its own; we only feed it an emulator and input. */
|
||||
internal val stockView: TerminalView = TerminalView(context, null)
|
||||
@@ -124,6 +133,11 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
val step = if (up) -1 else 1
|
||||
stockView.topRow = min(NEWEST_ROW, max(-transcriptRows, stockView.topRow + step))
|
||||
stockView.invalidate()
|
||||
// The selection highlight is recorded in THIS view's display list and is positioned
|
||||
// relative to `topRow`, so invalidating only the child would re-render scrolled glyphs
|
||||
// under a stale band. Reachable with a selection alive: an external mouse wheel still
|
||||
// scrolls (a selection lives in external rows, so moving the viewport is harmless).
|
||||
this@RemoteTerminalHostView.invalidate()
|
||||
}
|
||||
|
||||
override fun sendKeys(data: String) {
|
||||
@@ -134,6 +148,36 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
},
|
||||
)
|
||||
|
||||
// ── Text selection (the first-party replacement for the stock, crashing one) ──────────────────────
|
||||
|
||||
private val selectionBuffer = TerminalEmulatorSelectionBuffer { stockView.mEmulator }
|
||||
|
||||
private val selection = TerminalSelectionController(selectionBuffer, clipboard) { isActive ->
|
||||
if (isActive) {
|
||||
showCopyAffordance()
|
||||
} else {
|
||||
// A selection can be dropped mid-drag (a resize, a rebind), so the drag flag has to die with
|
||||
// it — a stale "true" would make the next pinch look like an extend.
|
||||
isExtendingSelection = false
|
||||
hideCopyAffordance()
|
||||
}
|
||||
// The highlight is painted by THIS view (dispatchDraw), so the frame — not just the child — has to
|
||||
// be invalidated for a span change to appear.
|
||||
invalidate()
|
||||
onSelectionChanged?.invoke(isActive)
|
||||
}
|
||||
|
||||
/** Set by [RemoteTerminalView] so `:app` can mirror the state in its own toolbar. */
|
||||
internal var onSelectionChanged: ((isActive: Boolean) -> Unit)? = null
|
||||
|
||||
private val selectionPainter = TerminalSelectionPainter()
|
||||
|
||||
/** The live floating toolbar, or null when nothing is selected (or the platform declined to start one). */
|
||||
private var copyAffordance: ActionMode? = null
|
||||
|
||||
/** True between the first extend MOVE and the lift, so the UP of a drag we own is reported as ours. */
|
||||
private var isExtendingSelection = false
|
||||
|
||||
init {
|
||||
addView(stockView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
||||
// Builds TerminalRenderer → onDraw is safe and the cell metrics become readable.
|
||||
@@ -159,13 +203,140 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
internal fun bindEmulator(emulator: TerminalEmulator) {
|
||||
stockView.mEmulator = emulator
|
||||
// A new emulator means a new transcript: drop any half-finished drag rather than let it resolve
|
||||
// rows against a buffer it did not start in.
|
||||
// rows against a buffer it did not start in, and drop any selection for the same reason — its rows
|
||||
// name content that no longer exists.
|
||||
scrollGesture.reset()
|
||||
selection.clear()
|
||||
}
|
||||
|
||||
/** Invalidate/redraw the stock view for the current emulator state (main thread). */
|
||||
/**
|
||||
* Invalidate/redraw the stock view for the current emulator state (main thread), keeping a live
|
||||
* selection pointing at the text it was made on.
|
||||
*
|
||||
* `TerminalView.onScreenUpdated` maintains a selection across incoming output — it shifts `mTopRow` by
|
||||
* `getScrollCounter()` and decrements its selection cursors — but only when `isSelectingText()` is true,
|
||||
* which for us is permanently false (we never arm the stock controller). What it does instead is reset
|
||||
* `mTopRow` to 0 and CLEAR the scroll counter, so both halves of that bookkeeping have to happen here:
|
||||
* read the counter first (nothing else can, afterwards), then re-pin the viewport and shift the span.
|
||||
* Skipping it would leave the user's highlight over text that scrolled away — and Copy would then put
|
||||
* something the user never selected on the clipboard.
|
||||
*/
|
||||
internal fun requestScreenUpdate() {
|
||||
val isSelecting = selection.isActive
|
||||
val scrolledRows = if (isSelecting) stockView.mEmulator?.scrollCounter ?: NO_SCROLL else NO_SCROLL
|
||||
val pinnedTopRow = if (isSelecting) stockView.topRow - scrolledRows else null
|
||||
|
||||
stockView.onScreenUpdated()
|
||||
|
||||
if (pinnedTopRow != null) {
|
||||
val oldestRow = -(stockView.mEmulator?.screen?.activeTranscriptRows ?: 0)
|
||||
stockView.topRow = pinnedTopRow.coerceIn(oldestRow, NEWEST_ROW)
|
||||
}
|
||||
selection.onContentScrolled(scrolledRows)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// ── Text-selection API (used by [RemoteTerminalView], which exposes it to `:app`) ──────────────────
|
||||
|
||||
internal val isSelectingText: Boolean get() = selection.isActive
|
||||
|
||||
/**
|
||||
* Start a selection at these view pixels, snapped to the word under them. Answering false means there
|
||||
* was no measured grid to resolve the pixels against, so the press is ignored.
|
||||
*/
|
||||
internal fun beginSelectionAt(xPx: Float, yPx: Float): Boolean {
|
||||
val cell = cellAt(xPx, yPx) ?: return false
|
||||
if (!selection.beginAt(cell)) return false
|
||||
// `getScrollCounter()` accumulates until something clears it, and the only thing that does is
|
||||
// `onScreenUpdated()`. A multi-chunk append posts one screen update for several main-thread appends,
|
||||
// so a long press can land between a scroll and its update — and those rows scrolled BEFORE this
|
||||
// selection existed. Counting them would shift the brand-new span by rows it never saw (measured: a
|
||||
// selection two rows of stale counter old landed on the wrong line). Zeroing here makes the shift in
|
||||
// [requestScreenUpdate] mean exactly "scrolled since the selection was made"; nothing else in the
|
||||
// pinned artifact reads the counter (stock only reads it inside `isSelectingText()`, always false).
|
||||
stockView.mEmulator?.clearScrollCounter()
|
||||
return true
|
||||
}
|
||||
|
||||
/** THE explicit copy (plan §8): nothing else in this module writes terminal text to the clipboard. */
|
||||
internal fun copySelection(): TerminalCopyResult = selection.copy()
|
||||
|
||||
internal fun clearSelection() {
|
||||
selection.clear()
|
||||
}
|
||||
|
||||
/** The text currently highlighted — for tests and for `:app` to preview; reads the buffer, mutates nothing. */
|
||||
internal fun selectedText(): String = selection.selectedText()
|
||||
|
||||
/** The highlight bands for the current selection, in this frame's pixel space. */
|
||||
internal fun highlightRects(): List<TerminalSelectionRect> {
|
||||
val span = selection.selection?.span ?: return emptyList()
|
||||
val bounds = selectionBuffer.bounds() ?: return emptyList()
|
||||
val metrics = cellMetrics() ?: return emptyList()
|
||||
return TerminalSelectionGeometry.highlightRects(span, bounds, metrics, stockView.topRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tap: dismiss a live selection, otherwise raise the soft keyboard.
|
||||
*
|
||||
* Dismissing takes precedence because a selection makes every drag an extend — the tap is how the user
|
||||
* gets scrolling back, and it is the platform-standard way to end a selection.
|
||||
*/
|
||||
internal fun onTapped() {
|
||||
if (selection.isActive) {
|
||||
selection.clear()
|
||||
return
|
||||
}
|
||||
showSoftKeyboard()
|
||||
}
|
||||
|
||||
private fun cellAt(xPx: Float, yPx: Float): TerminalCell? {
|
||||
val metrics = cellMetrics() ?: return null
|
||||
return TerminalSelectionGeometry.cellAt(xPx, yPx, metrics, stockView.topRow)
|
||||
}
|
||||
|
||||
private fun showCopyAffordance() {
|
||||
val existing = copyAffordance
|
||||
if (existing != null) {
|
||||
// The span moved: let the floating toolbar re-anchor rather than stacking a second mode.
|
||||
existing.invalidateContentRect()
|
||||
return
|
||||
}
|
||||
copyAffordance = startActionMode(
|
||||
TerminalSelectionActionMode(
|
||||
contentRect = { highlightRects().firstOrNull() },
|
||||
onCopy = {
|
||||
copySelection()
|
||||
selection.clear()
|
||||
},
|
||||
onDismissed = {
|
||||
copyAffordance = null
|
||||
selection.clear()
|
||||
},
|
||||
),
|
||||
ActionMode.TYPE_FLOATING,
|
||||
)
|
||||
}
|
||||
|
||||
private fun hideCopyAffordance() {
|
||||
// Null the field BEFORE finishing: finish() calls onDestroyActionMode, which calls back into
|
||||
// selection.clear(). The controller's own "already clear" guard stops the recursion; this stops a
|
||||
// second finish() on a dead mode.
|
||||
val mode = copyAffordance ?: return
|
||||
copyAffordance = null
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the children (the stock view paints every glyph) and then the selection highlight ON TOP.
|
||||
*
|
||||
* An overlay, not a renderer patch: plan §6.1 keeps rendering stock, and the renderer's own selection
|
||||
* channel (`TerminalView.mDefaultSelectors`) is package-private to `com.termux.view` — see
|
||||
* [TerminalSelectionPainter] for why reaching it was rejected.
|
||||
*/
|
||||
override fun dispatchDraw(canvas: Canvas) {
|
||||
super.dispatchDraw(canvas)
|
||||
selectionPainter.draw(canvas, highlightRects())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,12 +458,29 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
||||
/**
|
||||
* Widened to public (the `View` callback is `protected`) so the size-change behaviour — including the
|
||||
* selection drop below — is driven directly by a JVM test, exactly like the other overrides on this
|
||||
* frame. Nothing in production calls it; the platform does.
|
||||
*/
|
||||
public override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
||||
super.onSizeChanged(width, height, oldWidth, oldHeight)
|
||||
// A new size means a new grid and an emulator reflow, so the cells the span names are about to hold
|
||||
// different text. Dropping the selection is the honest response — re-mapping it across a reflow is
|
||||
// guesswork, and keeping it would let Copy return text the user never highlighted.
|
||||
selection.clear()
|
||||
sink?.onViewSize(width, height)
|
||||
}
|
||||
|
||||
/**
|
||||
* With a live selection every drag EXTENDS it; otherwise the drag goes to the scroll gesture.
|
||||
*
|
||||
* The branch is what makes the feature usable on a touch screen: without it the very drag that should
|
||||
* grow the selection would instead be claimed by [TerminalScrollGesture] and — inside an
|
||||
* alternate-screen TUI — typed into the shell as arrow keys.
|
||||
*/
|
||||
private fun routeTouch(event: MotionEvent): Boolean {
|
||||
if (selection.isActive) return routeSelectionTouch(event)
|
||||
eventInFlight = event
|
||||
try {
|
||||
return scrollGesture.onTouch(TouchFacts.of(event))
|
||||
@@ -301,6 +489,35 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeSelectionTouch(event: MotionEvent): Boolean = when (event.actionMasked) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
// A second finger is a pinch, which belongs to the stock ScaleGestureDetector (safe: no session
|
||||
// deref) and is where our onScale veto is enforced.
|
||||
if (event.pointerCount > SINGLE_POINTER) {
|
||||
isExtendingSelection
|
||||
} else {
|
||||
isExtendingSelection = true
|
||||
extendSelectionTo(event.x, event.y)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
val wasExtending = isExtendingSelection
|
||||
isExtendingSelection = false
|
||||
wasExtending
|
||||
}
|
||||
|
||||
// ACTION_DOWN is deliberately NOT claimed: the stock gesture detector has to keep seeing it so it
|
||||
// can still resolve the gesture into a tap (which dismisses the selection) or another long press
|
||||
// (which re-anchors it). Claiming it would make both impossible.
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun extendSelectionTo(xPx: Float, yPx: Float) {
|
||||
cellAt(xPx, yPx)?.let(selection::extendTo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock `sendMouseEventCode(event, MOUSE_WHEEL{UP,DOWN}_BUTTON, true)`, offsets 0-100: report the
|
||||
* wheel at the cell the gesture started on (one anchor per down-time) so a program tracking the mouse
|
||||
@@ -349,6 +566,12 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
/** No wheel gesture has been anchored yet (stock initialises `mMouseStartDownTime` to -1). */
|
||||
const val NO_DOWN_TIME: Long = -1L
|
||||
|
||||
/** Nothing scrolled off the top since the last screen update. */
|
||||
const val NO_SCROLL: Int = 0
|
||||
|
||||
/** A second pointer means a pinch, not a drag. */
|
||||
const val SINGLE_POINTER: Int = 1
|
||||
|
||||
/** `getColumnAndRow` returns `[column, row]`, both 0-based; the wire protocol is 1-based. */
|
||||
const val COLUMN_INDEX: Int = 0
|
||||
const val ROW_INDEX: Int = 1
|
||||
|
||||
@@ -17,9 +17,10 @@ import com.termux.terminal.TerminalEmulator
|
||||
*
|
||||
* **Glyph rendering and the cursor stay 100% stock. Scrolling and text selection do not** — both stock
|
||||
* implementations dereference the absent `TerminalSession`, so [RemoteTerminalHostView] owns vertical
|
||||
* drags and the long press is consumed ([WebTermTerminalViewClient.onLongPress], a recorded §6.5
|
||||
* deviation). [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member: which are
|
||||
* reachable, which are proven safe, and which had to be intercepted.
|
||||
* drags, and the long press is consumed ([WebTermTerminalViewClient.onLongPress]) and re-spent on a
|
||||
* first-party selection layer ([TerminalSelectionController]) rather than on the stock `ActionMode`, whose
|
||||
* Copy button is an NPE. [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member:
|
||||
* which are reachable, which are proven safe, and which had to be intercepted.
|
||||
*
|
||||
* ### What this class owns
|
||||
* It is the single place that knows which [RemoteTerminalSession] is bound, so it implements the
|
||||
@@ -33,9 +34,12 @@ import com.termux.terminal.TerminalEmulator
|
||||
* - **resize** — [RemoteTerminalHostView.onSizeChanged] → [TerminalResizeDriver] →
|
||||
* [RemoteTerminalSession.updateSize], which is the ONLY reason the server PTY ever learns the real
|
||||
* grid: `TerminalView.updateSize()` early-returns without a session (offsets 18–25).
|
||||
* - **selection** — [WebTermTerminalViewClient.onLongPress] → [RemoteTerminalHostView.beginSelectionAt],
|
||||
* then [copySelection] → `ClipboardManager`. Paste is untouched: it arrives through the IME.
|
||||
*
|
||||
* DEVICE-QA (plan §7): rendering, IME/CJK composition, selection, link taps, focus behaviour and grid
|
||||
* parity against web/iOS on the same physical size are verified on a device (risk R5).
|
||||
* DEVICE-QA (plan §7): rendering, IME/CJK composition, link taps, focus behaviour and grid parity against
|
||||
* web/iOS on the same physical size are verified on a device (risk R5) — and for selection specifically,
|
||||
* the floating Copy toolbar, the drag feel and the highlight's alignment with the glyphs.
|
||||
*/
|
||||
public class RemoteTerminalView(context: Context) {
|
||||
|
||||
@@ -55,7 +59,11 @@ public class RemoteTerminalView(context: Context) {
|
||||
}
|
||||
|
||||
override fun onTapped() {
|
||||
terminalView.showSoftKeyboard()
|
||||
terminalView.onTapped()
|
||||
}
|
||||
|
||||
override fun onLongPressAt(xPx: Float, yPx: Float) {
|
||||
terminalView.beginSelectionAt(xPx, yPx)
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) {
|
||||
@@ -107,11 +115,38 @@ public class RemoteTerminalView(context: Context) {
|
||||
/** Layout-change outlet (A17/A21 install point) — the §6.4 device-switch reclaim hook. */
|
||||
public var onViewSizeChanged: ((widthPx: Int, heightPx: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Fires whenever a text selection appears or disappears, so `:app` can mirror it (e.g. enable a Copy
|
||||
* button in the terminal toolbar). Purely optional: the module already shows its own floating Copy
|
||||
* affordance, so a host that ignores this still has working copy-out.
|
||||
*/
|
||||
public var onSelectionChanged: ((isSelecting: Boolean) -> Unit)? = null
|
||||
|
||||
init {
|
||||
terminalView.sink = sink
|
||||
// Installed BEFORE any emulator binds: touch, focus and the IME can all arrive first, and every
|
||||
// one of those stock paths dereferences `mClient` without a null check.
|
||||
terminalView.installClient(WebTermTerminalViewClient(sink))
|
||||
terminalView.onSelectionChanged = { isSelecting -> onSelectionChanged?.invoke(isSelecting) }
|
||||
}
|
||||
|
||||
// ── Copy-out (plan §6.5). The gesture path is self-contained; these are for `:app`'s own UI ────────
|
||||
|
||||
/** True while text is highlighted. */
|
||||
public val isSelectingText: Boolean get() = terminalView.isSelectingText
|
||||
|
||||
/**
|
||||
* Copy the highlighted text to the device clipboard. This is the ONLY path from terminal content to the
|
||||
* clipboard (plan §8): selecting copies nothing, and OSC 52 host-clipboard writes stay declined.
|
||||
*
|
||||
* Safe to call with nothing selected — it answers [TerminalCopyResult.NOTHING_SELECTED] rather than
|
||||
* clearing whatever the user already had.
|
||||
*/
|
||||
public fun copySelection(): TerminalCopyResult = terminalView.copySelection()
|
||||
|
||||
/** Drop the selection (also what a tap on the terminal does). */
|
||||
public fun clearSelection() {
|
||||
terminalView.clearSelection()
|
||||
}
|
||||
|
||||
/** Point the stock renderer at the forked emulator, then re-assert the measured grid on it. */
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PersistableBundle
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* The device clipboard, behind a one-method seam so the copy DECISION is unit-testable without a framework
|
||||
* clipboard and so there is exactly one place in the module that can write terminal text out of the app.
|
||||
*/
|
||||
internal interface TerminalClipboard {
|
||||
|
||||
/** @return true when [text] is now on the clipboard; false when the platform refused. */
|
||||
fun put(text: String): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* [TerminalClipboard] over `android.content.ClipboardManager`.
|
||||
*
|
||||
* ### Security posture (plan §8, §6.5)
|
||||
* Terminal output is untrusted and may be a secret — an API key echoed by a script, a token in a log line.
|
||||
* Two rules follow and both are enforced here rather than trusted to callers:
|
||||
*
|
||||
* - **Only an explicit user copy reaches this class.** There is no automatic path: OSC 52 host-clipboard
|
||||
* writes stay declined in [RemoteTerminalSession] (`onCopyTextToClipboard` is a no-op), and
|
||||
* [TerminalSelectionController] refuses to write without a live selection. Selecting text writes nothing.
|
||||
* - **The copied text is never logged.** Failures log the throwable TYPE only, matching
|
||||
* [RemoteTerminalSession]'s precedent; the payload never reaches logcat.
|
||||
*
|
||||
* The clip is also marked `EXTRA_IS_SENSITIVE` on API 33+, which suppresses the system clipboard preview
|
||||
* overlay. The user asked for the text, but nobody asked for it to be rendered over their screen for a
|
||||
* bystander — and this class cannot tell a filename from a password, so it treats every terminal copy as
|
||||
* sensitive.
|
||||
*/
|
||||
internal class SystemTerminalClipboard(private val context: Context) : TerminalClipboard {
|
||||
|
||||
override fun put(text: String): Boolean {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
if (clipboard == null) {
|
||||
Log.w(LOG_TAG, "no clipboard service available; nothing copied")
|
||||
return false
|
||||
}
|
||||
val clip = ClipData.newPlainText(CLIP_LABEL, text).apply { markSensitive() }
|
||||
return try {
|
||||
clipboard.setPrimaryClip(clip)
|
||||
true
|
||||
} catch (e: RuntimeException) {
|
||||
// OEM clipboard services throw here (SecurityException when not the focused app,
|
||||
// IllegalStateException / TransactionTooLargeException on a very large clip). A refused copy is
|
||||
// a reportable outcome, never a crash. Type only — the clip content must not reach logcat.
|
||||
Log.w(LOG_TAG, "clipboard refused the copy: ${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun ClipData.markSensitive() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
description.extras = PersistableBundle().apply {
|
||||
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Shown by the system clipboard UI. Deliberately generic: it must not describe WHICH session or
|
||||
* host the text came from.
|
||||
*/
|
||||
const val CLIP_LABEL: String = "Terminal selection"
|
||||
|
||||
const val LOG_TAG: String = "TerminalSelection"
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,23 @@ internal interface TerminalInputSink {
|
||||
/** Raw bytes onto the wire, verbatim (invariant #9 — never filtered). */
|
||||
fun sendInput(data: String)
|
||||
|
||||
/** The user tapped the terminal: take focus and raise the soft keyboard. */
|
||||
/**
|
||||
* The user tapped the terminal. Normally: take focus and raise the soft keyboard — but with a live
|
||||
* text selection the tap dismisses it instead (the standard platform gesture), which is why this is a
|
||||
* "tapped" event for the view to interpret rather than a "show the keyboard" command.
|
||||
*/
|
||||
fun onTapped()
|
||||
|
||||
/**
|
||||
* The user long-pressed at these view pixels: start a text selection there
|
||||
* ([RemoteTerminalHostView.beginSelectionAt]).
|
||||
*
|
||||
* The long press stays CONSUMED by [WebTermTerminalViewClient] either way — this is a notification, not
|
||||
* a decision. Stock text selection must remain unreachable (its Copy button dereferences the absent
|
||||
* `TerminalSession`), so the press is spent on the first-party layer instead of being handed back.
|
||||
*/
|
||||
fun onLongPressAt(xPx: Float, yPx: Float)
|
||||
|
||||
/** The host view was laid out at this pixel size (drives the §6.4 resize path). */
|
||||
fun onViewSize(widthPx: Int, heightPx: Int)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.ActionMode
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The explicit Copy affordance: a floating `ActionMode` over the selection.
|
||||
*
|
||||
* This is plan §6.5's "Android `ActionMode` → `ClipboardManager`" with the one part that could not be kept —
|
||||
* `TextSelectionCursorController` — replaced. It is our own `ActionMode.Callback2` on our own frame, so the
|
||||
* menu it builds has one item that calls [onCopy], and the stock callback whose Copy is
|
||||
* `mTermSession.onCopyTextToClipboard(...)` is never constructed. `TYPE_FLOATING` + [onGetContentRect] is the
|
||||
* same shape stock uses (`startActionMode(callback, 1)`), which is what makes the toolbar hover over the
|
||||
* selected text rather than take over the app bar.
|
||||
*
|
||||
* Paste is deliberately absent: it already works through the IME
|
||||
* ([RemoteTerminalInputConnection] → `writeInput`), and a paste item here would only duplicate it.
|
||||
*
|
||||
* @param contentRect the selection's bounding box in view pixels, or null when nothing is highlighted.
|
||||
* @param onCopy fired for the Copy item. The caller both copies and dismisses.
|
||||
* @param onDismissed fired when the mode ends for ANY reason (Copy, back, tapping away).
|
||||
*/
|
||||
internal class TerminalSelectionActionMode(
|
||||
private val contentRect: () -> TerminalSelectionRect?,
|
||||
private val onCopy: () -> Unit,
|
||||
private val onDismissed: () -> Unit,
|
||||
) : ActionMode.Callback2() {
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
// android.R.string.copy is the platform's own localised "Copy", so :terminal-view needs no
|
||||
// resources of its own for this.
|
||||
menu.add(Menu.NONE, COPY_ITEM_ID, Menu.NONE, android.R.string.copy)
|
||||
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Nothing to re-prepare: the single item is always enabled while a selection exists. */
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
if (item.itemId != COPY_ITEM_ID) return false
|
||||
onCopy()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode) {
|
||||
onDismissed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the floating toolbar should point. Falls back to the platform default (the whole view) when the
|
||||
* selection has scrolled out of sight, which is better than anchoring the toolbar off-screen.
|
||||
*/
|
||||
override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) {
|
||||
val rect = contentRect()
|
||||
if (rect == null) {
|
||||
super.onGetContentRect(mode, view, outRect)
|
||||
return
|
||||
}
|
||||
outRect.set(
|
||||
rect.left.roundToInt(),
|
||||
rect.top.roundToInt(),
|
||||
rect.right.roundToInt(),
|
||||
rect.bottom.roundToInt(),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Any non-zero id; there is only ever one item. */
|
||||
const val COPY_ITEM_ID: Int = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.util.Log
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
|
||||
/**
|
||||
* Read-only access to whatever the emulator currently holds — the ONE seam the selection layer reads the
|
||||
* terminal through.
|
||||
*
|
||||
* Two operations, both pure reads: what grid exists right now, and what text a span covers. Keeping it to
|
||||
* an interface is what lets the state machine ([TerminalSelectionController]) be driven by a fake, while
|
||||
* the real implementation is pinned against a real `TerminalEmulator` in the same JVM suite.
|
||||
*/
|
||||
internal interface TerminalSelectionBuffer {
|
||||
|
||||
/** The live grid, or null before an emulator is bound. */
|
||||
fun bounds(): TerminalGridBounds?
|
||||
|
||||
/**
|
||||
* The text inside [span]. Never throws: an off-grid span is clamped and a missing emulator yields "".
|
||||
* Implementations MUST NOT mutate anything (see [TerminalEmulatorSelectionBuffer] for why).
|
||||
*/
|
||||
fun textIn(span: TerminalSelectionSpan): String
|
||||
}
|
||||
|
||||
/**
|
||||
* [TerminalSelectionBuffer] over the bound Termux emulator.
|
||||
*
|
||||
* ### Threading — how a read is kept off a concurrent mutation (§6.2)
|
||||
* Every method here runs on the **main (render) thread**: the callers are touch/long-press/ActionMode
|
||||
* callbacks and `dispatchDraw`. Since the 2026-07-30 repair, EVERY emulator mutation also runs on the main
|
||||
* thread — [RemoteTerminalSession]'s single confined consumer hops to `mainDispatcher` for the `append` and
|
||||
* the `resize` and does nothing to the emulator anywhere else. So a read and a mutation are two main-thread
|
||||
* work items and **cannot interleave at all**. That is the same guarantee upstream Termux relies on
|
||||
* (`TerminalSession$MainThreadHandler.handleMessage` calls `append`), and it is the only one available:
|
||||
* `monitorenter` appears nowhere in `TerminalEmulator`, `TerminalBuffer`, `TerminalRow` or
|
||||
* `TerminalRenderer`, so there is no lock to take.
|
||||
*
|
||||
* It is worth being explicit about why "read it anyway and let a torn read self-correct" is not on the
|
||||
* table: that exact assumption was in this module's own KDoc until 2026-07-30 and it was wrong — a draw
|
||||
* landing inside `TerminalEmulator.resize`'s window (new `mColumns` published at offsets 97-106, rows
|
||||
* reallocated at offset 160) threw `ArrayIndexOutOfBoundsException` out of `TerminalRow.getStyle` and
|
||||
* killed the process on a real emulator. `getSelectedText` indexes the same `mText`/`mLines` arrays through
|
||||
* the same unsynchronised path, so a torn read here would be the same crash with the same lack of a
|
||||
* self-correcting next frame. **Do not move emulator mutation off the main thread**, and do not call these
|
||||
* methods from anywhere but the main thread.
|
||||
*
|
||||
* ### Why extraction is delegated rather than reimplemented
|
||||
* `TerminalBuffer.getSelectedText(x1, y1, x2, y2)` is the one part of the stock selection stack that never
|
||||
* touches `TerminalSession` — it walks rows via `TerminalRow.findStartOfColumn`, which is `WcWidth`-aware
|
||||
* (so a wide CJK cell copies whole from either of its two columns), trims each line's trailing blanks, and
|
||||
* joins soft-wrapped rows while preserving real line breaks. Re-deriving that in Kotlin would duplicate
|
||||
* subtle logic that must agree with what the renderer draws. `TerminalSelectionBufferTest` pins the
|
||||
* behaviour so a Termux bump that changes it fails loudly instead of silently copying the wrong bytes.
|
||||
*/
|
||||
internal class TerminalEmulatorSelectionBuffer(
|
||||
private val emulator: () -> TerminalEmulator?,
|
||||
) : TerminalSelectionBuffer {
|
||||
|
||||
override fun bounds(): TerminalGridBounds? {
|
||||
val emulator = emulator() ?: return null
|
||||
return TerminalGridBounds(
|
||||
columns = emulator.mColumns,
|
||||
screenRows = emulator.mRows,
|
||||
transcriptRows = emulator.screen.activeTranscriptRows,
|
||||
)
|
||||
}
|
||||
|
||||
override fun textIn(span: TerminalSelectionSpan): String {
|
||||
val emulator = emulator() ?: return ""
|
||||
val bounds = bounds() ?: return ""
|
||||
// Clamping INSIDE the port is what makes the two unguarded stock failures unreachable for every
|
||||
// caller: TerminalBuffer.externalToInternalRow throws IllegalArgumentException outside
|
||||
// -activeTranscriptRows..screenRows, and TerminalRow.findStartOfColumn walks mText with no bound
|
||||
// check for any column > mColumns. See TerminalSelectionSpan.clampedTo.
|
||||
val clamped = span.clampedTo(bounds) ?: return ""
|
||||
return try {
|
||||
emulator.getSelectedText(
|
||||
clamped.start.column,
|
||||
clamped.start.row,
|
||||
clamped.end.column,
|
||||
clamped.end.row,
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Should be unreachable after the clamp; a throw here would otherwise kill the process on the
|
||||
// UI thread. Log the throwable TYPE only — never the message, which can carry row content.
|
||||
logDroppedRead(e)
|
||||
""
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
logDroppedRead(e)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun logDroppedRead(throwable: Throwable) {
|
||||
Log.w(LOG_TAG, "dropping a selection read that threw: ${throwable.javaClass.simpleName}")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val LOG_TAG: String = "TerminalSelection"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The long-press snap: the run of non-blank cells around the pressed cell, so one press-and-release already
|
||||
* selects something worth copying (a path, a hash, a container id) instead of a single character.
|
||||
*
|
||||
* ### Deliberately not stock's expansion
|
||||
* `TextSelectionCursorController.setInitialTextSelectionPosition` (offsets 34-189) walks outward while the
|
||||
* neighbouring cell's text `!= " "` — but a blank cell's `getSelectedText` is `""`, not `" "`, because
|
||||
* trailing blanks are trimmed. So the stock comparison never matches and the expansion runs to both row
|
||||
* edges: in Termux a long press selects the whole line. Testing for *blank* instead gives word-granular
|
||||
* selection, which is what a shell line is actually made of; the user can still drag out to the rest of
|
||||
* the line.
|
||||
*/
|
||||
internal object TerminalSelectionWords {
|
||||
|
||||
/**
|
||||
* The selection a long press at [cell] should start with, or null when there is no grid yet.
|
||||
*
|
||||
* The anchor is the START of the run and the focus its END, so a subsequent drag backwards past the
|
||||
* word start flips the span the way a text editor does.
|
||||
*/
|
||||
fun runAt(cell: TerminalCell, buffer: TerminalSelectionBuffer): TerminalSelection? {
|
||||
val bounds = buffer.bounds() ?: return null
|
||||
if (!bounds.isMeasured) return null
|
||||
val pressed = bounds.clamp(cell)
|
||||
if (isBlank(pressed, buffer)) return TerminalSelection.at(pressed)
|
||||
|
||||
var first = pressed.column
|
||||
while (first > 0 && !isBlank(pressed.copy(column = first - 1), buffer)) first--
|
||||
|
||||
var last = pressed.column
|
||||
while (last < bounds.lastColumn && !isBlank(pressed.copy(column = last + 1), buffer)) last++
|
||||
|
||||
return TerminalSelection.anchoredOn(
|
||||
from = pressed.copy(column = first),
|
||||
to = pressed.copy(column = last),
|
||||
)
|
||||
}
|
||||
|
||||
/** A cell is blank when its extracted text is empty (trailing-blank trimming) or whitespace. */
|
||||
private fun isBlank(cell: TerminalCell, buffer: TerminalSelectionBuffer): Boolean =
|
||||
buffer.textIn(TerminalSelectionSpan(cell, cell)).isBlank()
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
/** What an explicit copy did. Public so `:app` can report the outcome in its own UI. */
|
||||
public enum class TerminalCopyResult {
|
||||
/** The selected text is on the clipboard. */
|
||||
COPIED,
|
||||
|
||||
/** Nothing was selected, so there was nothing to copy. */
|
||||
NOTHING_SELECTED,
|
||||
|
||||
/** The selection resolved to blank cells; the clipboard was deliberately left alone. */
|
||||
NOTHING_TO_COPY,
|
||||
|
||||
/** The platform clipboard refused the write (see [SystemTerminalClipboard]). */
|
||||
CLIPBOARD_UNAVAILABLE,
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection state machine: what a long press starts, what a drag does to it, what new output does to
|
||||
* it, and the single path from a live selection to the clipboard.
|
||||
*
|
||||
* Pure of android and of Termux — the terminal arrives through [TerminalSelectionBuffer] and leaves through
|
||||
* [TerminalClipboard], so every decision here is JVM-unit-testable. The view layer
|
||||
* ([RemoteTerminalHostView]) owns only pixels→cells and the affordance.
|
||||
*
|
||||
* Not thread-safe by design: like [TerminalResizeDriver] it is driven from touch and draw callbacks, i.e.
|
||||
* the main thread only. That is also the thread the emulator is mutated on, which is what makes the buffer
|
||||
* reads safe (see [TerminalEmulatorSelectionBuffer]).
|
||||
*
|
||||
* @param onChanged fired with the new "is a selection active" value whenever it or the span changes. The
|
||||
* view uses it to repaint the highlight and show/hide the Copy affordance.
|
||||
*/
|
||||
internal class TerminalSelectionController(
|
||||
private val buffer: TerminalSelectionBuffer,
|
||||
private val clipboard: TerminalClipboard,
|
||||
private val onChanged: (isActive: Boolean) -> Unit,
|
||||
) {
|
||||
/** The live selection, or null when there is none. Immutable — every change replaces it. */
|
||||
var selection: TerminalSelection? = null
|
||||
private set
|
||||
|
||||
val isActive: Boolean get() = selection != null
|
||||
|
||||
/**
|
||||
* Start a selection at [cell], snapped to the word under it ([TerminalSelectionWords]).
|
||||
*
|
||||
* @return true when a selection now exists. False means the grid is not measured yet (nothing is bound,
|
||||
* or the view has not been laid out), in which case the long press is simply ignored.
|
||||
*/
|
||||
fun beginAt(cell: TerminalCell): Boolean {
|
||||
val started = TerminalSelectionWords.runAt(cell, buffer) ?: return false
|
||||
publish(started)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Move the focus end to [cell] (clamped into the live grid). No-op without a selection. */
|
||||
fun extendTo(cell: TerminalCell) {
|
||||
val current = selection ?: return
|
||||
val bounds = buffer.bounds() ?: return
|
||||
if (!bounds.isMeasured) return
|
||||
publish(current.withFocus(bounds.clamp(cell)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the selection.
|
||||
*
|
||||
* Reports nothing when there was no selection — load-bearing, because the Copy affordance is dismissed
|
||||
* BY this method and its own dismissal callback calls it back; without the guard that is an infinite
|
||||
* loop (see [RemoteTerminalHostView]'s action-mode handling).
|
||||
*/
|
||||
fun clear() {
|
||||
if (selection == null) return
|
||||
selection = null
|
||||
onChanged(false)
|
||||
}
|
||||
|
||||
/** The text the current selection covers, or "" when there is none. Reads the buffer; mutates nothing. */
|
||||
fun selectedText(): String {
|
||||
val span = selection?.span ?: return ""
|
||||
return buffer.textIn(span)
|
||||
}
|
||||
|
||||
/**
|
||||
* THE explicit copy — the only path from terminal content to the device clipboard (plan §8).
|
||||
*
|
||||
* A blank result is refused rather than written: replacing whatever the user had on their clipboard with
|
||||
* an empty string is data loss, and it is the likely outcome of a stray long press on empty space.
|
||||
*/
|
||||
fun copy(): TerminalCopyResult {
|
||||
if (selection == null) return TerminalCopyResult.NOTHING_SELECTED
|
||||
val text = selectedText()
|
||||
if (text.isBlank()) return TerminalCopyResult.NOTHING_TO_COPY
|
||||
return if (clipboard.put(text)) TerminalCopyResult.COPIED else TerminalCopyResult.CLIPBOARD_UNAVAILABLE
|
||||
}
|
||||
|
||||
/**
|
||||
* [scrolledRows] new rows scrolled off the top of the screen, so every external row index the selection
|
||||
* holds is now that much smaller.
|
||||
*
|
||||
* Called from the screen-update path with `TerminalEmulator.getScrollCounter()` read BEFORE the stock
|
||||
* `onScreenUpdated()` clears it — the stock view only maintains this for its own selection controller
|
||||
* (`isSelectingText()`, always false here), so the shift has to be applied by us or the highlight ends
|
||||
* up pointing at different text than the user selected. Once the top of the selection falls past the
|
||||
* oldest retained transcript row the text is genuinely gone, and the selection is dropped rather than
|
||||
* silently re-pointed — mirroring stock's `stopTextSelectionMode()` in the same situation.
|
||||
*/
|
||||
fun onContentScrolled(scrolledRows: Int) {
|
||||
if (scrolledRows == 0) return
|
||||
val current = selection ?: return
|
||||
val bounds = buffer.bounds() ?: return
|
||||
val moved = current.movedBy(-scrolledRows)
|
||||
if (moved.span.start.row < bounds.firstRow) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
publish(moved)
|
||||
}
|
||||
|
||||
private fun publish(next: TerminalSelection) {
|
||||
if (next == selection) return
|
||||
selection = next
|
||||
onChanged(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import kotlin.math.floor
|
||||
|
||||
/** One highlight band, in the host frame's own pixel space (which is the stock child's — it sits at 0,0). */
|
||||
internal data class TerminalSelectionRect(
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pixels ↔ cells for the selection layer. Pure: the caller supplies the measured cell box and the
|
||||
* viewport's `topRow`, so both directions are JVM-unit-testable without a device — the same trick
|
||||
* [TerminalGridMath] plays for the resize formula.
|
||||
*
|
||||
* ### The mapping, and why it is not stock's
|
||||
* This is the exact inverse of the stock accessors this module already reads:
|
||||
* `TerminalView.getPointX(col)` is `round(col * fontWidth)` and `getPointY(row)` is
|
||||
* `(row - topRow) * lineSpacing`, i.e. cell `(c, r)` owns the band
|
||||
* `[c*fontWidth, (c+1)*fontWidth) x [(r-topRow)*lineSpacing, (r-topRow+1)*lineSpacing)`. `TerminalRenderer`
|
||||
* draws each row's baseline inside that same band (`y += mFontLineSpacing` per row, starting at
|
||||
* `mFontLineSpacingAndAscent`), so a highlight built this way lines up with the glyphs.
|
||||
*
|
||||
* Stock's own hit test ([com.termux.view.TerminalView.getColumnAndRow]) instead computes
|
||||
* `(y - mFontLineSpacingAndAscent) / mFontLineSpacing`, which subtracts the (negative) font ascent and so
|
||||
* lands roughly a fifth of a row above the glyph the user pressed. Reproducing that would make a long
|
||||
* press select the line above itself near a row boundary, so it is deliberately not reproduced.
|
||||
*/
|
||||
internal object TerminalSelectionGeometry {
|
||||
|
||||
/**
|
||||
* The cell under a touch, or null when the renderer has not measured a cell box yet (before the first
|
||||
* layout `fontWidth`/`lineSpacing` are 0 and there is no grid to divide by).
|
||||
*
|
||||
* The result is NOT clamped — a touch outside the grid legitimately resolves to an off-grid cell, and
|
||||
* clamping happens once, against the live bounds, in [TerminalSelectionSpan.clampedTo].
|
||||
*/
|
||||
fun cellAt(xPx: Float, yPx: Float, metrics: TerminalCellMetrics, topRow: Int): TerminalCell? {
|
||||
if (metrics.fontWidthPx <= 0f || metrics.lineSpacingPx <= 0) return null
|
||||
return TerminalCell(
|
||||
column = floor(xPx / metrics.fontWidthPx).toInt(),
|
||||
row = floor(yPx / metrics.lineSpacingPx).toInt() + topRow,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One band per VISIBLE row of [span].
|
||||
*
|
||||
* Rows above or below the viewport are dropped rather than emitted with an off-screen `top`: a
|
||||
* selection made in scrollback and then scrolled away must paint nothing, and a 10 000-row transcript
|
||||
* selection must not build 10 000 rects for a 24-row screen.
|
||||
*/
|
||||
fun highlightRects(
|
||||
span: TerminalSelectionSpan,
|
||||
bounds: TerminalGridBounds,
|
||||
metrics: TerminalCellMetrics,
|
||||
topRow: Int,
|
||||
): List<TerminalSelectionRect> {
|
||||
if (!bounds.isMeasured) return emptyList()
|
||||
if (metrics.fontWidthPx <= 0f || metrics.lineSpacingPx <= 0) return emptyList()
|
||||
|
||||
val firstVisibleRow = maxOf(span.start.row, topRow)
|
||||
val lastVisibleRow = minOf(span.end.row, topRow + bounds.screenRows - 1)
|
||||
if (firstVisibleRow > lastVisibleRow) return emptyList()
|
||||
|
||||
return (firstVisibleRow..lastVisibleRow).mapNotNull { row ->
|
||||
val columns = span.columnsIn(row, bounds.lastColumn) ?: return@mapNotNull null
|
||||
val top = (row - topRow) * metrics.lineSpacingPx.toFloat()
|
||||
TerminalSelectionRect(
|
||||
left = columns.first * metrics.fontWidthPx,
|
||||
top = top,
|
||||
// Inclusive columns → the band covers the whole last cell, hence the +1.
|
||||
right = (columns.last + 1) * metrics.fontWidthPx,
|
||||
bottom = top + metrics.lineSpacingPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
/**
|
||||
* The first-party text-selection model — cell coordinates, normalisation and clamping.
|
||||
*
|
||||
* ### Why this exists at all (the stock selection UI is a crash, not a feature)
|
||||
* `TerminalView.startTextSelectionMode` arms an `ActionMode` whose own handlers dereference the
|
||||
* `TerminalSession` this app deliberately does not have:
|
||||
* `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 are
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 are `mTermSession.onPasteTextFromClipboard()`
|
||||
* (re-verified with `javap -p -c` on the pinned `terminal-view-v0.118.0.aar`). So making the stock UI
|
||||
* reachable buys a selection whose **Copy button is an NPE**, which is strictly worse than no selection —
|
||||
* the user loses their work to a process kill instead of finding the affordance absent. `mTermSession`
|
||||
* cannot be supplied: `TerminalSession` is `final` and its constructor forks a local process, which is
|
||||
* exactly what this app must not do (plan §6.1).
|
||||
*
|
||||
* The one thing in that stock stack that IS session-free is the extraction —
|
||||
* `TerminalBuffer.getSelectedText(x1, y1, x2, y2)` reads rows and nothing else. So the replacement is: our
|
||||
* own coordinates (this file), our own hit-testing and highlight ([TerminalSelectionGeometry]), stock
|
||||
* extraction ([TerminalSelectionBuffer]) and a direct `ClipboardManager` write ([TerminalClipboard]) —
|
||||
* `TextSelectionCursorController` bypassed entirely, `onLongPress` still consuming so the stock mode can
|
||||
* never arm.
|
||||
*
|
||||
* Everything in this file is pure and immutable: no android, no Termux, no mutation. Every operation
|
||||
* returns a new value.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One terminal cell, ordered **row-major** — which is what makes a drag up-and-right still a backwards
|
||||
* drag, and lets [TerminalSelectionSpan] normalise with plain `minOf`/`maxOf`.
|
||||
*
|
||||
* @param column 0-based, left to right.
|
||||
* @param row an **external** row in Termux's sense: `0 .. screenRows-1` is the live screen and negative
|
||||
* rows walk back into the scrollback transcript. External rows are independent of the viewport's
|
||||
* `topRow`, so scrolling the view never moves a selection — but new output does (see [movedBy]).
|
||||
*/
|
||||
internal data class TerminalCell(val column: Int, val row: Int) : Comparable<TerminalCell> {
|
||||
|
||||
override fun compareTo(other: TerminalCell): Int =
|
||||
if (row != other.row) row.compareTo(other.row) else column.compareTo(other.column)
|
||||
|
||||
fun movedBy(rows: Int): TerminalCell = copy(row = row + rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* An **inclusive**, row-major-ordered cell range: [start] is never after [end].
|
||||
*
|
||||
* That invariant is established by construction — every producer ([spanning], [clampedTo], [expandedTo])
|
||||
* orders its output — and it is what lets [columnsIn] and the buffer read treat start/end as first/last
|
||||
* without re-checking.
|
||||
*/
|
||||
internal data class TerminalSelectionSpan(val start: TerminalCell, val end: TerminalCell) {
|
||||
|
||||
val isSingleCell: Boolean get() = start == end
|
||||
|
||||
/** The rows this span touches, oldest (smallest) first. */
|
||||
val rows: IntRange get() = start.row..end.row
|
||||
|
||||
/**
|
||||
* The columns selected on [row], or null when [row] is outside the span.
|
||||
*
|
||||
* The first row runs from its start column to the grid edge, the last row from column 0 to its end
|
||||
* column, and every row between is whole — matching `TerminalBuffer.getSelectedText`'s own per-row
|
||||
* range (offsets 57-98), so the highlight and the copied text can never disagree.
|
||||
*/
|
||||
fun columnsIn(row: Int, lastColumn: Int): IntRange? {
|
||||
if (row < start.row || row > end.row) return null
|
||||
val first = if (row == start.row) start.column else 0
|
||||
val last = if (row == end.row) end.column else lastColumn
|
||||
return first..last
|
||||
}
|
||||
|
||||
/** This span grown to include [cell]. A cell already inside it changes nothing. */
|
||||
fun expandedTo(cell: TerminalCell): TerminalSelectionSpan =
|
||||
TerminalSelectionSpan(minOf(start, cell), maxOf(end, cell))
|
||||
|
||||
fun movedBy(rows: Int): TerminalSelectionSpan =
|
||||
TerminalSelectionSpan(start.movedBy(rows), end.movedBy(rows))
|
||||
|
||||
/**
|
||||
* This span clamped into [bounds], or null when the grid has not been measured yet.
|
||||
*
|
||||
* **Load-bearing, not defensive dressing.** An unclamped cell reaches two unguarded stock paths:
|
||||
* `TerminalBuffer.externalToInternalRow` THROWS `IllegalArgumentException` for a row outside
|
||||
* `-activeTranscriptRows .. screenRows`, and `TerminalRow.findStartOfColumn` walks `mText` with **no
|
||||
* bound check** for any `column > mColumns` (offsets 13-173 — only the exact `column == mColumns` case
|
||||
* short-circuits), so an off-grid column runs off the end of the char array. A touch a few pixels past
|
||||
* the last cell, or a drag that leaves the view, produces exactly those inputs.
|
||||
*/
|
||||
fun clampedTo(bounds: TerminalGridBounds): TerminalSelectionSpan? {
|
||||
if (!bounds.isMeasured) return null
|
||||
return TerminalSelectionSpan(bounds.clamp(start), bounds.clamp(end))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The normalising factory: the two cells in either order produce the same span. */
|
||||
fun spanning(one: TerminalCell, other: TerminalCell): TerminalSelectionSpan =
|
||||
TerminalSelectionSpan(minOf(one, other), maxOf(one, other))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A live selection as the user's gesture describes it.
|
||||
*
|
||||
* @param anchor what the long press claimed — a **span**, not a cell, because a press snaps to the word
|
||||
* under it ([TerminalSelectionWords]). Keeping the whole anchor means a drag backwards past the word
|
||||
* start still leaves that word selected, the way a text editor behaves, instead of collapsing it to the
|
||||
* single cell the drag passed through.
|
||||
* @param focus where the finger is now.
|
||||
*/
|
||||
internal data class TerminalSelection(val anchor: TerminalSelectionSpan, val focus: TerminalCell) {
|
||||
|
||||
/**
|
||||
* The normalised, inclusive span: the anchor grown to reach the focus. A backwards drag yields exactly
|
||||
* the same span as the equivalent forwards one — [TerminalSelectionSpan.expandedTo] orders by
|
||||
* construction, so there is no direction to get wrong.
|
||||
*/
|
||||
val span: TerminalSelectionSpan get() = anchor.expandedTo(focus)
|
||||
|
||||
fun withFocus(cell: TerminalCell): TerminalSelection = copy(focus = cell)
|
||||
|
||||
/**
|
||||
* Shift the whole selection by [rows] (negative == the screen scrolled up under a live selection).
|
||||
*
|
||||
* New output renumbers external rows: the line the user selected is one row lower after each new line
|
||||
* arrives. Without this shift the highlight would keep pointing at the same coordinates while the text
|
||||
* under it changed — and the Copy button would then put *different* text on the clipboard than the one
|
||||
* highlighted, which for terminal content (possibly secrets) is a correctness bug, not a cosmetic one.
|
||||
*/
|
||||
fun movedBy(rows: Int): TerminalSelection =
|
||||
TerminalSelection(anchor.movedBy(rows), focus.movedBy(rows))
|
||||
|
||||
companion object {
|
||||
/** A selection of one cell — the fallback when a press lands on blank space. */
|
||||
fun at(cell: TerminalCell): TerminalSelection =
|
||||
TerminalSelection(TerminalSelectionSpan(cell, cell), cell)
|
||||
|
||||
/** A selection anchored on a whole run of cells, focused at its end. */
|
||||
fun anchoredOn(from: TerminalCell, to: TerminalCell): TerminalSelection =
|
||||
TerminalSelection(TerminalSelectionSpan.spanning(from, to), maxOf(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live grid a selection is resolved against: the emulator's `mColumns` / `mRows` and its buffer's
|
||||
* `activeTranscriptRows`. Read fresh for every operation (the grid changes on resize and the transcript
|
||||
* grows with output), never cached.
|
||||
*/
|
||||
internal data class TerminalGridBounds(
|
||||
val columns: Int,
|
||||
val screenRows: Int,
|
||||
val transcriptRows: Int,
|
||||
) {
|
||||
/** False before the first layout/bind, when there is no grid to clamp into. */
|
||||
val isMeasured: Boolean get() = columns > 0 && screenRows > 0
|
||||
|
||||
val lastColumn: Int get() = columns - 1
|
||||
|
||||
/** The oldest retained row; `getSelectedText` clamps to the same floor (offsets 15-29). */
|
||||
val firstRow: Int get() = -transcriptRows
|
||||
|
||||
/** The newest row of the live screen; `getSelectedText` clamps to the same ceiling (offsets 30-46). */
|
||||
val lastRow: Int get() = screenRows - 1
|
||||
|
||||
fun clamp(cell: TerminalCell): TerminalCell = TerminalCell(
|
||||
column = cell.column.coerceIn(0, lastColumn),
|
||||
row = cell.row.coerceIn(firstRow, lastRow),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
|
||||
/**
|
||||
* Paints the selection highlight as an OVERLAY, on top of the stock glyphs.
|
||||
*
|
||||
* ### Why an overlay rather than the renderer's own selection channel
|
||||
* `TerminalRenderer.render` already knows how to invert selected cells — `TerminalView.onDraw` passes it
|
||||
* four selector ints and the renderer flags every column between them. That channel is
|
||||
* `TerminalView.mDefaultSelectors`, which is **package-private** to `com.termux.view`, so reaching it would
|
||||
* take either reflection (silently broken by a version bump, and the module already refused reflection for
|
||||
* the cell metrics) or a source file planted in Termux's package. Neither is worth it for a highlight, and
|
||||
* plan §6.1 keeps rendering stock: nothing here patches `TerminalRenderer` or `TerminalView.onDraw`.
|
||||
*
|
||||
* A translucent fill drawn after the children therefore does the job. It is drawn in the host frame's own
|
||||
* coordinate space, which is the stock child's — the child fills the frame at (0,0) and `onDraw` applies no
|
||||
* canvas translation, so the bands line up with the glyphs cell for cell.
|
||||
*/
|
||||
internal class TerminalSelectionPainter {
|
||||
|
||||
private val fill = Paint().apply {
|
||||
color = SELECTION_ARGB
|
||||
style = Paint.Style.FILL
|
||||
// Cell-aligned rectangles: smoothing their edges only blurs the boundary between two selected rows.
|
||||
isAntiAlias = false
|
||||
}
|
||||
|
||||
fun draw(canvas: Canvas, rects: List<TerminalSelectionRect>) {
|
||||
rects.forEach { rect -> canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, fill) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* The design system's amber-gold (#E3A64A) at 35% alpha.
|
||||
*
|
||||
* Translucent because it is drawn OVER the glyphs: an opaque fill would hide the very text the user
|
||||
* is about to copy. The literal is inline because `:terminal-view` has no dependency on `:app`'s
|
||||
* design tokens (dependencies only flow down) — keep it in step with `WebTermColors` by hand.
|
||||
*/
|
||||
const val SELECTION_ARGB: Int = 0x59E3A64A.toInt()
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ import com.termux.view.TerminalViewClient
|
||||
* reachable and which are proven safe. Rendering and the cursor stay stock; scrolling and text selection
|
||||
* do not (plan §6.1/§6.5 deviations, both recorded there and on [onLongPress]).
|
||||
*
|
||||
|
||||
* Text selection is the one that changed shape rather than being cut: the long press is still consumed —
|
||||
* the stock `ActionMode` can never arm — but it now starts the first-party selection layer
|
||||
* ([TerminalSelectionController]) instead of being discarded.
|
||||
*
|
||||
* @param sink the single outlet — `:app`'s chord router, the emulator's live cursor modes, and the byte
|
||||
* send pump. See [TerminalInputSink].
|
||||
*/
|
||||
@@ -126,44 +129,31 @@ internal class WebTermTerminalViewClient(private val sink: TerminalInputSink) :
|
||||
override fun onScale(scale: Float): Float = NO_SCALE
|
||||
|
||||
/**
|
||||
* MUST stay true — text selection is **cut**, a recorded deviation from plan §6.5.
|
||||
* Start a FIRST-PARTY text selection at the press, and consume the event.
|
||||
*
|
||||
* `TerminalView$1.onLongPress` offsets 14-30 are `if (mClient.onLongPress(e)) return;`, so true is the
|
||||
* one supported way to stop `startTextSelectionMode` from arming. It has to be stopped, because the
|
||||
* stock selection ActionMode is a crash rather than a feature without a `TerminalSession`:
|
||||
* **The return value MUST stay true.** `TerminalView$1.onLongPress` offsets 14-30 are
|
||||
* `if (mClient.onLongPress(e)) return;`, so true is the one supported way to stop
|
||||
* `startTextSelectionMode` from arming — and it has to be stopped, because the stock selection
|
||||
* `ActionMode` is a crash rather than a feature without a `TerminalSession`:
|
||||
* `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 (**Copy**) are
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 (**Paste**) are
|
||||
* `mTermSession.onPasteTextFromClipboard()` — both unguarded, both on the only two buttons the mode
|
||||
* exists for. Selecting text would work; the moment the user tapped Copy the process would die.
|
||||
* `mTermSession` cannot be supplied either: `TerminalSession` is `final`, its constructor builds a
|
||||
* process-bound `MainThreadHandler`, and having no local subprocess is the point (plan §6.1).
|
||||
*
|
||||
* The controller itself is otherwise session-free (it reads `TerminalBuffer.getSelectedText`), so a
|
||||
* clipboard path is buildable — but it needs its own ActionMode, its own copy action and its own
|
||||
* selection highlight, i.e. a feature, not a guard. Until then a long press is a no-op instead of a
|
||||
* loaded gun, and the deviation is reported to the plan owner rather than left silently dead.
|
||||
* Copy-out is therefore **no longer a feature gap**: the press is spent on the replacement layer
|
||||
* instead of being discarded. [TerminalSelection] carries the design — our own cell coordinates
|
||||
* and highlight, stock's session-free `TerminalBuffer.getSelectedText` for the extraction, and a direct
|
||||
* `ClipboardManager` write ([TerminalClipboard]), with `TextSelectionCursorController` bypassed
|
||||
* entirely. The affordance is our own floating `ActionMode` ([TerminalSelectionActionMode]), which is
|
||||
* what plan §6.5 asks for; only the stock controller is replaced. Paste is untouched — it already works
|
||||
* through the IME ([RemoteTerminalInputConnection]).
|
||||
*/
|
||||
/**
|
||||
* Consume the long press, which suppresses stock text selection.
|
||||
*
|
||||
* **RECORDED DEVIATION from plan §6.5** ("Selection/copy: Termux TextSelectionCursorController +
|
||||
* Android ActionMode → ClipboardManager"), and a KNOWN FEATURE GAP — not an oversight, and not the
|
||||
* cheaper of two equal options. An adversarial review asked for stock selection to be RESTORED after
|
||||
* an earlier revision made it unreachable. Disassembling the pinned artifact shows restoring it would
|
||||
* only relocate the crash rather than remove it: the ActionMode's own handlers dereference the absent
|
||||
* session. `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 are
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 are
|
||||
* `mTermSession.onPasteTextFromClipboard()`. So `startTextSelectionMode` being reachable buys a
|
||||
* selection UI whose **Copy button is an NPE** — strictly worse than no selection, because the user
|
||||
* loses their work to a crash instead of finding the affordance absent.
|
||||
*
|
||||
* `mTermSession` cannot be supplied: `TerminalSession` is `final`, its constructor builds a
|
||||
* process-bound `MainThreadHandler`, and this app deliberately has no local subprocess (plan §6.1).
|
||||
*
|
||||
* The real fix is a first-party selection layer that reads the emulator's `TerminalBuffer` and talks
|
||||
* to `ClipboardManager` directly, bypassing `TextSelectionCursorController` entirely. That is a
|
||||
* feature, not a crash fix, so it is tracked in DEVICE_QA_CHECKLIST rather than smuggled in here.
|
||||
* Until then: copy-out is unavailable, and paste-in still works through the IME.
|
||||
*/
|
||||
override fun onLongPress(event: MotionEvent?): Boolean = true
|
||||
override fun onLongPress(event: MotionEvent?): Boolean {
|
||||
event?.let { sink.onLongPressAt(it.x, it.y) }
|
||||
return true
|
||||
}
|
||||
|
||||
override fun copyModeChanged(copyMode: Boolean) = Unit
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
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.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_HEIGHT_PX
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_WIDTH_PX
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.down
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.move
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.up
|
||||
|
||||
/**
|
||||
* Copy-out, driven through the real host frame and the real `TerminalViewClient` — the first-party
|
||||
* replacement for the stock selection UI that cannot be made reachable (its Copy button dereferences the
|
||||
* absent `TerminalSession`; see [TerminalSelection]'s doc and [WebTermTerminalViewClient.onLongPress]).
|
||||
*
|
||||
* The gesture entry point is called directly because Termux's `GestureAndScaleRecognizer` is framework code,
|
||||
* stripped by the mockable `android.jar`. Everything after it — the client's decision, the pixel→cell
|
||||
* mapping, the touch routing, the state machine, the buffer read — is the real compiled path.
|
||||
*/
|
||||
class RemoteTerminalSelectionTest {
|
||||
|
||||
private lateinit var host: RemoteTerminalHostView
|
||||
private lateinit var client: WebTermTerminalViewClient
|
||||
private lateinit var clipboard: RecordingClipboard
|
||||
private lateinit var sink: SelectionRoutingSink
|
||||
|
||||
/** The same forwarding [RemoteTerminalView] installs: long press and tap belong to the frame. */
|
||||
private class SelectionRoutingSink(private val host: () -> RemoteTerminalHostView) : TerminalInputSink {
|
||||
val sent = mutableListOf<String>()
|
||||
var keyboardRequests = 0
|
||||
|
||||
override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false
|
||||
override fun cursorModes(): TerminalCursorModes? = null
|
||||
override fun sendInput(data: String) {
|
||||
sent += data
|
||||
}
|
||||
|
||||
override fun onTapped() {
|
||||
if (host().isSelectingText) host().clearSelection() else keyboardRequests++
|
||||
}
|
||||
|
||||
override fun onLongPressAt(xPx: Float, yPx: Float) {
|
||||
host().beginSelectionAt(xPx, yPx)
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) = Unit
|
||||
}
|
||||
|
||||
private class RecordingClipboard : TerminalClipboard {
|
||||
val puts = mutableListOf<String>()
|
||||
|
||||
override fun put(text: String): Boolean {
|
||||
puts += text
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
StockTerminalViewFixture.mockCellMetrics()
|
||||
clipboard = RecordingClipboard()
|
||||
host = RemoteTerminalHostView(StockTerminalViewFixture.context(), clipboard)
|
||||
sink = SelectionRoutingSink { host }
|
||||
host.sink = sink
|
||||
client = WebTermTerminalViewClient(sink)
|
||||
host.installClient(client)
|
||||
}
|
||||
|
||||
@AfterEach fun tearDown() = unmockkAll()
|
||||
|
||||
private fun bind(vararg output: String): TerminalEmulator {
|
||||
val emulator = StockTerminalViewFixture.emulator()
|
||||
output.forEach { emulator.feed(it) }
|
||||
host.bindEmulator(emulator)
|
||||
return emulator
|
||||
}
|
||||
|
||||
/** Pixel centre of a cell, using the fixture's pinned cell box. */
|
||||
private fun x(column: Int) = (column + 0.5f) * CELL_WIDTH_PX
|
||||
private fun y(row: Int) = (row + 0.5f) * CELL_HEIGHT_PX
|
||||
|
||||
private fun longPress(column: Int, row: Int) {
|
||||
assertTrue(
|
||||
client.onLongPress(down(y = y(row), x = x(column))),
|
||||
"the long press must stay CONSUMED so stock text selection can never arm",
|
||||
)
|
||||
}
|
||||
|
||||
// ── The constraint: the stock selection mode stays unreachable ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a long press starts a first-party selection and never arms the stock ActionMode`() {
|
||||
bind("cat /etc/hosts")
|
||||
|
||||
longPress(column = 6, row = 0)
|
||||
|
||||
assertTrue(host.isSelectingText, "the first-party layer owns the selection")
|
||||
assertFalse(
|
||||
host.stockView.isSelectingText,
|
||||
"TextSelectionCursorController's Copy is mTermSession.onCopyTextToClipboard — an NPE",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long press before an emulator is bound is simply ignored`() {
|
||||
longPress(column = 3, row = 0)
|
||||
|
||||
assertFalse(host.isSelectingText)
|
||||
}
|
||||
|
||||
// ── Selecting, extending, copying ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the long press snaps to the word under the finger and copies exactly that`() {
|
||||
bind("cat /etc/hosts")
|
||||
longPress(column = 6, row = 0)
|
||||
|
||||
assertEquals(TerminalCopyResult.COPIED, host.copySelection())
|
||||
|
||||
assertEquals(listOf("/etc/hosts"), clipboard.puts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dragging after the long press extends the selection instead of scrolling`() {
|
||||
// The same vertical drag with no selection is a transcript scroll / arrow-key emit; with a live
|
||||
// selection it must extend, or the feature is unusable on a touch screen.
|
||||
bind("alpha\r\nbravo\r\ncharlie\r\n")
|
||||
longPress(column = 0, row = 0)
|
||||
|
||||
assertTrue(host.onInterceptTouchEvent(move(y = y(2), x = x(2))), "the extend drag is claimed")
|
||||
host.onTouchEvent(up(y = y(2), x = x(2)))
|
||||
|
||||
assertEquals(TerminalCopyResult.COPIED, host.copySelection())
|
||||
assertEquals(listOf("alpha\nbravo\ncha"), clipboard.puts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an extend drag inside an alternate-screen TUI sends no arrow keys`() {
|
||||
// Without the selection branch this drag would go to TerminalScrollGesture and type into the TUI.
|
||||
bind(ENTER_ALTERNATE_BUFFER, "vim buffer")
|
||||
longPress(column = 0, row = 0)
|
||||
|
||||
host.onInterceptTouchEvent(move(y = y(3), x = x(5)))
|
||||
host.onTouchEvent(up(y = y(3), x = x(5)))
|
||||
|
||||
assertTrue(sink.sent.isEmpty(), "extending a selection must not reach the shell")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drag backwards past the anchor still selects forwards`() {
|
||||
bind("alpha\r\nbravo\r\ncharlie\r\n")
|
||||
longPress(column = 2, row = 2) // snaps to "charlie", columns 0-6
|
||||
|
||||
host.onInterceptTouchEvent(move(y = y(0), x = x(2)))
|
||||
host.onTouchEvent(up(y = y(0), x = x(2)))
|
||||
|
||||
assertEquals(TerminalCopyResult.COPIED, host.copySelection())
|
||||
assertEquals(listOf("pha\nbravo\ncharlie"), clipboard.puts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second finger during an extend is not read as an extend`() {
|
||||
bind("alpha\r\nbravo\r\ncharlie\r\n")
|
||||
longPress(column = 0, row = 0)
|
||||
val before = host.copySelection().also { clipboard.puts.clear() }
|
||||
|
||||
host.onInterceptTouchEvent(move(y = y(2), x = x(2), pointers = 2))
|
||||
|
||||
assertEquals(TerminalCopyResult.COPIED, before)
|
||||
assertEquals(TerminalCopyResult.COPIED, host.copySelection())
|
||||
assertEquals(listOf("alpha"), clipboard.puts, "the pinch left the selection alone")
|
||||
}
|
||||
|
||||
// ── Dismissal ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a tap dismisses the selection instead of popping the keyboard`() {
|
||||
bind("cat /etc/hosts")
|
||||
longPress(column = 1, row = 0)
|
||||
|
||||
client.onSingleTapUp(down(y = y(0), x = x(0)))
|
||||
|
||||
assertFalse(host.isSelectingText)
|
||||
assertEquals(0, sink.keyboardRequests, "the tap is spent on the dismissal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap with no selection still raises the keyboard`() {
|
||||
bind("cat /etc/hosts")
|
||||
|
||||
client.onSingleTapUp(down(y = y(0), x = x(0)))
|
||||
|
||||
assertEquals(1, sink.keyboardRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `after a dismissal a drag scrolls again`() {
|
||||
val emulator = bind()
|
||||
repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") }
|
||||
longPress(column = 0, row = 0)
|
||||
client.onSingleTapUp(down(y = y(0), x = x(0)))
|
||||
|
||||
assertFalse(host.onInterceptTouchEvent(down(START_Y)))
|
||||
assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX)))
|
||||
host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + 2 * CELL_HEIGHT_PX))
|
||||
|
||||
assertEquals(-2, host.stockView.topRow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `binding a new emulator drops a stale selection`() {
|
||||
bind("cat /etc/hosts")
|
||||
longPress(column = 1, row = 0)
|
||||
|
||||
bind("a different session")
|
||||
|
||||
assertFalse(host.isSelectingText, "the old span points into a transcript that no longer exists")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a layout change drops the selection`() {
|
||||
bind("cat /etc/hosts")
|
||||
longPress(column = 1, row = 0)
|
||||
|
||||
// A new size means a regrid and a reflow: the cells the span names are about to hold other text.
|
||||
host.onSizeChanged(VIEW_WIDTH_PX, VIEW_HEIGHT_PX, 0, 0)
|
||||
|
||||
assertFalse(host.isSelectingText)
|
||||
}
|
||||
|
||||
// ── Live output under a selection ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `new output shifts the selection and pins the viewport so the highlight stays on its text`() {
|
||||
val emulator = bind("target\r\n")
|
||||
// Overfills the screen on purpose: those rows scroll BEFORE the press, so the emulator's scroll
|
||||
// counter is already non-zero when the selection is made. Counting them would shift the span by
|
||||
// rows it never saw — which is why beginSelectionAt zeroes the counter.
|
||||
repeat(SCREEN_ROWS) { emulator.feed("filler\r\n") }
|
||||
longPress(column = 1, row = 0)
|
||||
val selectedBefore = host.selectedText()
|
||||
|
||||
repeat(SCROLL_ROWS) { emulator.feed("more\r\n") }
|
||||
host.requestScreenUpdate()
|
||||
|
||||
assertEquals(selectedBefore, host.selectedText(), "the same TEXT, at a new row index")
|
||||
assertEquals(
|
||||
-SCROLL_ROWS,
|
||||
host.stockView.topRow,
|
||||
"stock onScreenUpdated jumps to the newest row unless we pin it back",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `output with no selection still jumps to the newest row`() {
|
||||
val emulator = bind()
|
||||
repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") }
|
||||
host.stockView.topRow = -5
|
||||
|
||||
host.requestScreenUpdate()
|
||||
|
||||
assertEquals(0, host.stockView.topRow, "the pin is for selections only")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selection scrolled out of the transcript is dropped`() {
|
||||
val emulator = StockTerminalViewFixture.emulator(transcriptRows = SMALL_TRANSCRIPT)
|
||||
host.bindEmulator(emulator)
|
||||
emulator.feed("target\r\n")
|
||||
longPress(column = 1, row = 0)
|
||||
|
||||
repeat(SMALL_TRANSCRIPT + SCREEN_ROWS + 1) { emulator.feed("flood\r\n") }
|
||||
host.requestScreenUpdate()
|
||||
|
||||
assertFalse(host.isSelectingText)
|
||||
}
|
||||
|
||||
// ── Highlight geometry ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the highlight covers the selected cells`() {
|
||||
bind("cat /etc/hosts")
|
||||
longPress(column = 1, row = 0)
|
||||
|
||||
val rects = host.highlightRects()
|
||||
|
||||
assertEquals(1, rects.size)
|
||||
assertEquals(0f, rects[0].left)
|
||||
assertEquals(3 * CELL_WIDTH_PX, rects[0].right, "'cat' is columns 0-2")
|
||||
assertEquals(0f, rects[0].top)
|
||||
assertEquals(CELL_HEIGHT_PX.toFloat(), rects[0].bottom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `there is no highlight without a selection`() {
|
||||
bind("cat /etc/hosts")
|
||||
|
||||
assertTrue(host.highlightRects().isEmpty())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SCREEN_ROWS: Int = 24
|
||||
const val SCROLLED_OFF_LINES: Int = 40
|
||||
const val SCROLL_ROWS: Int = 3
|
||||
const val SMALL_TRANSCRIPT: Int = 100
|
||||
const val START_Y: Float = 400f
|
||||
const val CLAIM_TRAVEL_PX: Float = 100f
|
||||
const val VIEW_WIDTH_PX: Int = 720
|
||||
const val VIEW_HEIGHT_PX: Int = 1280
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ internal object StockTerminalViewFixture {
|
||||
internal class RecordingInputSink : TerminalInputSink {
|
||||
val sent = mutableListOf<String>()
|
||||
var taps = 0
|
||||
val longPresses = mutableListOf<Pair<Float, Float>>()
|
||||
val sizes = mutableListOf<Pair<Int, Int>>()
|
||||
|
||||
override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false
|
||||
@@ -132,6 +133,10 @@ internal class RecordingInputSink : TerminalInputSink {
|
||||
taps++
|
||||
}
|
||||
|
||||
override fun onLongPressAt(xPx: Float, yPx: Float) {
|
||||
longPresses += xPx to yPx
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) {
|
||||
sizes += widthPx to heightPx
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
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.terminalview.StockTerminalViewFixture.feed
|
||||
|
||||
/**
|
||||
* Text extraction, against the **real** Termux `TerminalBuffer` driven by real bytes.
|
||||
*
|
||||
* This is the part of the stock selection stack that is session-free and therefore reusable:
|
||||
* `TerminalBuffer.getSelectedText` reads rows and nothing else — no `TerminalSession`, no view. Re-deriving
|
||||
* its wide-cell walk (`TerminalRow.findStartOfColumn` + `WcWidth`), its per-line trailing-blank trim and its
|
||||
* soft-wrap joining in Kotlin would be a second implementation of the same subtle logic, guaranteed to
|
||||
* drift from what the renderer draws. So the extraction is delegated and PINNED here instead: these tests
|
||||
* assert the behaviour this module depends on, so a Termux bump that changes it fails loudly.
|
||||
*/
|
||||
class TerminalSelectionBufferTest {
|
||||
|
||||
private fun bufferOf(emulator: TerminalEmulator?): TerminalSelectionBuffer =
|
||||
TerminalEmulatorSelectionBuffer { emulator }
|
||||
|
||||
private fun emulatorWith(vararg output: String): TerminalEmulator =
|
||||
StockTerminalViewFixture.emulator().also { emulator -> output.forEach { emulator.feed(it) } }
|
||||
|
||||
private fun TerminalSelectionBuffer.text(fromColumn: Int, fromRow: Int, toColumn: Int, toRow: Int) =
|
||||
textIn(TerminalSelectionSpan.spanning(TerminalCell(fromColumn, fromRow), TerminalCell(toColumn, toRow)))
|
||||
|
||||
// ── Bounds ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `bounds come from the live emulator and its transcript`() {
|
||||
val emulator = emulatorWith()
|
||||
repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") }
|
||||
|
||||
val bounds = bufferOf(emulator).bounds()
|
||||
|
||||
assertEquals(TerminalGridBounds(COLUMNS, SCREEN_ROWS, emulator.screen.activeTranscriptRows), bounds)
|
||||
assertTrue(bounds!!.transcriptRows > 0, "40 lines on a 24-row screen must leave a transcript")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `there are no bounds and no text before an emulator is bound`() {
|
||||
val buffer = bufferOf(null)
|
||||
|
||||
assertNull(buffer.bounds())
|
||||
assertEquals("", buffer.text(0, 0, 5, 0))
|
||||
}
|
||||
|
||||
// ── Trailing blanks, wide cells, line joining ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a row's trailing blanks are trimmed rather than copied as spaces`() {
|
||||
val buffer = bufferOf(emulatorWith("hi"))
|
||||
|
||||
// Selecting the whole row must not yield "hi" followed by 78 spaces.
|
||||
assertEquals("hi", buffer.text(0, 0, COLUMNS - 1, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wide CJK cell is copied whole from either of its two columns`() {
|
||||
// 你 occupies columns 0-1 and 好 columns 2-3; "world" then starts at column 4.
|
||||
val buffer = bufferOf(emulatorWith("你好world"))
|
||||
|
||||
assertEquals("你", buffer.text(0, 0, 1, 0))
|
||||
assertEquals("你", buffer.text(1, 0, 1, 0), "the right half of a wide cell is still that cell")
|
||||
assertEquals("你好", buffer.text(0, 0, 3, 0))
|
||||
assertEquals("好world", buffer.text(2, 0, 8, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a soft-wrapped line is joined, so a long command copies as one line`() {
|
||||
val buffer = bufferOf(emulatorWith("a".repeat(WRAPPED_LINE_LENGTH)))
|
||||
|
||||
val copied = buffer.text(0, 0, WRAPPED_LINE_LENGTH - COLUMNS - 1, 1)
|
||||
|
||||
assertEquals("a".repeat(WRAPPED_LINE_LENGTH), copied)
|
||||
assertFalse(copied.contains('\n'), "a wrap point is not a line break")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real line break is preserved`() {
|
||||
val buffer = bufferOf(emulatorWith("one\r\ntwo\r\n"))
|
||||
|
||||
assertEquals("one\ntwo", buffer.text(0, 0, 2, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-row selection takes the first row to the edge and the last to the focus`() {
|
||||
val buffer = bufferOf(emulatorWith("alpha\r\nbravo\r\ncharlie\r\n"))
|
||||
|
||||
assertEquals("pha\nbravo\ncha", buffer.text(2, 0, 2, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrollback rows are readable at negative row indices`() {
|
||||
val emulator = emulatorWith()
|
||||
repeat(SCROLLED_OFF_LINES) { index -> emulator.feed("line-$index\r\n") }
|
||||
|
||||
// 40 lines plus the trailing empty one scroll 17 rows off a 24-row screen, so screen row 0 holds
|
||||
// line-17 and transcript row -1 holds the line just above it.
|
||||
assertEquals("line-${SCROLLED_OFF_LINES - SCREEN_ROWS}", bufferOf(emulator).text(0, -1, COLUMNS - 1, -1))
|
||||
}
|
||||
|
||||
// ── The clamp that keeps the two unguarded stock failures unreachable ────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `an off-grid span is clamped instead of throwing out of the stock row walk`() {
|
||||
val buffer = bufferOf(emulatorWith("hello"))
|
||||
|
||||
// Row -9999: TerminalBuffer.externalToInternalRow throws IllegalArgumentException outside
|
||||
// -activeTranscriptRows..screenRows. Column 9999: TerminalRow.findStartOfColumn walks mText with
|
||||
// no bound check for column > mColumns. Both are reachable from a touch outside the grid.
|
||||
// The clamp turns the span into "the whole measured grid", i.e. the one written row plus the blank
|
||||
// rows below it — the point being that it RETURNS rather than throwing.
|
||||
assertEquals("hello", buffer.text(-50, -9999, 9999, 9999).trim())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmeasured emulator yields no text`() {
|
||||
// A 0-column emulator cannot be constructed by Termux, so the unmeasured case is "no emulator";
|
||||
// proving the clamp refuses rather than divides is still worth pinning.
|
||||
assertNull(TerminalGridBounds(columns = 0, screenRows = 0, transcriptRows = 0).takeIf { it.isMeasured })
|
||||
}
|
||||
|
||||
// ── Long-press word snap ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a long press snaps to the run of non-blank cells around it`() {
|
||||
val buffer = bufferOf(emulatorWith("cat /etc/hosts"))
|
||||
|
||||
val selection = TerminalSelectionWords.runAt(TerminalCell(column = 6, row = 0), buffer)
|
||||
|
||||
assertEquals(TerminalCell(4, 0), selection?.span?.start)
|
||||
assertEquals(TerminalCell(13, 0), selection?.span?.end)
|
||||
assertEquals("/etc/hosts", buffer.textIn(selection!!.span))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the snap stops at the start of the row`() {
|
||||
val buffer = bufferOf(emulatorWith("cat /etc/hosts"))
|
||||
|
||||
val selection = TerminalSelectionWords.runAt(TerminalCell(column = 1, row = 0), buffer)
|
||||
|
||||
assertEquals("cat", buffer.textIn(selection!!.span))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the snap spans wide cells`() {
|
||||
val buffer = bufferOf(emulatorWith("你好 world"))
|
||||
|
||||
// Column 1 is the right half of 你; the run is 你好, i.e. columns 0-3.
|
||||
val selection = TerminalSelectionWords.runAt(TerminalCell(column = 1, row = 0), buffer)
|
||||
|
||||
assertEquals("你好", buffer.textIn(selection!!.span))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long press on blank space selects just that cell`() {
|
||||
val buffer = bufferOf(emulatorWith("cat /etc/hosts"))
|
||||
|
||||
val selection = TerminalSelectionWords.runAt(TerminalCell(column = 3, row = 0), buffer)
|
||||
|
||||
assertTrue(selection!!.span.isSingleCell, "a blank cell must not swallow the words either side")
|
||||
assertEquals(TerminalCell(3, 0), selection.span.start)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `there is nothing to snap to before an emulator is bound`() {
|
||||
assertNull(TerminalSelectionWords.runAt(TerminalCell(0, 0), bufferOf(null)))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val COLUMNS: Int = 80
|
||||
const val SCREEN_ROWS: Int = 24
|
||||
|
||||
/** Enough output to push rows off the top so there is a transcript to read. */
|
||||
const val SCROLLED_OFF_LINES: Int = 40
|
||||
|
||||
/** Longer than one row, so the emulator sets the row's wrap flag. */
|
||||
const val WRAPPED_LINE_LENGTH: Int = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* The selection state machine, with the buffer and the clipboard faked so the DECISIONS are asserted
|
||||
* rather than the framework: when a selection exists, what a drag does to it, what a content scroll does
|
||||
* to it, and — the §8 rule — that terminal text reaches the clipboard on an explicit copy and at no other
|
||||
* moment.
|
||||
*/
|
||||
class TerminalSelectionControllerTest {
|
||||
|
||||
/** A one-row grid of text, enough to drive the state machine; real extraction is pinned elsewhere. */
|
||||
private class FakeBuffer(
|
||||
private val row: String = "hello world",
|
||||
private val bounds: TerminalGridBounds? = TerminalGridBounds(COLUMNS, SCREEN_ROWS, TRANSCRIPT_ROWS),
|
||||
) : TerminalSelectionBuffer {
|
||||
var reads = 0
|
||||
|
||||
override fun bounds(): TerminalGridBounds? = bounds
|
||||
|
||||
override fun textIn(span: TerminalSelectionSpan): String {
|
||||
reads++
|
||||
val bounds = bounds ?: return ""
|
||||
if (span.start.row != span.end.row) return row
|
||||
val columns = span.columnsIn(span.start.row, bounds.lastColumn) ?: return ""
|
||||
return row.substring(
|
||||
columns.first.coerceIn(0, row.length),
|
||||
(columns.last + 1).coerceIn(0, row.length),
|
||||
).trimEnd()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeClipboard(private val accepts: Boolean = true) : TerminalClipboard {
|
||||
val puts = mutableListOf<String>()
|
||||
|
||||
override fun put(text: String): Boolean {
|
||||
puts += text
|
||||
return accepts
|
||||
}
|
||||
}
|
||||
|
||||
private class Recorder {
|
||||
val changes = mutableListOf<Boolean>()
|
||||
}
|
||||
|
||||
private fun controller(
|
||||
buffer: TerminalSelectionBuffer = FakeBuffer(),
|
||||
clipboard: TerminalClipboard = FakeClipboard(),
|
||||
recorder: Recorder = Recorder(),
|
||||
) = TerminalSelectionController(buffer, clipboard) { recorder.changes += it }
|
||||
|
||||
// ── Begin / extend ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a long press starts a selection snapped to the word under it`() {
|
||||
val recorder = Recorder()
|
||||
val controller = controller(recorder = recorder)
|
||||
|
||||
assertTrue(controller.beginAt(TerminalCell(column = 2, row = 0)))
|
||||
|
||||
assertTrue(controller.isActive)
|
||||
assertEquals(TerminalCell(0, 0), controller.selection?.span?.start)
|
||||
assertEquals(TerminalCell(4, 0), controller.selection?.span?.end, "'hello' is columns 0-4")
|
||||
assertEquals(listOf(true), recorder.changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drag moves the focus and leaves the anchor where the press landed`() {
|
||||
val controller = controller()
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
controller.extendTo(TerminalCell(column = 8, row = 0))
|
||||
|
||||
assertEquals(TerminalCell(0, 0), controller.selection?.span?.start, "the anchor survives the drag")
|
||||
assertEquals(TerminalCell(8, 0), controller.selection?.span?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dragging back past the anchor flips the span and keeps the snapped word`() {
|
||||
val controller = controller()
|
||||
controller.beginAt(TerminalCell(8, 0)) // snaps to "world", columns 6-10
|
||||
controller.extendTo(TerminalCell(column = 1, row = 0))
|
||||
|
||||
val span = controller.selection?.span
|
||||
|
||||
assertEquals(TerminalCell(1, 0), span?.start)
|
||||
assertEquals(
|
||||
TerminalCell(10, 0),
|
||||
span?.end,
|
||||
"the whole anchored word stays in — a single-cell anchor would clip 'world' to its 'w'",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drag off the grid is clamped, not stored raw`() {
|
||||
val controller = controller()
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
controller.extendTo(TerminalCell(column = 9_999, row = 9_999))
|
||||
|
||||
assertEquals(TerminalCell(COLUMNS - 1, SCREEN_ROWS - 1), controller.selection?.span?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing can be selected before the grid is measured`() {
|
||||
val controller = controller(buffer = FakeBuffer(bounds = null))
|
||||
|
||||
assertFalse(controller.beginAt(TerminalCell(2, 0)))
|
||||
assertFalse(controller.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extending without a selection does nothing`() {
|
||||
val controller = controller()
|
||||
|
||||
controller.extendTo(TerminalCell(4, 0))
|
||||
|
||||
assertFalse(controller.isActive)
|
||||
}
|
||||
|
||||
// ── Copy (the only path to the clipboard) ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `selecting text puts nothing on the clipboard`() {
|
||||
// Plan §8: terminal content is untrusted and may be a secret. It reaches the clipboard ONLY on an
|
||||
// explicit user copy — never on selection, never on drag, never automatically.
|
||||
val clipboard = FakeClipboard()
|
||||
val controller = controller(clipboard = clipboard)
|
||||
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
controller.extendTo(TerminalCell(8, 0))
|
||||
controller.clear()
|
||||
|
||||
assertTrue(clipboard.puts.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit copy puts exactly the selected text on the clipboard once`() {
|
||||
val clipboard = FakeClipboard()
|
||||
val controller = controller(clipboard = clipboard)
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
assertEquals(TerminalCopyResult.COPIED, controller.copy())
|
||||
|
||||
assertEquals(listOf("hello"), clipboard.puts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying with no selection is refused and touches nothing`() {
|
||||
val clipboard = FakeClipboard()
|
||||
val controller = controller(clipboard = clipboard)
|
||||
|
||||
assertEquals(TerminalCopyResult.NOTHING_SELECTED, controller.copy())
|
||||
assertTrue(clipboard.puts.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank selection is never written to the clipboard`() {
|
||||
val clipboard = FakeClipboard()
|
||||
val controller = controller(buffer = FakeBuffer(row = " "), clipboard = clipboard)
|
||||
controller.beginAt(TerminalCell(1, 0))
|
||||
|
||||
assertEquals(TerminalCopyResult.NOTHING_TO_COPY, controller.copy())
|
||||
assertTrue(clipboard.puts.isEmpty(), "clearing the user's clipboard with an empty string is data loss")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused clipboard write is reported instead of being reported as success`() {
|
||||
val controller = controller(clipboard = FakeClipboard(accepts = false))
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
assertEquals(TerminalCopyResult.CLIPBOARD_UNAVAILABLE, controller.copy())
|
||||
}
|
||||
|
||||
// ── Clear ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `clearing drops the selection and reports the change once`() {
|
||||
val recorder = Recorder()
|
||||
val controller = controller(recorder = recorder)
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
controller.clear()
|
||||
|
||||
assertFalse(controller.isActive)
|
||||
assertNull(controller.selection)
|
||||
assertEquals(listOf(true, false), recorder.changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing an empty selection reports nothing`() {
|
||||
// Load-bearing: the copy affordance is dismissed by clear(), and its own dismissal calls clear()
|
||||
// back. Without this guard that is an infinite loop.
|
||||
val recorder = Recorder()
|
||||
val controller = controller(recorder = recorder)
|
||||
|
||||
controller.clear()
|
||||
controller.clear()
|
||||
|
||||
assertTrue(recorder.changes.isEmpty())
|
||||
}
|
||||
|
||||
// ── Content scrolling under a live selection ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `new output shifts the selection so it keeps pointing at the same text`() {
|
||||
val controller = controller()
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
controller.onContentScrolled(scrolledRows = 3)
|
||||
|
||||
assertEquals(TerminalCell(0, -3), controller.selection?.span?.start)
|
||||
assertEquals(TerminalCell(4, -3), controller.selection?.span?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selection scrolled out of the retained transcript is dropped, not left pointing at nothing`() {
|
||||
val recorder = Recorder()
|
||||
val controller = controller(recorder = recorder)
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
controller.onContentScrolled(scrolledRows = TRANSCRIPT_ROWS + 1)
|
||||
|
||||
assertFalse(controller.isActive)
|
||||
assertEquals(listOf(true, false), recorder.changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a scroll of zero rows is not a change`() {
|
||||
val controller = controller()
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
val before = controller.selection
|
||||
|
||||
controller.onContentScrolled(scrolledRows = 0)
|
||||
|
||||
assertEquals(before, controller.selection)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val COLUMNS: Int = 80
|
||||
const val SCREEN_ROWS: Int = 24
|
||||
const val TRANSCRIPT_ROWS: Int = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Pixels ↔ cells, and the highlight bands.
|
||||
*
|
||||
* The mapping is deliberately the INVERSE of the stock accessors this module already reads
|
||||
* (`TerminalView.getPointX(col)` = `round(col * fontWidth)`, `getPointY(row)` = `(row - topRow) *
|
||||
* lineSpacing`), so a tapped cell and its highlight band are the same cell. Stock's own
|
||||
* `getColumnAndRow` biases the row by `mFontLineSpacingAndAscent` instead, which puts its hit test
|
||||
* ~20% of a row above the glyph it is drawing — not something to reproduce.
|
||||
*/
|
||||
class TerminalSelectionGeometryTest {
|
||||
|
||||
@Test
|
||||
fun `a touch resolves to the cell under it`() {
|
||||
val cell = TerminalSelectionGeometry.cellAt(
|
||||
xPx = 2.5f * CELL_WIDTH_PX,
|
||||
yPx = 3.5f * CELL_HEIGHT_PX,
|
||||
metrics = METRICS,
|
||||
topRow = 0,
|
||||
)
|
||||
|
||||
assertEquals(TerminalCell(column = 2, row = 3), cell)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a touch while scrolled back resolves into the transcript`() {
|
||||
val cell = TerminalSelectionGeometry.cellAt(
|
||||
xPx = 0f,
|
||||
yPx = 0.5f * CELL_HEIGHT_PX,
|
||||
metrics = METRICS,
|
||||
topRow = -7,
|
||||
)
|
||||
|
||||
assertEquals(TerminalCell(column = 0, row = -7), cell, "row 0 of the viewport is topRow")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmeasured cell box resolves to no cell instead of dividing by zero`() {
|
||||
assertNull(
|
||||
TerminalSelectionGeometry.cellAt(10f, 10f, TerminalCellMetrics(0f, 0), topRow = 0),
|
||||
)
|
||||
assertNull(
|
||||
TerminalSelectionGeometry.cellAt(10f, 10f, TerminalCellMetrics(CELL_WIDTH_PX, 0), topRow = 0),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-row selection is one band, sized to the selected columns`() {
|
||||
val span = TerminalSelectionSpan.spanning(TerminalCell(2, 1), TerminalCell(4, 1))
|
||||
|
||||
val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
TerminalSelectionRect(
|
||||
left = 2 * CELL_WIDTH_PX,
|
||||
top = 1f * CELL_HEIGHT_PX,
|
||||
right = 5 * CELL_WIDTH_PX,
|
||||
bottom = 2f * CELL_HEIGHT_PX,
|
||||
),
|
||||
),
|
||||
rects,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-row selection bands the first row to the right edge and the last from the left`() {
|
||||
val span = TerminalSelectionSpan.spanning(TerminalCell(3, 0), TerminalCell(1, 2))
|
||||
|
||||
val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0)
|
||||
|
||||
assertEquals(3, rects.size)
|
||||
assertEquals(3 * CELL_WIDTH_PX, rects[0].left)
|
||||
assertEquals(COLUMNS * CELL_WIDTH_PX, rects[0].right, "the first row runs to the grid edge")
|
||||
assertEquals(0f, rects[1].left)
|
||||
assertEquals(COLUMNS * CELL_WIDTH_PX, rects[1].right, "a middle row is fully highlighted")
|
||||
assertEquals(0f, rects[2].left)
|
||||
assertEquals(2 * CELL_WIDTH_PX, rects[2].right)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rows scrolled out of the viewport produce no bands`() {
|
||||
// A selection made in scrollback, then scrolled away: nothing to paint, and nothing that would
|
||||
// paint at a negative y over the live screen.
|
||||
val span = TerminalSelectionSpan.spanning(TerminalCell(0, -40), TerminalCell(5, -38))
|
||||
|
||||
val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0)
|
||||
|
||||
assertTrue(rects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selection straddling the top of the viewport is clipped to the visible rows`() {
|
||||
val span = TerminalSelectionSpan.spanning(TerminalCell(0, -3), TerminalCell(5, 1))
|
||||
|
||||
val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = -1)
|
||||
|
||||
assertEquals(3, rects.size, "rows -1, 0 and 1 are visible; -3 and -2 are above the viewport")
|
||||
assertEquals(0f, rects[0].top, "the first visible band starts at the top of the view")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CELL_WIDTH_PX: Float = 12f
|
||||
const val CELL_HEIGHT_PX: Int = 24
|
||||
const val COLUMNS: Int = 80
|
||||
const val SCREEN_ROWS: Int = 24
|
||||
|
||||
val METRICS = TerminalCellMetrics(CELL_WIDTH_PX, CELL_HEIGHT_PX)
|
||||
val BOUNDS = TerminalGridBounds(COLUMNS, SCREEN_ROWS, transcriptRows = 100)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The selection model on its own: cell coordinates, normalisation, clamping and the per-row column
|
||||
* ranges. No view, no emulator, no android — this is the part that has to be right before any pixel or
|
||||
* any clipboard write can be.
|
||||
*/
|
||||
class TerminalSelectionModelTest {
|
||||
|
||||
// ── Normalisation ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a backwards drag is the same span as the forwards drag`() {
|
||||
val forwards = selection(cell(2, 1), cell(7, 4))
|
||||
val backwards = selection(cell(7, 4), cell(2, 1))
|
||||
|
||||
assertEquals(forwards.span, backwards.span)
|
||||
assertEquals(cell(2, 1), forwards.span.start)
|
||||
assertEquals(cell(7, 4), forwards.span.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a backwards drag inside one row swaps the columns only`() {
|
||||
val span = selection(cell(9, 3), cell(4, 3)).span
|
||||
|
||||
assertEquals(cell(4, 3), span.start)
|
||||
assertEquals(cell(9, 3), span.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ordering is row-major, so a lower row wins even with a bigger column`() {
|
||||
// Dragging up-and-right must not be read as a forwards selection: the row decides.
|
||||
val span = selection(cell(0, 5), cell(70, 2)).span
|
||||
|
||||
assertEquals(cell(70, 2), span.start)
|
||||
assertEquals(cell(0, 5), span.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-cell selection is a one-cell span`() {
|
||||
val span = selection(cell(5, 5), cell(5, 5)).span
|
||||
|
||||
assertEquals(span.start, span.end)
|
||||
assertTrue(span.isSingleCell)
|
||||
}
|
||||
|
||||
// ── Per-row column ranges (what both the highlight and the extraction use) ───────────────────────
|
||||
|
||||
@Test
|
||||
fun `the first row runs to the last column and the last row starts at column zero`() {
|
||||
val span = selection(cell(3, 0), cell(6, 2)).span
|
||||
|
||||
assertEquals(3..LAST_COLUMN, span.columnsIn(0, LAST_COLUMN))
|
||||
assertEquals(0..LAST_COLUMN, span.columnsIn(1, LAST_COLUMN), "a middle row is fully selected")
|
||||
assertEquals(0..6, span.columnsIn(2, LAST_COLUMN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single-row span uses both of its columns`() {
|
||||
val span = selection(cell(3, 4), cell(6, 4)).span
|
||||
|
||||
assertEquals(3..6, span.columnsIn(4, LAST_COLUMN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rows outside the span have no column range`() {
|
||||
val span = selection(cell(3, 2), cell(6, 4)).span
|
||||
|
||||
assertNull(span.columnsIn(1, LAST_COLUMN))
|
||||
assertNull(span.columnsIn(5, LAST_COLUMN))
|
||||
}
|
||||
|
||||
// ── Clamping (the reason an off-grid touch cannot crash the buffer read) ─────────────────────────
|
||||
|
||||
@Test
|
||||
fun `an off-grid touch clamps into the grid`() {
|
||||
// TerminalRow.findStartOfColumn walks mText with no bound check for column > mColumns, and
|
||||
// TerminalBuffer.externalToInternalRow THROWS for a row outside the transcript. Clamping here is
|
||||
// what keeps both unreachable.
|
||||
val span = selection(cell(-4, -900), cell(500, 99)).span.clampedTo(BOUNDS)
|
||||
|
||||
assertEquals(cell(0, -TRANSCRIPT_ROWS), span?.start)
|
||||
assertEquals(cell(LAST_COLUMN, SCREEN_ROWS - 1), span?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an in-grid span is left alone by the clamp`() {
|
||||
val span = selection(cell(1, -2), cell(9, 3)).span
|
||||
|
||||
assertEquals(span, span.clampedTo(BOUNDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmeasured grid clamps to nothing rather than to cell zero`() {
|
||||
val span = selection(cell(1, 1), cell(2, 2)).span
|
||||
|
||||
assertNull(span.clampedTo(TerminalGridBounds(columns = 0, screenRows = 0, transcriptRows = 0)))
|
||||
}
|
||||
|
||||
// ── The anchor is a SPAN, because a long press snaps to a word ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a word-anchored selection starts as exactly that word`() {
|
||||
val selection = TerminalSelection.anchoredOn(cell(4, 1), cell(13, 1))
|
||||
|
||||
assertEquals(cell(4, 1), selection.span.start)
|
||||
assertEquals(cell(13, 1), selection.span.end)
|
||||
assertEquals(cell(13, 1), selection.focus, "the focus starts at the end the drag continues from")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dragging back past a word-anchored selection keeps the whole word`() {
|
||||
// The editor behaviour: the word you long-pressed stays selected however you drag. Anchoring on a
|
||||
// single cell instead would collapse "charlie" to its first character the moment the finger moved
|
||||
// above it.
|
||||
val dragged = TerminalSelection.anchoredOn(cell(0, 2), cell(6, 2)).withFocus(cell(2, 0))
|
||||
|
||||
assertEquals(cell(2, 0), dragged.span.start)
|
||||
assertEquals(cell(6, 2), dragged.span.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a focus inside the anchor changes nothing`() {
|
||||
val anchored = TerminalSelection.anchoredOn(cell(4, 1), cell(13, 1))
|
||||
|
||||
assertEquals(anchored.span, anchored.withFocus(cell(8, 1)).span)
|
||||
}
|
||||
|
||||
// ── Content scrolling ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `new output shifts the selection up by the scrolled rows`() {
|
||||
// External row indices move when the screen scrolls: the content the user selected is one row
|
||||
// higher after one new line. Without this the highlight (and the copy) would silently drift.
|
||||
val moved = selection(cell(2, 5), cell(4, 6)).movedBy(-3)
|
||||
|
||||
assertEquals(cell(2, 2), moved.span.start)
|
||||
assertEquals(cell(4, 3), moved.span.end)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val COLUMNS: Int = 80
|
||||
const val SCREEN_ROWS: Int = 24
|
||||
const val TRANSCRIPT_ROWS: Int = 100
|
||||
const val LAST_COLUMN: Int = COLUMNS - 1
|
||||
val BOUNDS = TerminalGridBounds(COLUMNS, SCREEN_ROWS, TRANSCRIPT_ROWS)
|
||||
|
||||
fun cell(column: Int, row: Int) = TerminalCell(column, row)
|
||||
|
||||
/** A press at [from] with the finger now at [to] — the one-cell-anchor case. */
|
||||
fun selection(from: TerminalCell, to: TerminalCell) = TerminalSelection.at(from).withFocus(to)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -26,6 +29,7 @@ class WebTermTerminalViewClientTest {
|
||||
var taps = 0
|
||||
var sizes = mutableListOf<Pair<Int, Int>>()
|
||||
var keysOfferedToApp = 0
|
||||
val longPresses = mutableListOf<Pair<Float, Float>>()
|
||||
|
||||
fun setCursorApplicationMode(on: Boolean) {
|
||||
modes = TerminalCursorModes(cursorKeysApplication = on, keypadApplication = false)
|
||||
@@ -45,6 +49,10 @@ class WebTermTerminalViewClientTest {
|
||||
taps++
|
||||
}
|
||||
|
||||
override fun onLongPressAt(xPx: Float, yPx: Float) {
|
||||
longPresses += xPx to yPx
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) {
|
||||
sizes += widthPx to heightPx
|
||||
}
|
||||
@@ -136,6 +144,20 @@ class WebTermTerminalViewClientTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long press starts the first-party selection at the pressed pixels`() {
|
||||
// Consuming the event is what keeps the stock mode unreachable; forwarding the coordinates is what
|
||||
// turns the consumed press into the replacement feature instead of a discarded gesture.
|
||||
val sink = FakeSink()
|
||||
val event = mockk<MotionEvent>(relaxed = true)
|
||||
every { event.x } returns PRESS_X_PX
|
||||
every { event.y } returns PRESS_Y_PX
|
||||
|
||||
assertTrue(WebTermTerminalViewClient(sink).onLongPress(event))
|
||||
|
||||
assertEquals(listOf(PRESS_X_PX to PRESS_Y_PX), sink.longPresses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap raises the keyboard and pinch-zoom is declined`() {
|
||||
val sink = FakeSink()
|
||||
@@ -170,4 +192,9 @@ class WebTermTerminalViewClientTest {
|
||||
assertFalse(client.readShiftKey())
|
||||
assertFalse(client.readFnKey())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PRESS_X_PX: Float = 42f
|
||||
const val PRESS_Y_PX: Float = 96f
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user