diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 955e45f..028fdc9 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -50,7 +50,13 @@ - **T-iOS-16 集成 CI**: 吸收 spike 重构 harness;10 测对真自举 Node 服务器(echo/stty resize 1..1000/16MiB+ESC/C0 对抗回放/JOIN mirror/401/401/403/kill→WS close 而非 exit 帧/自然 exit 广播);ios.yml=包矩阵+coverage-gate.sh(own-Sources 过滤,修正 §9 已知缺陷)+App 测试+集成 job;覆盖率门红→绿演示实录;CI 平台未跑(如实注明)。 - **T-iOS-17 ntfy 文档**: ios/README.md;只读验证(引 setup-hooks.mjs file:line),**未触碰用户真实 hook 配置**(orchestrator 安全约束);payload 最小化逐行核;真机端到端 DEFERRED。 - **W3 验证(独立 agent,全项 PASS)**: 包套件 59+3+77+30+45;App 55 测 TEST SUCCEEDED;集成 10 测+1 known issue(故意的 1MiB 复现);语义抽查 5/5(KeyByteMap 逐字节 16 项、三选一字面量+零 allowAutoMode、零网络断言实测在 :60、轮询走 Tunables、CI 覆盖率 own-Sources 过滤);Owns 审计零越界。**累计 224 单测 + 10 集成**。 -- 待续: W4(T-iOS-15 汇合接线)→ W5(T-iOS-18/19 验收)。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。 +- **[x] W4 汇合(T-iOS-15,1 builder + verify 8/8 PASS)**: + - **T-iOS-15 App 接线**: `Wiring/` 9 文件——AppEnvironment 生产 DI(真 Keychain/真探针/URLSessionHTTPTransport 零逻辑透传,Origin 规则全留 APIClient)、EventFanOut(单消费者 engine.events → TerminalVM+GateVM+SessionActivityBridge 三分支保序广播)、bridge(.adopted→存 lastSessionId/.gate→列表 ⚠ overlay/.exited→清两者,防「继续上次」指向死会话)、TerminalSessionController(.background→close 干净 detach;suspend 后 .active→重建栈+open,generation 换 SwiftUI identity 触发首次 sizeChanged 夺回全屏)、**PrivacyShade 严格 `scenePhase != .active`**(三相位测试)、冷启动路由纯函数;WebTermApp 薄 @main;**删除 SpikeTerminalScreen**(grep 零残留)。 + - **两项自动化走查代理**: ①LiveServerSmokeTests——签名宿主内真生产 DI 图端到端(探针→host 入库→liveSessions→attach→echo 回显→close→kill 清理);②KeychainHostStoreLiveTests——真 Keychain 往返+`SecItemCopyMatching` 断言 `kSecAttrAccessible==AfterFirstUnlockThisDeviceOnly`+非 synchronizable(T-iOS-7 递延项闭合)。模拟器 boot 截屏:配对页正常渲染。 + - **环境硬墙(新发现,已记 harness)**: repo 在 `~/Documents`(TCC 保护)时,模拟器 App 语境 posix_spawn 的 node 读 repo 文件被 tccd 无限阻塞(sample 实测卡 `uv_cwd→__open_nocancel`;平台二进制豁免)→ SimServerHarness 双模式:A 自举(CI/非 TCC 路径)/B `WEBTERM_SERVER_URL` 外部注入;TCC 下 0.001s 快速失败+指引。**本机跑法**: 起服务器后 `xcrun simctl spawn booted launchctl setenv WEBTERM_SERVER_URL …` 再 xcodebuild test(命令行 TEST_RUNNER_ 前缀实测传不进宿主 App 测试进程);裸跑 smoke 红=有意显式失败非回归。 + - **偏差闭合(orchestrator 代 T-iOS-11 owner 修)**: W4 曾记「alive-engine `.active` 未调 notifyForegrounded(dims:)——W3 无 dims 钩子」→ TerminalViewModel 增 `lastSentDims`(仅记有效 resize,invalid 不覆盖,有测试)+ controller `.active` 分支接 `notifyForegrounded(dims:)`(无有效 dims 时跳过,SwiftTerm 首次布局兜底)。**76 App 测全绿**(75+1)。 + - **verify(独立 agent,8/8)**: 214 包测+76 App 测+10 集成;spike 删净;遮罩规则/生命周期双向 close 路径/组装安全(G 全走 APIClient、零 ad-hoc URLSession、零 ArbitraryLoads、Origin 零手拼)逐项在源码核实;Owns 零越界。 +- 待续: W5(T-iOS-18 F 走查 ∥ T-iOS-19 安全核对,report-only)→ P0 收官。真机项(键盘/IME/扫码/切换器遮罩目检/ntfy 端到端)全部 DEFERRED 至有真机时由用户执行 T-iOS-18 清单。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。 ### ✅ iOS 客户端实施计划 — `docs/PLAN_IOS_CLIENT.md`(规划完成 — 2026-07-04,main,未提交;纯文档,零代码改动) **多 agent 编排 = 一个 ultracode `Workflow`(66 agents,7 阶段)**:5 读者并行(wire contract 逐 file:line / 功能矩阵 / relay 现状 / 模板规范 / iOS 平台联网调研)→ 3 架构师独立竞标(full-native / WKWebView-hybrid / phased)→ 3 评委多视角打分(UX/工程/安全,**phased 26.5 胜出**,native 23.5、hybrid 16.5,21 条 mustSteal 嫁接)→ 综合简报 → 写手出稿(843 行)→ **4 评审交叉验证**(对照 src/ 源码逐条核实 / iOS API 联网核查 / 安全 / 计划质量)→ 每条 finding 对抗式 verify(CRITICAL/HIGH 双票)→ fixer 应用。 diff --git a/ios/App/WebTerm/Screens/SpikeTerminalScreen.swift b/ios/App/WebTerm/Screens/SpikeTerminalScreen.swift deleted file mode 100644 index 282f95a..0000000 --- a/ios/App/WebTerm/Screens/SpikeTerminalScreen.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// SpikeTerminalScreen.swift — T-iOS-2 Day-1 spike ②【临时文件,W4/T-iOS-15 删除】 -// -// 目的:SwiftTerm TerminalView 的真机键盘/IME smoke 载体(plan §7 T-iOS-2): -// - 键盘弹出 / first-responder 行为 -// - 中文 IME 组合输入(不自加 keydown 监听,组合事件全交系统栈) -// - inputAccessoryView 上 Esc/Ctrl-C 可发 —— SwiftTerm iOS 端自带 -// TerminalAccessory(含 esc/ctrl/tab 键,iOSAccessoryView.swift), -// spike 直接用它验证;正式 key-bar(逐字节复刻 public/keybar.ts)归 T-iOS-11 -// - 文本选择不崩(SwiftTerm 自绘选择是历史坑点) -// -// 接线为【纯本地回显】(无服务器依赖):typed bytes → send() → feed() 原样喂回, -// CR 扩成 CRLF。真实 SessionEngine 接线是 W2/W3 的事。 -// -// 2026-07-04:无可用真机 —— 真机清单各项记 DEFERRED(真机不可用)(见 PROGRESS_LOG -// 条目)。本文件当下的验收是:作为 App target 源码在模拟器 xcodebuild 构建通过。 - -import SwiftTerm -import SwiftUI - -/// Day-1 spike 的最小 SwiftUI 壳。不接入 WebTermApp 导航(WebTermApp.swift 归 -/// T-iOS-1/T-iOS-15 所有);存在本身即证明 App target 在 Swift 6 strict -/// concurrency 下能链接并承载 SwiftTerm 的 TerminalView。 -struct SpikeTerminalScreen: View { - var body: some View { - LocalEchoTerminal() - .ignoresSafeArea(.container, edges: .bottom) - .navigationTitle("SwiftTerm spike") - } -} - -/// 承载 SwiftTerm UIKit `TerminalView` 的 UIViewRepresentable,接本地回显。 -/// 形状即 T-iOS-11 正式 TerminalScreen 的雏形(去掉 SessionEngine)。 -private struct LocalEchoTerminal: UIViewRepresentable { - func makeCoordinator() -> LocalEchoCoordinator { - LocalEchoCoordinator() - } - - func makeUIView(context: Context) -> TerminalView { - let view = TerminalView(frame: .zero) - view.terminalDelegate = context.coordinator - view.feed(text: SpikeGreeting.text) - return view - } - - func updateUIView(_ uiView: TerminalView, context: Context) { - // 本地回显无外部状态可同步。 - } -} - -private enum SpikeGreeting { - /// 行尾用 CRLF:PTY 语义下裸 LF 只下移一行不回车。 - static let text = "SwiftTerm local-echo spike (T-iOS-2)\r\n" - + "Typed bytes are echoed locally; CR -> CRLF.\r\n" - + "真机清单: 键盘弹出 / 中文 IME / accessory esc·ctrl / 文本选择\r\n\r\n> " -} - -/// 本地回显 delegate:SwiftTerm 要"发给宿主"的每个字节原样喂回终端。 -/// Enter 产生的 CR(0x0D)补一个 LF,光标才会回行首并换行。 -@MainActor -private final class LocalEchoCoordinator: NSObject, @preconcurrency TerminalViewDelegate { - private static let carriageReturn: UInt8 = 0x0D - private static let lineFeed: UInt8 = 0x0A - - func send(source: TerminalView, data: ArraySlice) { - var echoed: [UInt8] = [] - echoed.reserveCapacity(data.count + 1) - for byte in data { - echoed.append(byte) - if byte == Self.carriageReturn { - echoed.append(Self.lineFeed) - } - } - source.feed(byteArray: echoed[...]) - } - - func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {} - func setTerminalTitle(source: TerminalView, title: String) {} - func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} - func scrolled(source: TerminalView, position: Double) {} - func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {} - func clipboardCopy(source: TerminalView, content: Data) {} - func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} - // bell / iTermContent 用 SwiftTerm 的默认实现。 -} - -#Preview { - SpikeTerminalScreen() -} diff --git a/ios/App/WebTerm/ViewModels/TerminalViewModel.swift b/ios/App/WebTerm/ViewModels/TerminalViewModel.swift index c480e8b..ae8381a 100644 --- a/ios/App/WebTerm/ViewModels/TerminalViewModel.swift +++ b/ios/App/WebTerm/ViewModels/TerminalViewModel.swift @@ -43,12 +43,25 @@ final class TerminalViewModel { 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 @@ -182,8 +195,14 @@ final class TerminalViewModel { } /// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded — - /// the engine validates bounds and owns terminal-state dropping. + /// 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)) } diff --git a/ios/App/WebTerm/WebTermApp.swift b/ios/App/WebTerm/WebTermApp.swift index cb264b3..64ced54 100644 --- a/ios/App/WebTerm/WebTermApp.swift +++ b/ios/App/WebTerm/WebTermApp.swift @@ -1,24 +1,15 @@ import SwiftUI -/// T-iOS-1 scaffold: empty-window placeholder. Navigation assembly -/// (Pairing → SessionList → Terminal) is wired in T-iOS-15. +/// T-iOS-15 · App entry: assemble the production dependency graph once and +/// hand it to the coordinator (Pairing → SessionList → Terminal). All wiring +/// lives under `Wiring/`; this file stays a thin `@main`. @main struct WebTermApp: App { + @State private var coordinator = AppCoordinator(environment: .production()) + var body: some Scene { WindowGroup { - HelloPlaceholderView() + RootView(coordinator: coordinator) } } } - -private struct HelloPlaceholderView: View { - var body: some View { - VStack(spacing: 12) { - Image(systemName: "terminal") - .font(.largeTitle) - Text("Hello WebTerm") - .font(.title2) - } - .padding() - } -} diff --git a/ios/App/WebTerm/Wiring/AppCoordinator.swift b/ios/App/WebTerm/Wiring/AppCoordinator.swift new file mode 100644 index 0000000..6dd3356 --- /dev/null +++ b/ios/App/WebTerm/Wiring/AppCoordinator.swift @@ -0,0 +1,146 @@ +import Foundation +import HostRegistry +import Observation +import SwiftUI + +/// T-iOS-15 · Navigation + lifecycle owner: Pairing → SessionList → Terminal +/// with the production dependency graph (plan §7 T-iOS-15 step 1). +/// +/// - Cold start: read the host store once — no paired host → Pairing, else +/// SessionList (`ColdStartPolicy.initialRoute`). +/// - `SessionListScreen.onOpen` → build ONE `TerminalSessionController` +/// (single foreground session, plan §1) and push the terminal. +/// - Back → `closeTerminal()` → `engine.close()` (detach; PTY keeps running). +/// - scenePhase is forwarded to the open controller (`.background` → +/// suspend/close, `.active` → resume/rebuild). The privacy shade is view +/// layer (RootView + PrivacyShadePolicy), not coordinator state. +@MainActor +@Observable +final class AppCoordinator { + private(set) var route: ColdStartPolicy.RootRoute = .loading + private(set) var terminalController: TerminalSessionController? + /// First-run pairing VM (root route). Recreated per entry to keep pairing + /// state machines single-shot. + private(set) var rootPairingViewModel: PairingViewModel? + /// Add-host pairing VM (sheet from the list header). + private(set) var addHostPairingViewModel: PairingViewModel? + var isAddHostPresented = false + + let sessionList: SessionListViewModel + @ObservationIgnored let environment: AppEnvironment + + init(environment: AppEnvironment) { + self.environment = environment + sessionList = SessionListViewModel( + hostStore: environment.hostStore, + http: environment.http, + clock: ContinuousClock() + ) + } + + // MARK: - Cold start + + /// Decide the boot route from the host store. A store READ failure routes + /// to the session list, whose own `reloadHosts` surfaces the explicit + /// error copy (never a silent empty pairing screen hiding a broken store). + func bootstrap() async { + guard route == .loading else { return } + do { + let hosts = try await environment.hostStore.loadAll() + route = ColdStartPolicy.initialRoute(pairedHostCount: hosts.count) + } catch { + route = .sessions + } + if route == .pairing { + rootPairingViewModel = makePairingViewModel() + } + } + + /// First-run pairing done → move to the list (the paired host is already + /// in the store — PairingViewModel upserts before signalling). + func completeFirstPairing(_ host: HostRegistry.Host) { + rootPairingViewModel = nil + route = .sessions + Task { await sessionList.reloadHosts() } + } + + // MARK: - Add-host sheet (list header hook) + + func presentAddHost() { + addHostPairingViewModel = makePairingViewModel() + isAddHostPresented = true + } + + func completeAddHost(_ host: HostRegistry.Host) { + isAddHostPresented = false + addHostDismissed() + } + + /// Sheet gone (paired OR cancelled): drop the VM and refresh hosts — the + /// list VM keeps the active host if it still exists. + func addHostDismissed() { + addHostPairingViewModel = nil + Task { await sessionList.reloadHosts() } + } + + // MARK: - Terminal open/close + + /// `SessionListScreen.onOpen` (one navigation signal per tap) and the + /// "继续上次" banner both land here. `sessionId == nil` = new session. + func open(_ request: SessionListViewModel.OpenRequest) { + guard terminalController == nil else { return } // one foreground session + let controller = TerminalSessionController( + host: request.host, + sessionId: request.sessionId, + environment: environment, + onPendingChanged: { [weak self] sessionId, pending in + self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending) + } + ) + terminalController = controller + controller.start() + } + + /// Back navigation popped the terminal: explicit detach. + func closeTerminal() { + terminalController?.teardown() + terminalController = nil + } + + // MARK: - "继续上次" (cold start step 5) + + var continueLastSessionId: UUID? { + ColdStartPolicy.continueLastSessionId( + activeHost: sessionList.activeHost, + store: environment.lastSessionStore + ) + } + + func openContinueLast() { + guard let host = sessionList.activeHost, let sessionId = continueLastSessionId else { + return + } + open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId)) + } + + // MARK: - scenePhase (plan §7 T-iOS-15 step 3) + + func handleScenePhase(_ phase: ScenePhase) { + switch phase { + case .background: + terminalController?.suspend() + case .active: + terminalController?.resumeIfNeeded() + case .inactive: + break // transient; shade covers it at the view layer + @unknown default: + break + } + } + + // MARK: - Helpers + + private func makePairingViewModel() -> PairingViewModel { + PairingViewModel(store: environment.hostStore, probe: environment.probe) + } +} diff --git a/ios/App/WebTerm/Wiring/AppEnvironment.swift b/ios/App/WebTerm/Wiring/AppEnvironment.swift new file mode 100644 index 0000000..f53fe38 --- /dev/null +++ b/ios/App/WebTerm/Wiring/AppEnvironment.swift @@ -0,0 +1,56 @@ +import APIClient +import Foundation +import HostRegistry +import SessionCore +import WireProtocol + +/// T-iOS-15 · Production dependency graph (composition root). One immutable +/// value assembled at launch; every screen/VM receives its dependencies from +/// here — no ad-hoc URLSession/keychain access anywhere else in the App layer. +/// +/// Assembly security audit (task 安全注, verified at this single point): +/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via +/// SessionListViewModel's client, probe's kill round-trip inside +/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own. +/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport; +/// HTTP: APIClient's route builder) — nothing here hand-assembles one. +/// - Secrets: hosts in `KeychainHostStore` +/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it); +/// UserDefaults carries only the non-secret per-host lastSessionId. +/// - No debug ATS overrides exist (project.yml declares NO +/// NSAllowsArbitraryLoads in any configuration — only the five §5.2 CIDR +/// exceptions), and no isSecureTextEntry-style screenshot hacks are used; +/// `UIScreen.isCaptured` detection is SKIPPED per plan (accepted residual +/// risk, local trust model). +struct AppEnvironment: Sendable { + let hostStore: any HostStore + let lastSessionStore: any LastSessionStore + let http: any HTTPTransport + let termTransport: any TermTransport + /// Injected into `PairingViewModel` — production is `runPairingProbe` + /// over the real transports (two-step: RO GET, then WS attach + guarded + /// kill; only runs after the user's explicit confirm, T-iOS-12). + let probe: PairingViewModel.Probe + + static func production() -> AppEnvironment { + let http = URLSessionHTTPTransport() + let termTransport = URLSessionTermTransport() + return AppEnvironment( + hostStore: KeychainHostStore(), + lastSessionStore: UserDefaultsLastSessionStore(), + http: http, + termTransport: termTransport, + probe: { endpoint in + await runPairingProbe(endpoint: endpoint, http: http, ws: termTransport) + } + ) + } + + /// Away-digest source for a session engine: wraps `APIClient.events` per + /// host (the engine never holds an HTTP client — plan §3.2). + func makeEventsSource(endpoint: HostEndpoint) + -> @Sendable (UUID) async throws -> [TimelineEvent] { + let client = APIClient(endpoint: endpoint, http: http) + return { id in try await client.events(id: id) } + } +} diff --git a/ios/App/WebTerm/Wiring/ColdStartPolicy.swift b/ios/App/WebTerm/Wiring/ColdStartPolicy.swift new file mode 100644 index 0000000..0e2f1bc --- /dev/null +++ b/ios/App/WebTerm/Wiring/ColdStartPolicy.swift @@ -0,0 +1,32 @@ +import Foundation +import HostRegistry + +/// T-iOS-15 · Cold-start selection logic, kept pure for unit tests +/// (ColdStartPolicyTests): which root screen boots, and whether the session +/// list highlights "继续上次" for the active host. +enum ColdStartPolicy { + enum RootRoute: Equatable { + /// Host store not read yet. + case loading + /// No paired host → pairing is the only next step (plan §7 step 1). + case pairing + /// At least one paired host → the merged chooser/dashboard list. + case sessions + } + + static func initialRoute(pairedHostCount: Int) -> RootRoute { + pairedHostCount == 0 ? .pairing : .sessions + } + + /// The "继续上次" target for the list's highlight banner: the last + /// server-adopted session persisted for the ACTIVE host (nil = no banner). + /// Persistence side: `SessionActivityBridge` (adopted → set, exited → + /// cleared), so a returned id was live when last seen. + static func continueLastSessionId( + activeHost: HostRegistry.Host?, + store: any LastSessionStore + ) -> UUID? { + guard let activeHost else { return nil } + return store.lastSessionId(host: activeHost.id) + } +} diff --git a/ios/App/WebTerm/Wiring/EventFanOut.swift b/ios/App/WebTerm/Wiring/EventFanOut.swift new file mode 100644 index 0000000..8e1bfb3 --- /dev/null +++ b/ios/App/WebTerm/Wiring/EventFanOut.swift @@ -0,0 +1,53 @@ +/// T-iOS-15 · Broadcast adapter for a single-consumer `AsyncStream`. +/// +/// `SessionEngine.events` has exactly ONE consumer (engine contract), but the +/// wiring needs three: `TerminalViewModel` (output/connection/exit), +/// `GateViewModel` (gate/digest) and `SessionActivityBridge` (adopted / +/// pending-⚠ / last-session persistence). Both VMs were designed for this — +/// their `events` parameter is injected separately from the engine precisely +/// so a fan-out branch can be passed instead (their type docs say so). +/// +/// Shape: `branchCount` child streams are created up front (immutable `let`s +/// — no late subscription, so no missed-element semantics to define); ONE pump +/// task consumes the source and yields to every branch in order. AsyncStream's +/// default unbounded buffering means a slow branch never drops or blocks the +/// others. Source finish — which `SessionEngine.close()` guarantees — finishes +/// every branch; `cancel()` is the teardown belt-and-braces for rebuilds. +final class EventFanOut: Sendable { + let branches: [AsyncStream] + private let continuations: [AsyncStream.Continuation] + private let pumpTask: Task + + init(source: AsyncStream, branchCount: Int) { + var branches: [AsyncStream] = [] + var continuations: [AsyncStream.Continuation] = [] + for _ in 0...makeStream() + branches.append(stream) + continuations.append(continuation) + } + self.branches = branches + self.continuations = continuations + let sinks = continuations + pumpTask = Task { + for await element in source { + for sink in sinks { + sink.yield(element) + } + } + for sink in sinks { + sink.finish() + } + } + } + + /// Stop pumping and finish every branch immediately (idempotent — finish + /// on a finished continuation is a no-op). Used when a suspended terminal + /// stack is torn down and rebuilt. + func cancel() { + pumpTask.cancel() + for sink in continuations { + sink.finish() + } + } +} diff --git a/ios/App/WebTerm/Wiring/PrivacyShade.swift b/ios/App/WebTerm/Wiring/PrivacyShade.swift new file mode 100644 index 0000000..30f1982 --- /dev/null +++ b/ios/App/WebTerm/Wiring/PrivacyShade.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// T-iOS-15 · Privacy shade (security-critical, plan §7 T-iOS-15). +/// +/// iOS writes the app-switcher snapshot to DISK the moment the scene reaches +/// `.background` — but the switcher is usually ENTERED at `.inactive`. The +/// shade therefore covers whenever `scenePhase != .active` (the exact rule; +/// `.inactive`-only would let the `.background` snapshot capture terminal +/// content — API keys, tokens, source — into the on-disk switcher image). +/// Restored on `.active`. +/// +/// Pure mapping split from the view so the rule is unit-testable for all +/// three phases (PrivacyShadeTests). +enum PrivacyShadePolicy { + static func isShadeVisible(for scenePhase: ScenePhase) -> Bool { + scenePhase != .active + } +} + +/// Opaque cover rendered ABOVE the whole navigation tree (RootView ZStack) — +/// deliberately no animation: the snapshot moment must never race a fade. +/// +/// Scope note (documented): sheets present in a separate presentation layer a +/// root overlay cannot cover — but no terminal bytes are ever rendered in a +/// sheet (pairing / plan-gate only), so the root-level shade covers every +/// surface that shows PTY content. +struct PrivacyShadeView: View { + var body: some View { + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + VStack(spacing: ShadeMetrics.spacing) { + Image(systemName: "terminal.fill") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("WebTerm") + .font(.headline) + .foregroundStyle(.secondary) + } + } + .accessibilityHidden(true) + } + + private enum ShadeMetrics { + static let spacing: CGFloat = 12 + } +} diff --git a/ios/App/WebTerm/Wiring/RootView.swift b/ios/App/WebTerm/Wiring/RootView.swift new file mode 100644 index 0000000..f470bcf --- /dev/null +++ b/ios/App/WebTerm/Wiring/RootView.swift @@ -0,0 +1,126 @@ +import HostRegistry +import SwiftUI + +/// T-iOS-15 · Root of the app: route switch (Pairing / SessionList), terminal +/// push, add-host sheet, scenePhase forwarding and the privacy shade. +/// +/// The shade is the TOPMOST layer of this ZStack and appears whenever +/// `scenePhase != .active` (exact rule — PrivacyShadePolicy + tests); it +/// covers the whole navigation tree, terminal included. Sheets live above any +/// overlay, but no sheet renders terminal bytes (pairing / plan gate only). +struct RootView: View { + @Bindable var coordinator: AppCoordinator + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + ZStack { + NavigationStack { + rootContent + .navigationDestination(isPresented: terminalBinding) { + terminalDestination + } + } + if PrivacyShadePolicy.isShadeVisible(for: scenePhase) { + PrivacyShadeView() + } + } + .task { await coordinator.bootstrap() } + .onChange(of: scenePhase) { _, phase in + coordinator.handleScenePhase(phase) + } + .sheet( + isPresented: $coordinator.isAddHostPresented, + onDismiss: { coordinator.addHostDismissed() } + ) { + addHostSheet + } + } + + // MARK: - Route switch + + @ViewBuilder private var rootContent: some View { + switch coordinator.route { + case .loading: + ProgressView() + case .pairing: + firstRunPairing + case .sessions: + sessionList + } + } + + @ViewBuilder private var firstRunPairing: some View { + if let viewModel = coordinator.rootPairingViewModel { + PairingScreen(viewModel: viewModel) { host in + coordinator.completeFirstPairing(host) + } + } else { + ProgressView() + } + } + + private var sessionList: some View { + SessionListScreen( + viewModel: coordinator.sessionList, + onOpen: { coordinator.open($0) }, + onAddHost: { coordinator.presentAddHost() } + ) + .safeAreaInset(edge: .bottom) { continueLastBanner } + } + + // MARK: - "继续上次" highlight (cold start step 5) + + @ViewBuilder private var continueLastBanner: some View { + if coordinator.continueLastSessionId != nil { + Button { + coordinator.openContinueLast() + } label: { + Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, RootMetrics.bannerHorizontalPadding) + .padding(.vertical, RootMetrics.bannerVerticalPadding) + .background(.thinMaterial) + } + } + + // MARK: - Terminal push + + private var terminalBinding: Binding { + Binding( + get: { coordinator.terminalController != nil }, + set: { presented in + guard !presented else { return } + coordinator.closeTerminal() // back → engine.close() (detach) + } + ) + } + + @ViewBuilder private var terminalDestination: some View { + if let controller = coordinator.terminalController { + TerminalContainerView(controller: controller) + } + } + + // MARK: - Add-host sheet (multi-host entry, list header) + + @ViewBuilder private var addHostSheet: some View { + if let viewModel = coordinator.addHostPairingViewModel { + NavigationStack { + PairingScreen(viewModel: viewModel) { host in + coordinator.completeAddHost(host) + } + } + } + } +} + +private enum RootMetrics { + static let bannerHorizontalPadding: CGFloat = 16 + static let bannerVerticalPadding: CGFloat = 8 +} + +private enum RootCopy { + static let continueLast = "继续上次会话" +} diff --git a/ios/App/WebTerm/Wiring/SessionActivityBridge.swift b/ios/App/WebTerm/Wiring/SessionActivityBridge.swift new file mode 100644 index 0000000..f316d3b --- /dev/null +++ b/ios/App/WebTerm/Wiring/SessionActivityBridge.swift @@ -0,0 +1,129 @@ +import Foundation +import HostRegistry +import SessionCore + +/// T-iOS-15 · Third fan-out branch: session-level side effects that belong to +/// neither terminal nor gate UI. +/// +/// - `.adopted` → remember the SERVER-issued id (always adopt it — attaching +/// an unknown UUID yields a brand-new session) and persist it per host via +/// `LastSessionStore` (TerminalViewModel's doc assigns this persistence to +/// the T-iOS-15 wiring). This id is also what a background→foreground +/// rebuild re-attaches to. +/// - `.gate` → forward pending (gate != nil) into +/// `SessionListViewModel.setPendingApproval` — the ⚠ overlay hook the list +/// VM documents ("The T-iOS-15 wiring forwards gate/status events into +/// setPendingApproval"). Gates arriving before adoption have no session to +/// attribute and are dropped (cannot happen on the real wire: `attached` is +/// always first — defensive only). +/// - `.exited` → clear the ⚠ overlay AND the persisted lastSessionId: a dead +/// session must not stay flagged nor be offered as "继续上次" (documented +/// decision — otherwise cold start would silently spawn a NEW session via +/// the unknown-UUID attach path). +/// +/// A detach (stream finish without exit) deliberately KEEPS the ⚠ overlay: +/// the server still holds the gate; the list poll prunes it if the session +/// disappears. +@MainActor +final class SessionActivityBridge { + private(set) var adoptedSessionId: UUID? + + private let events: AsyncStream + private let hostId: UUID + private let lastSessionStore: any LastSessionStore + private let onPendingChanged: @MainActor (UUID, Bool) -> Void + private var consumeTask: Task? + + // Deterministic test barrier (same pattern as the W3 ViewModels). + private(set) var processedEventCount = 0 + private var eventWaiters: [CountWaiter] = [] + + private struct CountWaiter { + let target: Int + let continuation: CheckedContinuation + } + + init( + events: AsyncStream, + hostId: UUID, + lastSessionStore: any LastSessionStore, + onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void + ) { + self.events = events + self.hostId = hostId + self.lastSessionStore = lastSessionStore + self.onPendingChanged = onPendingChanged + } + + /// Begin consuming. Idempotent — the branch 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() + } + } + + func stop() { + consumeTask?.cancel() + consumeTask = nil + releaseAllWaiters() + } + + // MARK: - Event application + + private func apply(_ event: SessionEvent) { + switch event { + case .adopted(let sessionId): + adoptedSessionId = sessionId + lastSessionStore.setLastSessionId(sessionId, host: hostId) + case .gate(let gate): + forwardPending(gate != nil) + case .exited: + forwardPending(false) + lastSessionStore.setLastSessionId(nil, host: hostId) + case .connection, .output, .telemetry, .digest: + break // terminal / gate UI domain + } + processedEventCount += 1 + resumeEventWaiters() + } + + private func forwardPending(_ pending: Bool) { + guard let adoptedSessionId else { return } + onPendingChanged(adoptedSessionId, pending) + } + + // MARK: - Test barrier + + /// 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)] + } + } + + 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 releaseAllWaiters() { + let all = eventWaiters + eventWaiters = [] + for waiter in all { + waiter.continuation.resume() + } + } +} diff --git a/ios/App/WebTerm/Wiring/TerminalContainerView.swift b/ios/App/WebTerm/Wiring/TerminalContainerView.swift new file mode 100644 index 0000000..1fe3222 --- /dev/null +++ b/ios/App/WebTerm/Wiring/TerminalContainerView.swift @@ -0,0 +1,103 @@ +import SessionCore +import SwiftUI + +/// T-iOS-15 · Terminal destination view: TerminalScreen (T-iOS-11) plus the +/// gate/digest surfaces (T-iOS-14) wired to the controller's GateViewModel — +/// the "接入 TerminalScreen 的 wiring 归 T-iOS-15" hand-off. +/// +/// Layout decisions (documented): +/// - `.id(controller.generation)`: a rebuild (background→foreground) swaps the +/// ViewModels; without a new identity SwiftUI would keep the old UIView and +/// `makeUIView` (where the output sink attaches) would never run again. +/// - Digest + tool-gate banner live in a TOP overlay stack: the bottom edge +/// belongs to the keyboard + key bar. TerminalScreen's own ReconnectBanner +/// is also top-aligned, but it hides on `.connected` while gates/digests +/// only arrive connected — practical overlap is nil. +/// - Plan gate presents as a medium-detent sheet; swiping it away is allowed +/// (the user may need to scroll the terminal to read the plan), and a +/// "计划待批" re-entry chip appears until the gate resolves. Deciding +/// removes the gate server-side → `planGate` goes nil → sheet dismisses. +struct TerminalContainerView: View { + let controller: TerminalSessionController + /// Epoch of a plan gate the user swiped away — suppresses re-present for + /// THAT gate only; a new epoch re-presents automatically. + @State private var dismissedPlanGateEpoch: Int? + + private enum Metrics { + static let overlaySpacing: CGFloat = 8 + static let overlayHorizontalPadding: CGFloat = 12 + /// Clears TerminalScreen's own top-aligned ReconnectBanner zone. + static let overlayTopPadding: CGFloat = 52 + } + + private enum Copy { + static let planGatePending = "⚠ 计划待批准" + } + + var body: some View { + TerminalScreen(viewModel: controller.terminalViewModel) + .id(controller.generation) + .navigationBarTitleDisplayMode(.inline) + .overlay(alignment: .top) { topOverlays } + .sheet(isPresented: planGateBinding) { planGateSheet } + } + + // MARK: - Digest + tool gate (top stack) + + @ViewBuilder private var topOverlays: some View { + let gateViewModel = controller.gateViewModel + VStack(spacing: Metrics.overlaySpacing) { + if let digest = gateViewModel.digest { + AwayDigestView( + digest: digest, + isExpanded: gateViewModel.isDigestExpanded, + onExpand: { gateViewModel.expandDigest() }, + onDismiss: { gateViewModel.dismissDigest() } + ) + } + if let gate = gateViewModel.toolGate { + GateBanner(gate: gate) { affordance, epoch in + gateViewModel.decide(affordance, epoch: epoch) + } + } + if hasDismissedPendingPlanGate { + Button(Copy.planGatePending) { dismissedPlanGateEpoch = nil } + .buttonStyle(.borderedProminent) + .tint(.orange) + .controlSize(.small) + } + } + .padding(.horizontal, Metrics.overlayHorizontalPadding) + .padding(.top, Metrics.overlayTopPadding) + .animation(.default, value: gateViewModel.toolGate) + .animation(.default, value: gateViewModel.digest) + } + + // MARK: - Plan gate sheet + + private var hasDismissedPendingPlanGate: Bool { + guard let gate = controller.gateViewModel.planGate else { return false } + return gate.epoch == dismissedPlanGateEpoch + } + + private var planGateBinding: Binding { + Binding( + get: { + guard let gate = controller.gateViewModel.planGate else { return false } + return gate.epoch != dismissedPlanGateEpoch + }, + set: { presented in + guard !presented else { return } + dismissedPlanGateEpoch = controller.gateViewModel.planGate?.epoch + } + ) + } + + @ViewBuilder private var planGateSheet: some View { + if let gate = controller.gateViewModel.planGate { + PlanGateSheet(gate: gate) { affordance, epoch in + controller.gateViewModel.decide(affordance, epoch: epoch) + } + } + } +} diff --git a/ios/App/WebTerm/Wiring/TerminalSessionController.swift b/ios/App/WebTerm/Wiring/TerminalSessionController.swift new file mode 100644 index 0000000..95daf22 --- /dev/null +++ b/ios/App/WebTerm/Wiring/TerminalSessionController.swift @@ -0,0 +1,209 @@ +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 + + @ObservationIgnored private let host: HostRegistry.Host + @ObservationIgnored private let environment: AppEnvironment + @ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void + @ObservationIgnored private var engine: SessionEngine + @ObservationIgnored private var fanOut: EventFanOut + @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 + ) { + self.host = host + self.environment = environment + self.onPendingChanged = onPendingChanged + 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 + } + + // 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() + let engine = engine + let sessionId = targetSessionId + Task { await engine.open(sessionId: sessionId, cwd: nil) } + } + + /// 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() + let engine = engine + let sessionId = targetSessionId + Task { await engine.open(sessionId: sessionId, cwd: nil) } + } + + /// 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: - Stack assembly + + private struct Stack { + let engine: SessionEngine + let fanOut: EventFanOut + 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 + 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 + } +} diff --git a/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift b/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift new file mode 100644 index 0000000..6768932 --- /dev/null +++ b/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift @@ -0,0 +1,26 @@ +import Foundation +import WireProtocol + +/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves +/// the URLSession wrapper for the production side; no package owns it, so the +/// assembly layer provides it). Deliberately logic-free: `APIClient` builds +/// every request — including the Origin-iff-G rule (plan §3.4 铁律) — and this +/// type only performs the exchange. Adding ANY header/URL logic here would +/// bypass that single audited point (review CRITICAL). +struct URLSessionHTTPTransport: HTTPTransport { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + // http(s)-only endpoints (HostEndpoint validates) always produce + // an HTTPURLResponse; anything else is a transport-level anomaly. + throw URLError(.badServerResponse) + } + return (data, httpResponse) + } +} diff --git a/ios/App/WebTermTests/ColdStartPolicyTests.swift b/ios/App/WebTermTests/ColdStartPolicyTests.swift new file mode 100644 index 0000000..7f09088 --- /dev/null +++ b/ios/App/WebTermTests/ColdStartPolicyTests.swift @@ -0,0 +1,65 @@ +import Foundation +import HostRegistry +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-15 · Cold-start selection logic (pure): which root screen boots, and +/// whether the session list should highlight "继续上次" for the active host. +@MainActor +@Suite("ColdStartPolicy") +struct ColdStartPolicyTests { + private final class StubLastSessionStore: LastSessionStore, @unchecked Sendable { + private let stored: [UUID: UUID] + init(stored: [UUID: UUID] = [:]) { self.stored = stored } + func lastSessionId(host: UUID) -> UUID? { stored[host] } + func setLastSessionId(_ id: UUID?, host: UUID) {} + } + + private static func makeHost(name: String = "mac") throws -> HostRegistry.Host { + let url = try #require(URL(string: "http://192.168.1.5:3000")) + let endpoint = try #require(HostEndpoint(baseURL: url)) + return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint) + } + + // MARK: - Root route: no paired host → Pairing; else SessionList + + @Test("无配对 host → 冷启动进 Pairing") + func noHostsBootsIntoPairing() { + #expect(ColdStartPolicy.initialRoute(pairedHostCount: 0) == .pairing) + } + + @Test("有配对 host → 冷启动进 SessionList") + func pairedHostsBootIntoSessionList() { + #expect(ColdStartPolicy.initialRoute(pairedHostCount: 1) == .sessions) + #expect(ColdStartPolicy.initialRoute(pairedHostCount: 3) == .sessions) + } + + // MARK: - "继续上次" highlight + + @Test("active host 存有 lastSessionId → 给出继续上次目标") + func continueLastReturnsStoredSession() throws { + let host = try Self.makeHost() + let sessionId = UUID() + let store = StubLastSessionStore(stored: [host.id: sessionId]) + + let target = ColdStartPolicy.continueLastSessionId(activeHost: host, store: store) + + #expect(target == sessionId) + } + + @Test("该 host 无记录 → 无继续上次高亮") + func continueLastNilWithoutRecord() throws { + let host = try Self.makeHost() + let other = UUID() + let store = StubLastSessionStore(stored: [other: UUID()]) + + #expect(ColdStartPolicy.continueLastSessionId(activeHost: host, store: store) == nil) + } + + @Test("尚无 active host(hosts 未加载/为空)→ 无高亮") + func continueLastNilWithoutActiveHost() { + let store = StubLastSessionStore() + #expect(ColdStartPolicy.continueLastSessionId(activeHost: nil, store: store) == nil) + } +} diff --git a/ios/App/WebTermTests/EventFanOutTests.swift b/ios/App/WebTermTests/EventFanOutTests.swift new file mode 100644 index 0000000..5c7fcbc --- /dev/null +++ b/ios/App/WebTermTests/EventFanOutTests.swift @@ -0,0 +1,79 @@ +import Testing +@testable import WebTerm + +/// T-iOS-15 · Fan-out adapter tests. `SessionEngine.events` is a +/// single-consumer `AsyncStream`, but TerminalViewModel, GateViewModel AND the +/// session-activity bridge must all observe it — `EventFanOut` is the wiring +/// layer's broadcast seam. These tests pin its contract: every branch receives +/// every element, in order, and source termination / cancel() propagate. +@Suite("EventFanOut") +struct EventFanOutTests { + private static let branchCount = 3 + + @Test("每个分支都按序收到全部元素") + func allBranchesReceiveAllElementsInOrder() async { + // Arrange + let (source, continuation) = AsyncStream.makeStream() + let fanOut = EventFanOut(source: source, branchCount: Self.branchCount) + + // Act + for value in 1...5 { + continuation.yield(value) + } + continuation.finish() + + // Assert: unbounded buffering — late consumption still sees everything. + for branch in fanOut.branches { + var received: [Int] = [] + for await value in branch { + received.append(value) + } + #expect(received == [1, 2, 3, 4, 5]) + } + } + + @Test("分支数量与请求一致") + func branchCountMatchesRequest() { + let (source, _) = AsyncStream.makeStream() + let fanOut = EventFanOut(source: source, branchCount: Self.branchCount) + #expect(fanOut.branches.count == Self.branchCount) + } + + @Test("源结束 → 所有分支结束(消费循环退出,不悬挂)") + func sourceFinishFinishesEveryBranch() async { + // Arrange + let (source, continuation) = AsyncStream.makeStream() + let fanOut = EventFanOut(source: source, branchCount: Self.branchCount) + + // Act + continuation.yield("only") + continuation.finish() + + // Assert: every branch loop terminates after draining. + for branch in fanOut.branches { + var count = 0 + for await _ in branch { + count += 1 + } + #expect(count == 1) + } + } + + @Test("cancel() → 分支立即终止(teardown 不泄漏消费任务)") + func cancelTerminatesBranches() async { + // Arrange + let (source, continuation) = AsyncStream.makeStream() + let fanOut = EventFanOut(source: source, branchCount: Self.branchCount) + + // Act: cancel without ever finishing the source. + fanOut.cancel() + continuation.yield(42) // post-cancel input must not hang consumers + + // Assert: iteration completes (content is unspecified mid-flight — + // termination is the contract under test). + for branch in fanOut.branches { + for await _ in branch {} + } + #expect(Bool(true)) // reaching here = no hang + } +} diff --git a/ios/App/WebTermTests/KeychainHostStoreLiveTests.swift b/ios/App/WebTermTests/KeychainHostStoreLiveTests.swift new file mode 100644 index 0000000..86f65d8 --- /dev/null +++ b/ios/App/WebTermTests/KeychainHostStoreLiveTests.swift @@ -0,0 +1,79 @@ +import Foundation +import HostRegistry +import Security +import Testing +import WireProtocol + +/// T-iOS-15 · Keychain-on-simulator assertion (deferred from T-iOS-7). +/// +/// `swift test` binaries are unsigned → the data-protection keychain answers +/// `errSecMissingEntitlement` (-34018), so the package layer only tests the +/// store against a fake `SecItemShim`. THIS bundle runs signed inside the +/// WebTerm app host, so here the REAL `LiveSecItemShim` path is exercised +/// end-to-end and the §5.3 protection class is asserted on the stored item. +@Suite("KeychainHostStore (real keychain, signed app host)", .serialized) +struct KeychainHostStoreLiveTests { + /// Test-only service/account — never collides with the production item. + private static let service = "com.yaojia.webterm.host-registry.livetests" + private static let account = "hosts-live-roundtrip" + + private static func makeHost() throws -> HostRegistry.Host { + let url = try #require(URL(string: "http://192.168.1.9:3000")) + let endpoint = try #require(HostEndpoint(baseURL: url)) + return HostRegistry.Host(id: UUID(), name: "live-roundtrip", endpoint: endpoint) + } + + /// Removes the test item regardless of test outcome. + private static func deleteTestItem() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true, + ] + SecItemDelete(query as CFDictionary) + } + + @Test("真 Keychain 往返(upsert/loadAll/remove)+ kSecAttrAccessible 属性断言") + func realKeychainRoundtripAndProtectionClass() async throws { + Self.deleteTestItem() // clean slate from any earlier aborted run + defer { Self.deleteTestItem() } // never leave the test item behind + + // Arrange: REAL shim (default init), isolated service/account. + let store = KeychainHostStore(service: Self.service, account: Self.account) + let host = try Self.makeHost() + + // Act + Assert: upsert → visible via loadAll. + let afterUpsert = try await store.upsert(host) + #expect(afterUpsert == [host]) + #expect(try await store.loadAll() == [host]) + + // Assert §5.3: the STORED item's protection class, straight from + // SecItemCopyMatching attributes (not from our own spec constants). + var result: CFTypeRef? + let attributesQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + kSecUseDataProtectionKeychain as String: true, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + let status = SecItemCopyMatching(attributesQuery as CFDictionary, &result) + #expect(status == errSecSuccess, "SecItemCopyMatching failed: \(status)") + let attributes = try #require(result as? [String: Any]) + let accessible = try #require(attributes[kSecAttrAccessible as String] as? String) + #expect( + accessible == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String, + "stored item must be AfterFirstUnlockThisDeviceOnly (§5.3), got \(accessible)" + ) + // §5.3: never iCloud-synced — the item must not be synchronizable. + let synchronizable = attributes[kSecAttrSynchronizable as String] as? Bool ?? false + #expect(!synchronizable) + + // Act + Assert: remove → empty store (item deleted). + let afterRemove = try await store.remove(id: host.id) + #expect(afterRemove.isEmpty) + #expect(try await store.loadAll() == []) + } +} diff --git a/ios/App/WebTermTests/LiveServerSmokeTests.swift b/ios/App/WebTermTests/LiveServerSmokeTests.swift new file mode 100644 index 0000000..648b5c7 --- /dev/null +++ b/ios/App/WebTermTests/LiveServerSmokeTests.swift @@ -0,0 +1,114 @@ +import APIClient +import Foundation +import HostRegistry +import SessionCore +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-15 · Hosted live-server smoke — the automated stand-in for the manual +/// 配对→列表→attach walkthrough (plan §7 T-iOS-15; the true device tap-through +/// + app-switcher shade visual check remain DEFERRED to T-iOS-18). +/// +/// Drives the REAL production DI graph end-to-end against the repo's real +/// Node server: `runPairingProbe` over `URLSessionHTTPTransport` + +/// `URLSessionTermTransport` (WS upgrade with Origin + guarded kill +/// round-trip) → Host stored (InMemory per task) → `APIClient.liveSessions` → +/// `SessionEngine.open(nil)` → input echo → output marker → `close()`. +@Suite("Live-server smoke (production DI graph)", .serialized) +struct LiveServerSmokeTests { + /// Collects engine output off the single-consumer stream. + private actor OutputObserver { + private(set) var adoptedId: UUID? + private var buffer = "" + private static let tailLength = 400 + + func recordAdopted(_ id: UUID) { adoptedId = id } + + /// Appends a chunk; true once the accumulated output contains `marker`. + func appendAndCheck(_ chunk: String, marker: String) -> Bool { + buffer += chunk + return buffer.contains(marker) + } + + var tail: String { String(buffer.suffix(Self.tailLength)) } + } + + @Test("探针→host 入库→liveSessions→attach(null)→echo→output→close") + func productionGraphEndToEnd() async throws { + let endpoint = try await SimServerHarness.shared.endpoint() + + // Production transports — the same instances AppEnvironment wires. + let http = URLSessionHTTPTransport() + let ws = URLSessionTermTransport() + + // ① Pairing probe (two-step: RO GET, WS attach + Origin, guarded kill). + let probed: HostEndpoint + switch await runPairingProbe(endpoint: endpoint, http: http, ws: ws) { + case .failure(let error): + Issue.record("pairing probe failed: \(error)") + return + case .success(let validated): + probed = validated + } + + // ② Host stored (InMemory stand-in is explicitly allowed here). + let store = InMemoryHostStore() + let host = HostRegistry.Host(id: UUID(), name: "smoke", endpoint: probed) + _ = try await store.upsert(host) + #expect(try await store.loadAll().map(\.id) == [host.id]) + + // ③ Session list over the real HTTP path (RO — no Origin). + let api = APIClient(endpoint: probed, http: http) + _ = try await api.liveSessions() + + // ④ Real engine: attach(null) → shell echo → observe evaluated marker. + // Arithmetic dedup: the TYPED command contains "$((...))", only the + // EVALUATED output contains the final marker string. + let base = 4200 + let offset = Int.random(in: 1...999) + let marker = "smoke-\(base + offset)" + let command = "echo smoke-$((\(base)+\(offset)))\r" + + let engine = SessionEngine( + transport: ws, clock: ContinuousClock(), endpoint: probed, + eventsSource: { id in try await api.events(id: id) } + ) + let observer = OutputObserver() + let consumeTask = Task { () -> Bool in + for await event in engine.events { + switch event { + case .adopted(let id): + await observer.recordAdopted(id) + case .output(let chunk): + if await observer.appendAndCheck(chunk, marker: marker) { return true } + default: + break + } + } + return false + } + + await engine.open(sessionId: nil, cwd: nil) + await engine.send(.input(data: command)) // queues until attach-ready + + let watchdog = Task { + try? await Task.sleep(for: SmokeTunables.outputTimeout) + consumeTask.cancel() // AsyncStream iteration honors cancellation + } + let sawMarker = await consumeTask.value + watchdog.cancel() + await engine.close() + + let outputTail = await observer.tail + #expect(sawMarker, "output 未包含 \(marker);tail: \(outputTail)") + + // Cleanup: kill the smoke session via the guarded G route (Origin). + let adopted = try #require(await observer.adoptedId, "attached 帧未到达") + do { + try await api.killSession(id: adopted) + } catch APIClientError.sessionNotFound { + // Already gone — cleanup goal reached. + } + } +} diff --git a/ios/App/WebTermTests/PrivacyShadeTests.swift b/ios/App/WebTermTests/PrivacyShadeTests.swift new file mode 100644 index 0000000..3cfa3e6 --- /dev/null +++ b/ios/App/WebTermTests/PrivacyShadeTests.swift @@ -0,0 +1,29 @@ +import SwiftUI +import Testing +@testable import WebTerm + +/// T-iOS-15 · Privacy-shade state mapping (plan §7 T-iOS-15, security-critical). +/// +/// The rule under test is EXACT: the shade covers whenever +/// `scenePhase != .active` — NOT just `.inactive`. Entering the app switcher +/// is `.inactive`, but the moment iOS writes the switcher snapshot to disk is +/// `.background`; covering only one of the two leaks terminal content +/// (API keys / tokens / source) into the on-disk snapshot. +@MainActor +@Suite("PrivacyShadePolicy") +struct PrivacyShadeTests { + @Test("scenePhase == .active → 遮罩隐藏(恢复终端)") + func activeShowsTerminal() { + #expect(!PrivacyShadePolicy.isShadeVisible(for: .active)) + } + + @Test("scenePhase == .inactive → 遮罩可见(切换器入口态)") + func inactiveIsCovered() { + #expect(PrivacyShadePolicy.isShadeVisible(for: .inactive)) + } + + @Test("scenePhase == .background → 遮罩可见(快照写盘时刻)") + func backgroundIsCovered() { + #expect(PrivacyShadePolicy.isShadeVisible(for: .background)) + } +} diff --git a/ios/App/WebTermTests/SessionActivityBridgeTests.swift b/ios/App/WebTermTests/SessionActivityBridgeTests.swift new file mode 100644 index 0000000..7a0e00e --- /dev/null +++ b/ios/App/WebTermTests/SessionActivityBridgeTests.swift @@ -0,0 +1,164 @@ +import Foundation +import HostRegistry +import SessionCore +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-15 · Session-activity bridge tests: the third fan-out branch that +/// (a) persists the server-adopted sessionId per host (`LastSessionStore`, +/// the "继续上次" cold-start source), +/// (b) forwards gate pending↔lifted into +/// `SessionListViewModel.setPendingApproval` (the ⚠ overlay hook the list +/// VM header documents), and +/// (c) on exit clears both — a dead session must not stay ⚠ nor be offered +/// as "继续上次". +@MainActor +@Suite("SessionActivityBridge") +struct SessionActivityBridgeTests { + // MARK: - Test doubles + + /// Thread-safe in-memory `LastSessionStore` that records every write. + final class RecordingLastSessionStore: LastSessionStore, @unchecked Sendable { + private let lock = NSLock() + private var storage: [UUID: UUID] = [:] + private var writes: [(id: UUID?, host: UUID)] = [] + + func lastSessionId(host: UUID) -> UUID? { + lock.lock() + defer { lock.unlock() } + return storage[host] + } + + func setLastSessionId(_ id: UUID?, host: UUID) { + lock.lock() + defer { lock.unlock() } + storage[host] = id + writes = writes + [(id, host)] + } + + var writeCount: Int { + lock.lock() + defer { lock.unlock() } + return writes.count + } + } + + // MARK: - Harness + + @MainActor + final class Harness { + let hostId = UUID() + let store = RecordingLastSessionStore() + let continuation: AsyncStream.Continuation + let bridge: SessionActivityBridge + private(set) var pendingChanges: [(sessionId: UUID, pending: Bool)] = [] + + init() { + let (stream, continuation) = AsyncStream.makeStream() + self.continuation = continuation + var recorder: (@MainActor (UUID, Bool) -> Void)! + let bridge = SessionActivityBridge( + events: stream, hostId: hostId, lastSessionStore: store, + onPendingChanged: { sessionId, pending in + recorder(sessionId, pending) + } + ) + self.bridge = bridge + recorder = { [weak self] sessionId, pending in + self?.pendingChanges.append((sessionId, pending)) + } + bridge.start() + } + } + + private static func toolGate(epoch: Int) -> GateState { + GateState(kind: .tool, detail: "Bash", epoch: epoch) + } + + // MARK: - (a) adopted → persisted last session + + @Test("adopted → 记录 adoptedSessionId 并按 host 持久化 lastSessionId") + func adoptedPersistsLastSession() async { + let harness = Harness() + let sessionId = UUID() + + harness.continuation.yield(.adopted(sessionId: sessionId)) + await harness.bridge.waitUntilProcessed(eventCount: 1) + + #expect(harness.bridge.adoptedSessionId == sessionId) + #expect(harness.store.lastSessionId(host: harness.hostId) == sessionId) + } + + @Test("重连拿到新 id → 采用并覆盖持久化(永远采用服务器回发的 id)") + func readoptionOverwritesPersistedId() async { + let harness = Harness() + let first = UUID() + let second = UUID() + + harness.continuation.yield(.adopted(sessionId: first)) + harness.continuation.yield(.adopted(sessionId: second)) + await harness.bridge.waitUntilProcessed(eventCount: 2) + + #expect(harness.bridge.adoptedSessionId == second) + #expect(harness.store.lastSessionId(host: harness.hostId) == second) + } + + // MARK: - (b) gate → pending overlay forwarding + + @Test("gate 上升/解除 → setPendingApproval(true)/(false) 带 adopted 的 sessionId") + func gateForwardsPendingWithAdoptedId() async { + let harness = Harness() + let sessionId = UUID() + + harness.continuation.yield(.adopted(sessionId: sessionId)) + harness.continuation.yield(.gate(Self.toolGate(epoch: 1))) + harness.continuation.yield(.gate(nil)) + await harness.bridge.waitUntilProcessed(eventCount: 3) + + #expect(harness.pendingChanges.count == 2) + #expect(harness.pendingChanges[0].sessionId == sessionId) + #expect(harness.pendingChanges[0].pending == true) + #expect(harness.pendingChanges[1].sessionId == sessionId) + #expect(harness.pendingChanges[1].pending == false) + } + + @Test("adopted 之前到达的 gate → 不转发(没有可关联的 sessionId)") + func gateBeforeAdoptionIsDropped() async { + let harness = Harness() + + harness.continuation.yield(.gate(Self.toolGate(epoch: 1))) + await harness.bridge.waitUntilProcessed(eventCount: 1) + + #expect(harness.pendingChanges.isEmpty) + } + + // MARK: - (c) exited → clear overlay + last session + + @Test("exited → 清 ⚠ overlay 且清掉 lastSessionId(死会话不再被『继续上次』指向)") + func exitClearsOverlayAndLastSession() async { + let harness = Harness() + let sessionId = UUID() + + harness.continuation.yield(.adopted(sessionId: sessionId)) + harness.continuation.yield(.gate(Self.toolGate(epoch: 1))) + harness.continuation.yield(.exited(code: 0, reason: nil)) + await harness.bridge.waitUntilProcessed(eventCount: 3) + + #expect(harness.pendingChanges.last?.pending == false) + #expect(harness.pendingChanges.last?.sessionId == sessionId) + #expect(harness.store.lastSessionId(host: harness.hostId) == nil) + } + + @Test("output/telemetry/connection 与本桥无关 → 不写 store、不发 overlay") + func unrelatedEventsAreIgnored() async { + let harness = Harness() + + harness.continuation.yield(.output("hello")) + harness.continuation.yield(.connection(.connected)) + await harness.bridge.waitUntilProcessed(eventCount: 2) + + #expect(harness.pendingChanges.isEmpty) + #expect(harness.store.writeCount == 0) + } +} diff --git a/ios/App/WebTermTests/SimServerHarness.swift b/ios/App/WebTermTests/SimServerHarness.swift new file mode 100644 index 0000000..5e42972 --- /dev/null +++ b/ios/App/WebTermTests/SimServerHarness.swift @@ -0,0 +1,246 @@ +// +// SimServerHarness.swift — T-iOS-15 hosted live-server smoke 的基础设施。 +// +// WebTermTests 跑在【模拟器内的签名 App 宿主】里:iOS SDK 没有 +// Foundation.Process,但模拟器进程本质是宿主 mac 上的原生进程 —— +// `posix_spawn`(iOS 2.0+ 可用,见 SDK spawn.h)在模拟器上直接工作, +// 模拟器也能直达宿主 loopback(App 的 ATS 127.0.0.0/8 例外已在 project.yml)。 +// +// 生命周期与 ios/IntegrationTests/ServerHarness.swift 同思路(death-pipe): +// 只 spawn 一个 /bin/sh,它把真服务器(node_modules/.bin/tsx src/server.ts) +// 拉起为后台子进程后阻塞在 `read`(stdin = 本测试进程持有写端的管道)。 +// 测试进程无论以何种方式退出,写端被内核关闭 → `read` 得 EOF → sh `kill` 服务器。 +// 无孤儿、无需 atexit。 +// +// 运行模式(同 ServerHarness 的 A/B 两式): +// A. 自举(默认):repo 不在 TCC 保护目录时,测试进程内 posix_spawn 起真服务器。 +// B. 外部服务器:TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1: +// (TEST_RUNNER_ 前缀是 xcodebuild 向测试进程透传 env 的官方通道)。 +// repo 根可用 TEST_RUNNER_WEBTERM_REPO_ROOT 覆盖(默认由 #filePath 推导)。 +// +// TCC 硬墙(T-iOS-15 实测,2026-07-05):repo 位于 ~/Documents|Desktop|Downloads +// 时模式 A 必挂——模拟器 App 语境 spawn 的 node 打开该目录下文件会被 tccd 无限 +// 阻塞(sample 实测主线程卡 uv_cwd → __open_nocancel;sh 内 head 同样挂起; +// /bin/sh、/bin/date 等平台二进制不受影响)。此时 harness 快速失败并给出模式 B +// 的可操作指引,绝不无声挂 60 秒。CI checkout(如 /Users/runner/work)不受影响。 + +import Darwin +import Foundation +import WireProtocol + +enum SmokeTunables { + static let serverReadyTimeout: Duration = .seconds(60) + static let serverReadyPollInterval: Duration = .milliseconds(200) + /// bash 冷启动 + echo 往返的总预算。 + static let outputTimeout: Duration = .seconds(45) + /// tsx shebang 是 `#!/usr/bin/env node` —— sh 命令里显式前置常见 node 安装位。 + static let spawnPathPrefix = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin" +} + +enum SmokeHarnessError: Error, CustomStringConvertible { + case setup(String) + case timeout(String) + + var description: String { + switch self { + case .setup(let detail): return "setup: \(detail)" + case .timeout(let detail): return "timeout: \(detail)" + } + } +} + +/// spawn 一次、全部用例复用(缓存 in-flight Task,避免 actor 可重入期间重复 spawn)。 +actor SimServerHarness { + static let shared = SimServerHarness() + private var bootTask: Task? + /// death-pipe 写端:持有到测试进程退出(内核代为关闭 → 服务器被回收)。 + private var deathPipeWriteFD: Int32? + + func endpoint() async throws -> HostEndpoint { + if let bootTask { return try await bootTask.value } + let task = Task { try await self.bootstrap() } + bootTask = task + return try await task.value + } + + private func bootstrap() async throws -> HostEndpoint { + let env = ProcessInfo.processInfo.environment + if let external = env["WEBTERM_SERVER_URL"] { + guard let url = URL(string: external), let endpoint = HostEndpoint(baseURL: url) else { + throw SmokeHarnessError.setup("WEBTERM_SERVER_URL 不是合法 http(s) URL: \(external)") + } + try await Self.awaitReady(endpoint, logPath: nil) + return endpoint + } + + let repoRoot = Self.locateRepoRoot(environment: env) + if let folder = Self.tccProtectedFolder(containing: repoRoot.path) { + throw SmokeHarnessError.setup(Self.tccGuidance(folder: folder, repoRoot: repoRoot.path)) + } + let tsxPath = repoRoot.appending(path: "node_modules/.bin/tsx").path + guard FileManager.default.isExecutableFile(atPath: tsxPath) else { + throw SmokeHarnessError.setup("找不到 \(tsxPath) — 先在 repo 根目录跑 npm install/npm ci") + } + let port = try Self.findFreeLoopbackPort() + guard let baseURL = URL(string: "http://127.0.0.1:\(port)"), + let endpoint = HostEndpoint(baseURL: baseURL) else { + throw SmokeHarnessError.setup("无法构造 endpoint, port=\(port)") + } + let logPath = FileManager.default.temporaryDirectory + .appending(path: "webterm-hosted-smoke-\(port).log").path + deathPipeWriteFD = try Self.spawnServerShell( + repoRoot: repoRoot.path, port: port, logPath: logPath + ) + try await Self.awaitReady(endpoint, logPath: logPath) + return endpoint + } + + // MARK: - Spawn (posix_spawn + death-pipe) + + /// Returns the pipe WRITE fd the caller must keep open for the server's + /// lifetime (closing it — including by process death — kills the server). + private static func spawnServerShell( + repoRoot: String, port: Int, logPath: String + ) throws -> Int32 { + var pipeFDs: [Int32] = [0, 0] + guard pipe(&pipeFDs) == 0 else { + throw SmokeHarnessError.setup("pipe() 失败: errno \(errno)") + } + let (readFD, writeFD) = (pipeFDs[0], pipeFDs[1]) + + let command = """ + cd '\(repoRoot)' || exit 1 + echo "[harness] sh up $(date +%T)" >> '\(logPath)' + PORT=\(port) BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \ + PATH="\(SmokeTunables.spawnPathPrefix):$PATH" \ + node_modules/.bin/tsx src/server.ts >> '\(logPath)' 2>&1 & + SRV=$! + read _ + echo "[harness] death-pipe EOF $(date +%T) — killing $SRV" >> '\(logPath)' + kill $SRV 2>/dev/null + """ + + var fileActions: posix_spawn_file_actions_t? + posix_spawn_file_actions_init(&fileActions) + defer { posix_spawn_file_actions_destroy(&fileActions) } + posix_spawn_file_actions_adddup2(&fileActions, readFD, 0) // sh 的 stdin = 管道读端 + posix_spawn_file_actions_addclose(&fileActions, readFD) + posix_spawn_file_actions_addclose(&fileActions, writeFD) // 子进程绝不持有写端 + + let argv = ["/bin/sh", "-c", command] + var argvC: [UnsafeMutablePointer?] = argv.map { strdup($0) } + argvC.append(nil) + // MINIMAL clean env — the simulator app's own environment is poison + // for host binaries: its DYLD_ROOT_PATH/DYLD_FALLBACK_* point into the + // iOS simulator runtime. /bin/sh is SIP-protected (dyld ignores DYLD_* + // there), but /usr/local|homebrew node is NOT and dies at dyld stage + // before printing anything. Only PATH + writable HOME/TMPDIR survive. + let inherited = ProcessInfo.processInfo.environment + let cleanEnv = [ + "PATH": SmokeTunables.spawnPathPrefix, + "HOME": inherited["HOME"] ?? NSTemporaryDirectory(), + "TMPDIR": inherited["TMPDIR"] ?? NSTemporaryDirectory(), + ] + var envC: [UnsafeMutablePointer?] = cleanEnv + .map { strdup("\($0.key)=\($0.value)") } + envC.append(nil) + defer { + for pointer in argvC { free(pointer) } + for pointer in envC { free(pointer) } + } + + var pid: pid_t = 0 + let status = posix_spawn(&pid, "/bin/sh", &fileActions, nil, argvC, envC) + close(readFD) // 父进程不需要读端 + guard status == 0 else { + close(writeFD) + throw SmokeHarnessError.setup("posix_spawn(/bin/sh) 失败: \(status)") + } + return writeFD + } + + // MARK: - Readiness + + private static func awaitReady(_ endpoint: HostEndpoint, logPath: String?) async throws { + let probeURL = endpoint.baseURL.appending(path: "live-sessions") + let deadline = ContinuousClock.now + SmokeTunables.serverReadyTimeout + while ContinuousClock.now < deadline { + if let (data, response) = try? await URLSession.shared.data(from: probeURL), + (response as? HTTPURLResponse)?.statusCode == 200, + (try? JSONSerialization.jsonObject(with: data)) is [Any] { + return + } + try await Task.sleep(for: SmokeTunables.serverReadyPollInterval) + } + throw SmokeHarnessError.timeout( + "服务器 \(endpoint.baseURL) 在 \(SmokeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logPath ?? "n/a")" + ) + } + + // MARK: - TCC guard (fail fast, actionable — see header) + + private static let tccProtectedFolders: Set = ["Documents", "Desktop", "Downloads"] + + /// `/Users//(Documents|Desktop|Downloads)/…` → the protected folder + /// name, else nil. Host-path heuristic (the sim's own HOME is a container + /// path, so the host home cannot come from the environment). + static func tccProtectedFolder(containing path: String) -> String? { + let components = URL(fileURLWithPath: path).pathComponents + guard components.count > 3, components[1] == "Users", + tccProtectedFolders.contains(components[3]) else { return nil } + return components[3] + } + + private static func tccGuidance(folder: String, repoRoot: String) -> String { + """ + repo 位于 macOS TCC 保护目录(~/\(folder)):模拟器沙箱内 spawn 的 node \ + 读取该目录会被 tccd 无限阻塞(实测 open() 挂起),自举模式必挂。请在宿主机 \ + 自行起服务器后以外部模式重跑: + cd '\(repoRoot)' && PORT=

BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 npm start & + TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:

xcodebuild … test + """ + } + + // MARK: - Locations + + private static func locateRepoRoot(environment: [String: String]) -> URL { + if let override = environment["WEBTERM_REPO_ROOT"] { + return URL(fileURLWithPath: override, isDirectory: true) + } + // #filePath = /ios/App/WebTermTests/SimServerHarness.swift + return URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // WebTermTests + .deletingLastPathComponent() // App + .deletingLastPathComponent() // ios + .deletingLastPathComponent() // repo root + } + + /// bind(port=0) 拿空闲 loopback 端口(同 IntegrationTests 手法;竞态窗口可忽略)。 + private static func findFreeLoopbackPort() throws -> Int { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw SmokeHarnessError.setup("socket() 失败: errno \(errno)") } + defer { close(fd) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { throw SmokeHarnessError.setup("bind() 失败: errno \(errno)") } + + var assigned = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &assigned) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(fd, $0, &length) + } + } + guard named == 0 else { throw SmokeHarnessError.setup("getsockname() 失败: errno \(errno)") } + return Int(UInt16(bigEndian: assigned.sin_port)) + } +} diff --git a/ios/App/WebTermTests/TerminalViewModelTests.swift b/ios/App/WebTermTests/TerminalViewModelTests.swift index 44b7e99..65c1b0f 100644 --- a/ios/App/WebTermTests/TerminalViewModelTests.swift +++ b/ios/App/WebTermTests/TerminalViewModelTests.swift @@ -277,4 +277,20 @@ struct TerminalViewModelTests { let frames = await harness.transport.sentFramesByConnection[0] #expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))]) } + + @Test("lastSentDims: valid resize is remembered for notifyForegrounded; invalid dims never overwrite a good pair") + func lastSentDimsTracksOnlyValidResizes() async throws { + // Arrange + let harness = try Harness() + #expect(harness.viewModel.lastSentDims == nil) + + // Act: a valid layout pass, then a bogus one (cols=0 fails Validation). + harness.viewModel.sendResize(cols: 120, rows: 40) + harness.viewModel.sendResize(cols: 0, rows: 40) + await harness.viewModel.waitUntilForwarded(sendCount: 2) + + // Assert: the good pair survives — this is what the wiring feeds to + // engine.notifyForegrounded(dims:) on the alive-engine .active hop. + #expect(harness.viewModel.lastSentDims == .init(cols: 120, rows: 40)) + } }