feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut (engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle (.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active, spike screen deleted, cold-start routing Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→ attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral) Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active → notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner) Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode (self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv) Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
This commit is contained in:
@@ -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-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。
|
- **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 集成**。
|
- **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,未提交;纯文档,零代码改动)
|
### ✅ 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 应用。
|
**多 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 应用。
|
||||||
|
|||||||
@@ -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<UInt8>) {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
@@ -43,12 +43,25 @@ final class TerminalViewModel {
|
|||||||
case exited(code: Int, reason: String?)
|
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
|
/// Actionable copy for `.failed(.replayTooLarge)` (plan §3.2 / §3.2.1
|
||||||
/// coupling warning): reconnecting would deterministically fail forever,
|
/// coupling warning): reconnecting would deterministically fail forever,
|
||||||
/// so the user must change a knob, not wait.
|
/// so the user must change a knob, not wait.
|
||||||
static let replayTooLargeMessage =
|
static let replayTooLargeMessage =
|
||||||
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
|
"服务器 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 banner: ConnectionBanner = .none
|
||||||
private(set) var phase: TerminalPhase = .live
|
private(set) var phase: TerminalPhase = .live
|
||||||
/// Server-adopted session id (ALWAYS the server-issued one — persisting it
|
/// 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 —
|
/// 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) {
|
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))
|
enqueueSend(.resize(cols: cols, rows: rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// T-iOS-1 scaffold: empty-window placeholder. Navigation assembly
|
/// T-iOS-15 · App entry: assemble the production dependency graph once and
|
||||||
/// (Pairing → SessionList → Terminal) is wired in T-iOS-15.
|
/// hand it to the coordinator (Pairing → SessionList → Terminal). All wiring
|
||||||
|
/// lives under `Wiring/`; this file stays a thin `@main`.
|
||||||
@main
|
@main
|
||||||
struct WebTermApp: App {
|
struct WebTermApp: App {
|
||||||
|
@State private var coordinator = AppCoordinator(environment: .production())
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
146
ios/App/WebTerm/Wiring/AppCoordinator.swift
Normal file
146
ios/App/WebTerm/Wiring/AppCoordinator.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
ios/App/WebTerm/Wiring/AppEnvironment.swift
Normal file
56
ios/App/WebTerm/Wiring/AppEnvironment.swift
Normal file
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
32
ios/App/WebTerm/Wiring/ColdStartPolicy.swift
Normal file
32
ios/App/WebTerm/Wiring/ColdStartPolicy.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
ios/App/WebTerm/Wiring/EventFanOut.swift
Normal file
53
ios/App/WebTerm/Wiring/EventFanOut.swift
Normal file
@@ -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<Element: Sendable>: Sendable {
|
||||||
|
let branches: [AsyncStream<Element>]
|
||||||
|
private let continuations: [AsyncStream<Element>.Continuation]
|
||||||
|
private let pumpTask: Task<Void, Never>
|
||||||
|
|
||||||
|
init(source: AsyncStream<Element>, branchCount: Int) {
|
||||||
|
var branches: [AsyncStream<Element>] = []
|
||||||
|
var continuations: [AsyncStream<Element>.Continuation] = []
|
||||||
|
for _ in 0..<branchCount {
|
||||||
|
let (stream, continuation) = AsyncStream<Element>.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
ios/App/WebTerm/Wiring/PrivacyShade.swift
Normal file
47
ios/App/WebTerm/Wiring/PrivacyShade.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
126
ios/App/WebTerm/Wiring/RootView.swift
Normal file
126
ios/App/WebTerm/Wiring/RootView.swift
Normal file
@@ -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<Bool> {
|
||||||
|
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 = "继续上次会话"
|
||||||
|
}
|
||||||
129
ios/App/WebTerm/Wiring/SessionActivityBridge.swift
Normal file
129
ios/App/WebTerm/Wiring/SessionActivityBridge.swift
Normal file
@@ -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<SessionEvent>
|
||||||
|
private let hostId: UUID
|
||||||
|
private let lastSessionStore: any LastSessionStore
|
||||||
|
private let onPendingChanged: @MainActor (UUID, Bool) -> Void
|
||||||
|
private var consumeTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
// 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<Void, Never>
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
events: AsyncStream<SessionEvent>,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
ios/App/WebTerm/Wiring/TerminalContainerView.swift
Normal file
103
ios/App/WebTerm/Wiring/TerminalContainerView.swift
Normal file
@@ -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<Bool> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
209
ios/App/WebTerm/Wiring/TerminalSessionController.swift
Normal file
209
ios/App/WebTerm/Wiring/TerminalSessionController.swift
Normal file
@@ -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<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
|
||||||
|
) {
|
||||||
|
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<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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
26
ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift
Normal file
26
ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
ios/App/WebTermTests/ColdStartPolicyTests.swift
Normal file
65
ios/App/WebTermTests/ColdStartPolicyTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
ios/App/WebTermTests/EventFanOutTests.swift
Normal file
79
ios/App/WebTermTests/EventFanOutTests.swift
Normal file
@@ -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<Int>.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<Int>.makeStream()
|
||||||
|
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||||
|
#expect(fanOut.branches.count == Self.branchCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("源结束 → 所有分支结束(消费循环退出,不悬挂)")
|
||||||
|
func sourceFinishFinishesEveryBranch() async {
|
||||||
|
// Arrange
|
||||||
|
let (source, continuation) = AsyncStream<String>.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<Int>.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
|
||||||
|
}
|
||||||
|
}
|
||||||
79
ios/App/WebTermTests/KeychainHostStoreLiveTests.swift
Normal file
79
ios/App/WebTermTests/KeychainHostStoreLiveTests.swift
Normal file
@@ -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() == [])
|
||||||
|
}
|
||||||
|
}
|
||||||
114
ios/App/WebTermTests/LiveServerSmokeTests.swift
Normal file
114
ios/App/WebTermTests/LiveServerSmokeTests.swift
Normal file
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
ios/App/WebTermTests/PrivacyShadeTests.swift
Normal file
29
ios/App/WebTermTests/PrivacyShadeTests.swift
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
164
ios/App/WebTermTests/SessionActivityBridgeTests.swift
Normal file
164
ios/App/WebTermTests/SessionActivityBridgeTests.swift
Normal file
@@ -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<SessionEvent>.Continuation
|
||||||
|
let bridge: SessionActivityBridge
|
||||||
|
private(set) var pendingChanges: [(sessionId: UUID, pending: Bool)] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let (stream, continuation) = AsyncStream<SessionEvent>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
246
ios/App/WebTermTests/SimServerHarness.swift
Normal file
246
ios/App/WebTermTests/SimServerHarness.swift
Normal file
@@ -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:<port>
|
||||||
|
// (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<HostEndpoint, any Error>?
|
||||||
|
/// 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<CChar>?] = 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<CChar>?] = 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<String> = ["Documents", "Desktop", "Downloads"]
|
||||||
|
|
||||||
|
/// `/Users/<name>/(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=<p> 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:<p> 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 = <repo>/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<sockaddr_in>.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<sockaddr_in>.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard bound == 0 else { throw SmokeHarnessError.setup("bind() 失败: errno \(errno)") }
|
||||||
|
|
||||||
|
var assigned = sockaddr_in()
|
||||||
|
var length = socklen_t(MemoryLayout<sockaddr_in>.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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -277,4 +277,20 @@ struct TerminalViewModelTests {
|
|||||||
let frames = await harness.transport.sentFramesByConnection[0]
|
let frames = await harness.transport.sentFramesByConnection[0]
|
||||||
#expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
|
#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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user