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
256 lines
10 KiB
Swift
256 lines
10 KiB
Swift
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 后
|
||
/// 的重开(suspend→resume)不再注入。
|
||
@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 当前目标:既有会话 → 裸 attach;fresh spawn(目标 id 为
|
||
/// nil)→ 带 cwd attach + attach 后注入 bootstrap(T-iOS-26)。suspend
|
||
/// 前已采纳的 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(
|
||
transport: environment.termTransport,
|
||
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
|
||
}
|
||
}
|