App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
371 lines
15 KiB
Swift
371 lines
15 KiB
Swift
import Foundation
|
||
import Observation
|
||
import SessionCore
|
||
import WireProtocol
|
||
|
||
/// Terminal screen state (T-iOS-11, plan §3.5): consumes the engine's
|
||
/// `SessionEvent` stream on the MainActor and turns it into UI state — output
|
||
/// forwarded to SwiftTerm, connection banner, non-retryable failure copy and
|
||
/// the read-only exit state. All outbound traffic (key bar, hardware key
|
||
/// commands, SwiftTerm delegate) funnels through here into ONE ordered queue.
|
||
///
|
||
/// Testability / stream-sharing decision (documented per task brief):
|
||
/// `engine.events` is a single-consumer `AsyncStream`, and GateViewModel
|
||
/// (T-iOS-14) must eventually observe `.gate`/`.digest` from the SAME stream.
|
||
/// So the events stream is injected SEPARATELY from the engine: today callers
|
||
/// pass `engine.events` verbatim (tests do exactly that, over
|
||
/// `TestSupport.FakeTransport`); the T-iOS-15 wiring may pass a fan-out branch
|
||
/// instead without touching this class.
|
||
///
|
||
/// Swift 6 strict concurrency: the class is `@MainActor`, the terminal sink is
|
||
/// `@MainActor`-typed — `feed()` off the main actor cannot compile.
|
||
@MainActor
|
||
@Observable
|
||
final class TerminalViewModel {
|
||
// MARK: - UI state model
|
||
|
||
/// Connection banner state (mirrors the web client's status line:
|
||
/// public/terminal-session.ts `SessionStatus`).
|
||
enum ConnectionBanner: Equatable {
|
||
case none
|
||
case connecting
|
||
/// Retry `attempt` fires after `next` (ReconnectMachine ladder 1s→30s).
|
||
case reconnecting(attempt: Int, next: Duration)
|
||
}
|
||
|
||
/// Which terminal the user is looking at: a live one, a dead-for-good one
|
||
/// (non-retryable failure), or a finished one (read-only).
|
||
enum TerminalPhase: Equatable {
|
||
case live
|
||
/// Non-retryable terminal failure — actionable copy instead of a spinner.
|
||
case failed(message: String)
|
||
/// The shell exited; the terminal stays readable but accepts no input.
|
||
case exited(code: Int, reason: String?)
|
||
}
|
||
|
||
/// Terminal geometry snapshot (Equatable for test assertions; the frozen
|
||
/// engine API takes a tuple, so `asTuple` bridges).
|
||
struct TerminalDims: Equatable, Sendable {
|
||
let cols: Int
|
||
let rows: Int
|
||
var asTuple: (cols: Int, rows: Int) { (cols, rows) }
|
||
}
|
||
|
||
/// Actionable copy for `.failed(.replayTooLarge)` (plan §3.2 / §3.2.1
|
||
/// coupling warning): reconnecting would deterministically fail forever,
|
||
/// so the user must change a knob, not wait.
|
||
static let replayTooLargeMessage =
|
||
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
|
||
|
||
/// Actionable copy for `.failed(.unauthorized)` (C1 over B3, ios-completion
|
||
/// §1.1). The server answers **401 on the WS upgrade for two different
|
||
/// reasons** — the Origin/CSWSH check runs first, the `webterm_auth` cookie
|
||
/// second (src/server.ts:1367-1379) — and the status is identical, so the
|
||
/// client provably cannot tell them apart. Hence the copy names BOTH
|
||
/// remedies instead of guessing one. Retrying is pointless (the engine
|
||
/// already treats this as terminal), so the message asks for a credential
|
||
/// change, not patience.
|
||
static let unauthorizedMessage =
|
||
"主机拒绝了连接(401):访问令牌缺失或不正确,也可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
|
||
+ "请在会话列表的主机菜单里「管理主机与令牌」重新输入访问令牌;"
|
||
+ "若令牌无误,就把主机的 ALLOWED_ORIGINS 设成 App 连接的完整地址后重启 web-terminal。"
|
||
|
||
/// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
|
||
/// Read by the wiring layer for `notifyForegrounded(dims:)` — the frozen
|
||
/// §3.2 signature needs real cols/rows and this is their single source.
|
||
private(set) var lastSentDims: TerminalDims?
|
||
|
||
private(set) var banner: ConnectionBanner = .none
|
||
private(set) var phase: TerminalPhase = .live
|
||
/// Server-adopted session id (ALWAYS the server-issued one — persisting it
|
||
/// per host is the T-iOS-15 wiring's job via `LastSessionStore`).
|
||
private(set) var sessionId: UUID?
|
||
/// Sanitized OSC title (T-iOS-23). Raw `setTerminalTitle` delegate input
|
||
/// is HOST/ATTACKER-CONTROLLED and passes `TitleSanitizer` at THIS
|
||
/// boundary; nil = no title (empty after sanitisation).
|
||
private(set) var terminalTitle: String?
|
||
|
||
/// Read-only = no input reaches the PTY (exit / terminal failure). Resize
|
||
/// is NOT gated here — the engine owns terminal-state dropping.
|
||
var isReadOnly: Bool { phase != .live }
|
||
|
||
/// Single render input for `ReconnectBanner`: terminal phases win over
|
||
/// transient connection states.
|
||
var bannerModel: ReconnectBanner.Model? {
|
||
switch phase {
|
||
case .failed(let message):
|
||
return .failed(message: message)
|
||
case .exited(let code, let reason):
|
||
return .exited(code: code, reason: reason)
|
||
case .live:
|
||
break
|
||
}
|
||
switch banner {
|
||
case .none:
|
||
return nil
|
||
case .connecting:
|
||
return .connecting
|
||
case .reconnecting(let attempt, let next):
|
||
return .reconnecting(attempt: attempt, retryIn: next)
|
||
}
|
||
}
|
||
|
||
// MARK: - Dependencies & plumbing (not observed)
|
||
|
||
@ObservationIgnored private let engine: SessionEngine
|
||
@ObservationIgnored private let events: AsyncStream<SessionEvent>
|
||
@ObservationIgnored private var consumeTask: Task<Void, Never>?
|
||
/// Where output bytes go (SwiftTerm's `feed(text:)`). `@MainActor`-typed:
|
||
/// feeding off the main actor is a compile error.
|
||
@ObservationIgnored private var terminalSink: (@MainActor (String) -> Void)?
|
||
/// Output that arrived before the SwiftTerm view existed; flushed in order
|
||
/// the moment the sink attaches (replay must never be dropped).
|
||
@ObservationIgnored private var pendingOutput: [String] = []
|
||
/// Ordered outbound queue: UIKit callbacks are synchronous, `engine.send`
|
||
/// is async — one pump task preserves submission order (two quick key taps
|
||
/// must never race each other onto the wire).
|
||
@ObservationIgnored private var sendQueue: [ClientMessage] = []
|
||
@ObservationIgnored private var isPumping = false
|
||
|
||
// MARK: - Test-visible diagnostics & deterministic barriers (internal)
|
||
|
||
/// Events applied so far — `waitUntilProcessed` barrier counter.
|
||
@ObservationIgnored private(set) var processedEventCount = 0
|
||
/// Sends handed to the engine so far — `waitUntilForwarded` barrier counter.
|
||
@ObservationIgnored private(set) var forwardedSendCount = 0
|
||
/// Input dropped because the terminal is read-only (exit/failed).
|
||
@ObservationIgnored private(set) var droppedReadOnlyInputCount = 0
|
||
/// Test tap, called after each event is applied (state already coherent).
|
||
@ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?
|
||
|
||
// MARK: - OSC title surface (T-iOS-23; wired by TerminalSessionController)
|
||
|
||
/// List-side registry hook: fires with the ADOPTED sessionId and the
|
||
/// SANITIZED title ("" = title cleared — the registry drops the entry).
|
||
/// Titles arriving before adoption are held and forwarded once on
|
||
/// `.adopted` (defensive — `attached` always precedes output on the wire).
|
||
@ObservationIgnored var onTitleChanged: (@MainActor (UUID, String) -> Void)?
|
||
/// Latest sanitized title not yet delivered to `onTitleChanged` because
|
||
/// no sessionId was known at the time. nil = nothing held.
|
||
@ObservationIgnored private var heldTitleForward: String?
|
||
|
||
private struct CountWaiter {
|
||
let target: Int
|
||
let continuation: CheckedContinuation<Void, Never>
|
||
}
|
||
|
||
@ObservationIgnored private var eventWaiters: [CountWaiter] = []
|
||
@ObservationIgnored private var sendWaiters: [CountWaiter] = []
|
||
|
||
// MARK: - Lifecycle
|
||
|
||
/// - Parameters:
|
||
/// - engine: send-side dependency (`send` only — `open`/`close` belong to
|
||
/// the T-iOS-15 wiring that constructs the engine).
|
||
/// - events: the event stream to consume; pass `engine.events` unless a
|
||
/// fan-out branch is needed (see type doc).
|
||
init(engine: SessionEngine, events: AsyncStream<SessionEvent>) {
|
||
self.engine = engine
|
||
self.events = events
|
||
}
|
||
|
||
/// Begin consuming events. Idempotent — a second call is a no-op (the
|
||
/// stream has exactly one consumer).
|
||
func start() {
|
||
guard consumeTask == nil else { return }
|
||
consumeTask = Task { [weak self] in
|
||
guard let events = self?.events else { return }
|
||
for await event in events {
|
||
guard let self else { return }
|
||
self.apply(event)
|
||
}
|
||
self?.releaseAllWaiters() // stream over — never leave a test hanging
|
||
}
|
||
}
|
||
|
||
/// Stop consuming (screen torn down). Does NOT close the engine — detach
|
||
/// vs. keep-running is the T-iOS-15 lifecycle owner's call.
|
||
func stop() {
|
||
consumeTask?.cancel()
|
||
consumeTask = nil
|
||
releaseAllWaiters()
|
||
}
|
||
|
||
// MARK: - Terminal output sink
|
||
|
||
/// Attach the SwiftTerm feed target. Buffered output (anything that
|
||
/// arrived before the view existed) flushes immediately, in order.
|
||
func attachTerminalSink(_ sink: @escaping @MainActor (String) -> Void) {
|
||
terminalSink = sink
|
||
let buffered = pendingOutput
|
||
pendingOutput = []
|
||
for chunk in buffered {
|
||
sink(chunk)
|
||
}
|
||
}
|
||
|
||
// MARK: - Outbound (KeyBar / UIKeyCommand / SwiftTerm delegate)
|
||
|
||
/// Key-bar or hardware key press: bytes resolved through `KeyByteMap`
|
||
/// (the single source of truth — plan §7 T-iOS-11).
|
||
func send(key: KeyByteMap.Key) {
|
||
sendInput(KeyByteMap.bytes(for: key))
|
||
}
|
||
|
||
/// Raw input bytes, verbatim (invariant #9 — no content filtering).
|
||
/// Dropped (and counted) while the terminal is read-only.
|
||
func sendInput(_ data: String) {
|
||
guard !isReadOnly else {
|
||
droppedReadOnlyInputCount += 1
|
||
return
|
||
}
|
||
enqueueSend(.input(data: data))
|
||
}
|
||
|
||
/// SwiftTerm reported an OSC 0/2 title (`setTerminalTitle` delegate).
|
||
/// Sanitize FIRST — the raw string is untrusted terminal output — then
|
||
/// surface locally and forward to the list registry (T-iOS-23).
|
||
func setTerminalTitle(_ raw: String) {
|
||
let sanitized = TitleSanitizer.sanitize(raw)
|
||
terminalTitle = sanitized.isEmpty ? nil : sanitized
|
||
guard let sessionId else {
|
||
heldTitleForward = sanitized
|
||
return
|
||
}
|
||
onTitleChanged?(sessionId, sanitized)
|
||
}
|
||
|
||
/// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded —
|
||
/// the engine validates bounds and owns terminal-state dropping. Valid
|
||
/// dims are remembered in `lastSentDims` so the T-iOS-15 wiring can feed
|
||
/// `engine.notifyForegrounded(dims:)` on scenePhase reactivation (closes
|
||
/// the W4 documented deviation; invalid dims never overwrite a good pair).
|
||
func sendResize(cols: Int, rows: Int) {
|
||
if Validation.isValidResize(cols: cols, rows: rows) {
|
||
lastSentDims = TerminalDims(cols: cols, rows: rows)
|
||
}
|
||
enqueueSend(.resize(cols: cols, rows: rows))
|
||
}
|
||
|
||
// MARK: - Event application (single consumer, MainActor)
|
||
|
||
private func apply(_ event: SessionEvent) {
|
||
switch event {
|
||
case .connection(let state):
|
||
applyConnection(state)
|
||
case .adopted(let id):
|
||
sessionId = id // ALWAYS adopt the server-issued id
|
||
if let held = heldTitleForward {
|
||
heldTitleForward = nil
|
||
onTitleChanged?(id, held)
|
||
}
|
||
case .output(let data):
|
||
deliverOutput(data)
|
||
case .exited(let code, let reason):
|
||
phase = .exited(code: code, reason: reason)
|
||
case .gate, .telemetry, .digest:
|
||
break // GateViewModel's domain (T-iOS-14; wired in T-iOS-15)
|
||
}
|
||
processedEventCount += 1
|
||
onEventApplied?(event)
|
||
resumeEventWaiters()
|
||
}
|
||
|
||
private func applyConnection(_ state: ConnectionState) {
|
||
switch state {
|
||
case .connecting:
|
||
banner = .connecting
|
||
case .connected:
|
||
banner = .none
|
||
case .reconnecting(let attempt, let next):
|
||
banner = .reconnecting(attempt: attempt, next: next)
|
||
case .closed:
|
||
banner = .none // deliberate end; exit/failure phase (if any) stays
|
||
case .failed(.replayTooLarge):
|
||
banner = .none
|
||
phase = .failed(message: Self.replayTooLargeMessage)
|
||
case .failed(.unauthorized):
|
||
// Same shape as replayTooLarge: a terminal state, so the spinner
|
||
// must go and the terminal becomes read-only with actionable copy.
|
||
banner = .none
|
||
phase = .failed(message: Self.unauthorizedMessage)
|
||
}
|
||
}
|
||
|
||
private func deliverOutput(_ data: String) {
|
||
guard let terminalSink else {
|
||
pendingOutput = pendingOutput + [data]
|
||
return
|
||
}
|
||
terminalSink(data)
|
||
}
|
||
|
||
// MARK: - Ordered send pump
|
||
|
||
private func enqueueSend(_ message: ClientMessage) {
|
||
sendQueue = sendQueue + [message]
|
||
guard !isPumping else { return }
|
||
isPumping = true
|
||
Task { await self.pumpSendQueue() }
|
||
}
|
||
|
||
private func pumpSendQueue() async {
|
||
while let next = sendQueue.first {
|
||
sendQueue = Array(sendQueue.dropFirst())
|
||
await engine.send(next)
|
||
forwardedSendCount += 1
|
||
resumeSendWaiters()
|
||
}
|
||
isPumping = false
|
||
}
|
||
|
||
// MARK: - Deterministic test barriers (no polling, no real sleeps)
|
||
|
||
/// Suspends until at least `eventCount` events have been applied.
|
||
func waitUntilProcessed(eventCount target: Int) async {
|
||
await withCheckedContinuation { continuation in
|
||
guard processedEventCount < target else {
|
||
continuation.resume()
|
||
return
|
||
}
|
||
eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
|
||
}
|
||
}
|
||
|
||
/// Suspends until at least `sendCount` messages were handed to the engine.
|
||
func waitUntilForwarded(sendCount target: Int) async {
|
||
await withCheckedContinuation { continuation in
|
||
guard forwardedSendCount < target else {
|
||
continuation.resume()
|
||
return
|
||
}
|
||
sendWaiters = sendWaiters + [CountWaiter(target: target, continuation: continuation)]
|
||
}
|
||
}
|
||
|
||
private func resumeEventWaiters() {
|
||
let satisfied = eventWaiters.filter { $0.target <= processedEventCount }
|
||
eventWaiters = eventWaiters.filter { $0.target > processedEventCount }
|
||
for waiter in satisfied {
|
||
waiter.continuation.resume()
|
||
}
|
||
}
|
||
|
||
private func resumeSendWaiters() {
|
||
let satisfied = sendWaiters.filter { $0.target <= forwardedSendCount }
|
||
sendWaiters = sendWaiters.filter { $0.target > forwardedSendCount }
|
||
for waiter in satisfied {
|
||
waiter.continuation.resume()
|
||
}
|
||
}
|
||
|
||
private func releaseAllWaiters() {
|
||
let all = eventWaiters + sendWaiters
|
||
eventWaiters = []
|
||
sendWaiters = []
|
||
for waiter in all {
|
||
waiter.continuation.resume()
|
||
}
|
||
}
|
||
}
|