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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user