Files
web-terminal/ios/App/WebTerm/ViewModels/TerminalViewModel.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00

353 lines
14 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 1s30s).
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 或调高客户端上限"
/// 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)
}
}
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()
}
}
}