Files
web-terminal/ios/App/WebTerm/Wiring/TerminalSessionController.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
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.
2026-07-30 15:58:01 +02:00

259 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import HostRegistry
import Observation
import SessionCore
import WireProtocol
/// T-iOS-15 · One open terminal = one controller: builds and owns the
/// per-session stack (engine fan-out TerminalViewModel + GateViewModel +
/// SessionActivityBridge) and drives the scenePhase lifecycle.
///
/// Lifecycle (plan §7 T-iOS-15):
/// - `.background` `suspend()`: `engine.close()` a clean detach (the
/// server-side PTY keeps running), never a half-dead socket that iOS will
/// kill mid-suspend anyway.
/// - `.active` after a suspend `resumeIfNeeded()`: `close()` is terminal for
/// an engine, so the stack is REBUILT and re-opened against the
/// server-adopted sessionId. The fresh SwiftTerm view (new `generation`
/// new UIView) replays the full ring buffer and fires `sizeChanged` on first
/// layout that resize is the latest-writer-wins full-screen reclaim
/// (), immediately after the attach.
/// - `.active` with the engine still alive (transient `.inactive`, e.g.
/// control center / app switcher peek): `engine.notifyForegrounded(dims:)`
/// with `TerminalViewModel.lastSentDims` reconnect-if-needed + resize
/// reclaim (latest-writer-wins). Skipped only when no valid dims were ever
/// sent (SwiftTerm has not laid out yet its first `sizeChanged` covers
/// it). The W4 documented deviation is CLOSED (orchestrator added the
/// `lastSentDims` accessor on behalf of the T-iOS-11 owner).
@MainActor
@Observable
final class TerminalSessionController: Identifiable {
let id = UUID()
/// Bumped on every rebuild; the container view uses it as the SwiftUI
/// identity of `TerminalScreen`, forcing a fresh `makeUIView` so the new
/// ViewModel's output sink actually attaches to a new SwiftTerm view.
private(set) var generation = 0
private(set) var terminalViewModel: TerminalViewModel
private(set) var gateViewModel: GateViewModel
/// T-iOS-29 · read-only exposure for the coordinator's new-in-cwd flow
/// (the host to reopen against a controller is host-bound for life).
@ObservationIgnored let host: HostRegistry.Host
@ObservationIgnored private let environment: AppEnvironment
@ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void
/// T-iOS-23 · OSC-title outlet (adopted sessionId + SANITIZED title)
/// re-attached to every rebuilt TerminalViewModel so a background
/// foreground rebuild never silently drops the list-title feed.
@ObservationIgnored private let onTitleChanged: @MainActor (UUID, String) -> Void
/// T-iOS-26 · fresh-spawn `attach(null, cwd)`
/// sessionId nil cwd
/// T-iOS-29 coordinator fresh spawn
/// new-in-cwd cwd 退
@ObservationIgnored let spawnCwd: String?
/// T-iOS-26 · attach `claude\r`engine
/// attach-first attach 线 id
/// suspendresume
@ObservationIgnored private let bootstrapInput: String?
/// open(+bootstrap) ProjectOpenWiringTests
@ObservationIgnored private(set) var openTask: Task<Void, Never>?
@ObservationIgnored private var engine: SessionEngine
@ObservationIgnored private var fanOut: EventFanOut<SessionEvent>
@ObservationIgnored private var bridge: SessionActivityBridge
/// What the next (re)open attaches to: the server-adopted id once known,
/// else the list's requested id (nil = new session).
@ObservationIgnored private var targetSessionId: UUID?
@ObservationIgnored private var isSuspended = false
@ObservationIgnored private var hasStarted = false
@ObservationIgnored private var isTornDown = false
/// Fan-out branch order (single place, no magic indices).
private enum Branch {
static let terminal = 0
static let gate = 1
static let activity = 2
static let count = 3
}
init(
host: HostRegistry.Host,
sessionId: UUID?,
environment: AppEnvironment,
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void,
onTitleChanged: @escaping @MainActor (UUID, String) -> Void = { _, _ in },
spawnCwd: String? = nil,
bootstrapInput: String? = nil
) {
self.host = host
self.environment = environment
self.onPendingChanged = onPendingChanged
self.onTitleChanged = onTitleChanged
self.spawnCwd = spawnCwd
self.bootstrapInput = bootstrapInput
self.targetSessionId = sessionId
let stack = Self.makeStack(
host: host, environment: environment, onPendingChanged: onPendingChanged
)
engine = stack.engine
fanOut = stack.fanOut
terminalViewModel = stack.terminalViewModel
gateViewModel = stack.gateViewModel
bridge = stack.bridge
terminalViewModel.onTitleChanged = onTitleChanged
}
// MARK: - Lifecycle entry points
/// Kick off: consumers first, then the engine connect (events buffer
/// unbounded, so this order is belt-and-braces, not a race fix).
func start() {
guard !hasStarted, !isTornDown else { return }
hasStarted = true
startConsumers()
openEngineAtTarget()
}
/// scenePhase `.background`: clean detach (PTY keeps running).
func suspend() {
guard hasStarted, !isSuspended, !isTornDown else { return }
isSuspended = true
rememberAdoptedSession()
stopConsumers()
let engine = engine
Task { await engine.close() }
}
/// scenePhase `.active`: rebuild-and-reopen if we suspended; with the
/// engine still alive, notifyForegrounded (reconnect + size reclaim).
func resumeIfNeeded() {
guard !isTornDown else { return }
guard isSuspended else {
notifyForegroundedIfPossible()
return
}
isSuspended = false
rebuildStack()
startConsumers()
openEngineAtTarget()
}
/// (Re)open attachfresh spawn id
/// nil cwd attach + attach bootstrapT-iOS-26suspend
/// id bootstrap
private func openEngineAtTarget() {
let engine = engine
let sessionId = targetSessionId
let cwd = sessionId == nil ? spawnCwd : nil
let bootstrap = sessionId == nil ? bootstrapInput : nil
openTask = Task {
await engine.open(sessionId: sessionId, cwd: cwd)
if let bootstrap {
await engine.send(.input(data: bootstrap))
}
}
}
/// Alive-engine `.active` hop: feed the engine the last VALID dims the
/// terminal reported so it reconnects (if dropped during `.inactive`) and
/// re-stamps the PTY size (latest-writer-wins reclaims full screen).
private func notifyForegroundedIfPossible() {
guard hasStarted, let dims = terminalViewModel.lastSentDims else { return }
let engine = engine
Task { await engine.notifyForegrounded(dims: dims.asTuple) }
}
/// Back navigation / screen dismissed: detach for good. Idempotent.
func teardown() {
guard !isTornDown else { return }
isTornDown = true
rememberAdoptedSession()
stopConsumers()
let engine = engine
Task { await engine.close() }
}
// MARK: - Timeline drill-down (T-iOS-24, additive)
/// Per-host timeline fetcher for the container's `TimelineSheet` the
/// same `APIClient.events` wrapper the engine's away-digest uses; the
/// single derivation point stays `AppEnvironment.makeEventsSource`.
var timelineEventsSource: @Sendable (UUID) async throws -> [TimelineEvent] {
environment.makeEventsSource(endpoint: host.endpoint)
}
// MARK: - Stack assembly
private struct Stack {
let engine: SessionEngine
let fanOut: EventFanOut<SessionEvent>
let terminalViewModel: TerminalViewModel
let gateViewModel: GateViewModel
let bridge: SessionActivityBridge
}
private static func makeStack(
host: HostRegistry.Host,
environment: AppEnvironment,
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
) -> Stack {
let engine = SessionEngine(
// E1 · The transport is built for THIS host, so the WS upgrade
// carries this host's access token (§1.1) a shared, host-blind
// transport could not resolve a token for a mixed fleet at all.
transport: environment.makeTermTransport(for: host),
clock: ContinuousClock(),
endpoint: host.endpoint,
eventsSource: environment.makeEventsSource(endpoint: host.endpoint)
)
let fanOut = EventFanOut(source: engine.events, branchCount: Branch.count)
return Stack(
engine: engine,
fanOut: fanOut,
terminalViewModel: TerminalViewModel(
engine: engine, events: fanOut.branches[Branch.terminal]
),
gateViewModel: GateViewModel(
engine: engine, events: fanOut.branches[Branch.gate],
haptics: GateHaptics(), clock: ContinuousClock()
),
bridge: SessionActivityBridge(
events: fanOut.branches[Branch.activity], hostId: host.id,
lastSessionStore: environment.lastSessionStore,
onPendingChanged: onPendingChanged
)
)
}
private func rebuildStack() {
fanOut.cancel()
let stack = Self.makeStack(
host: host, environment: environment, onPendingChanged: onPendingChanged
)
engine = stack.engine
fanOut = stack.fanOut
terminalViewModel = stack.terminalViewModel
gateViewModel = stack.gateViewModel
bridge = stack.bridge
terminalViewModel.onTitleChanged = onTitleChanged // rebuilt VM re-wired
generation += 1 // new SwiftUI identity fresh SwiftTerm view
}
private func startConsumers() {
terminalViewModel.start()
gateViewModel.start()
bridge.start()
}
private func stopConsumers() {
terminalViewModel.stop()
gateViewModel.stop()
bridge.stop()
}
/// The reopen target: always prefer the server-adopted id (it may differ
/// from what was requested unknown UUIDs mint new sessions).
private func rememberAdoptedSession() {
targetSessionId = bridge.adoptedSessionId ?? targetSessionId
}
}