From cbaa08dabadb3927bd23edd3dfb541e84b1130f6 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 4 Jul 2026 21:19:30 +0200 Subject: [PATCH] feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles Verify: independent agent re-ran all acceptance — 6/6 PASS --- .github/workflows/ios.yml | 45 ++ docs/PLAN_IOS_CLIENT.md | 6 +- docs/PROGRESS_LOG.md | 10 + ios/.gitignore | 10 + .../WebTerm/Screens/SpikeTerminalScreen.swift | 89 +++ ios/App/WebTerm/WebTermApp.swift | 24 + ios/IntegrationTests/LiveServerTests.swift | 13 + ios/IntegrationTests/OriginSpikeTests.swift | 591 ++++++++++++++++++ ios/IntegrationTests/Package.swift | 21 + ios/Packages/APIClient/Package.swift | 24 + .../APIClient/APIClientPlaceholder.swift | 12 + .../APIClientTests/PackageScaffoldTests.swift | 13 + ios/Packages/HostRegistry/Package.swift | 24 + .../HostRegistryPlaceholder.swift | 12 + .../PackageScaffoldTests.swift | 13 + ios/Packages/SessionCore/Package.swift | 24 + .../SessionCore/SessionCorePlaceholder.swift | 13 + .../PackageScaffoldTests.swift | 13 + ios/Packages/TestSupport/Package.swift | 21 + .../Sources/TestSupport/FakeClock.swift | 176 ++++++ .../TestSupport/FakeHTTPTransport.swift | 118 ++++ .../Sources/TestSupport/FakeTransport.swift | 161 +++++ .../TestSupportTests/FakeClockTests.swift | 32 + .../FakeHTTPTransportTests.swift | 42 ++ .../TestSupportTests/FakeTransportTests.swift | 41 ++ ios/Packages/WireProtocol/Package.swift | 17 + .../Sources/WireProtocol/ClientMessage.swift | 41 ++ .../Sources/WireProtocol/HTTPTransport.swift | 13 + .../Sources/WireProtocol/HostEndpoint.swift | 89 +++ .../Sources/WireProtocol/MessageCodec.swift | 153 +++++ .../Sources/WireProtocol/ServerMessage.swift | 118 ++++ .../Sources/WireProtocol/TermTransport.swift | 30 + .../Sources/WireProtocol/TimelineEvent.swift | 53 ++ .../Sources/WireProtocol/Tunables.swift | 44 ++ .../Sources/WireProtocol/Validation.swift | 31 + .../Sources/WireProtocol/WireConstants.swift | 17 + .../WireProtocolPlaceholder.swift | 11 + .../CodecRoundtripTests.swift | 215 +++++++ .../ContractConstantsTests.swift | 79 +++ .../WireProtocolTests/HostEndpointTests.swift | 126 ++++ .../PackageScaffoldTests.swift | 11 + .../WireProtocolTests/ServerVectorTests.swift | 259 ++++++++ .../WireProtocolTests/ServerViewParser.swift | 110 ++++ .../Tests/WireProtocolTests/TestHelpers.swift | 71 +++ .../TimelineEventTests.swift | 77 +++ ios/project.yml | 93 +++ 46 files changed, 3203 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ios.yml create mode 100644 ios/.gitignore create mode 100644 ios/App/WebTerm/Screens/SpikeTerminalScreen.swift create mode 100644 ios/App/WebTerm/WebTermApp.swift create mode 100644 ios/IntegrationTests/LiveServerTests.swift create mode 100644 ios/IntegrationTests/OriginSpikeTests.swift create mode 100644 ios/IntegrationTests/Package.swift create mode 100644 ios/Packages/APIClient/Package.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/APIClientPlaceholder.swift create mode 100644 ios/Packages/APIClient/Tests/APIClientTests/PackageScaffoldTests.swift create mode 100644 ios/Packages/HostRegistry/Package.swift create mode 100644 ios/Packages/HostRegistry/Sources/HostRegistry/HostRegistryPlaceholder.swift create mode 100644 ios/Packages/HostRegistry/Tests/HostRegistryTests/PackageScaffoldTests.swift create mode 100644 ios/Packages/SessionCore/Package.swift create mode 100644 ios/Packages/SessionCore/Sources/SessionCore/SessionCorePlaceholder.swift create mode 100644 ios/Packages/SessionCore/Tests/SessionCoreTests/PackageScaffoldTests.swift create mode 100644 ios/Packages/TestSupport/Package.swift create mode 100644 ios/Packages/TestSupport/Sources/TestSupport/FakeClock.swift create mode 100644 ios/Packages/TestSupport/Sources/TestSupport/FakeHTTPTransport.swift create mode 100644 ios/Packages/TestSupport/Sources/TestSupport/FakeTransport.swift create mode 100644 ios/Packages/TestSupport/Tests/TestSupportTests/FakeClockTests.swift create mode 100644 ios/Packages/TestSupport/Tests/TestSupportTests/FakeHTTPTransportTests.swift create mode 100644 ios/Packages/TestSupport/Tests/TestSupportTests/FakeTransportTests.swift create mode 100644 ios/Packages/WireProtocol/Package.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/ClientMessage.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/HTTPTransport.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/HostEndpoint.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/MessageCodec.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/ServerMessage.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/TermTransport.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/TimelineEvent.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/Tunables.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/Validation.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/WireConstants.swift create mode 100644 ios/Packages/WireProtocol/Sources/WireProtocol/WireProtocolPlaceholder.swift create mode 100644 ios/Packages/WireProtocol/Tests/WireProtocolTests/CodecRoundtripTests.swift create mode 100644 ios/Packages/WireProtocol/Tests/WireProtocolTests/ContractConstantsTests.swift create mode 100644 ios/Packages/WireProtocol/Tests/WireProtocolTests/HostEndpointTests.swift create mode 100644 ios/Packages/WireProtocol/Tests/WireProtocolTests/PackageScaffoldTests.swift create mode 100644 ios/Packages/WireProtocol/Tests/WireProtocolTests/ServerVectorTests.swift create mode 100644 ios/Packages/WireProtocol/Tests/WireProtocolTests/ServerViewParser.swift create mode 100644 ios/Packages/WireProtocol/Tests/WireProtocolTests/TestHelpers.swift create mode 100644 ios/Packages/WireProtocol/Tests/WireProtocolTests/TimelineEventTests.swift create mode 100644 ios/project.yml diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..220937c --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,45 @@ +# iOS CI skeleton (T-iOS-1). T-iOS-16 hardens this: real Node-server +# integration tests + the per-package 80% coverage gate (PLAN_IOS_CLIENT §9). +name: ios + +on: + push: + paths: + - "ios/**" + - ".github/workflows/ios.yml" + pull_request: + paths: + - "ios/**" + - ".github/workflows/ios.yml" + +jobs: + package-tests: + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + package: [WireProtocol, SessionCore, HostRegistry, APIClient] + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 16.3 + run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer + - name: swift test (${{ matrix.package }}) + run: swift test --package-path ios/Packages/${{ matrix.package }} + + app-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 16.3 + run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer + - name: Install XcodeGen + run: brew install xcodegen + - name: Generate project + run: cd ios && xcodegen generate + - name: Build (iPhone 16 simulator) + run: | + xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + CODE_SIGNING_ALLOWED=NO build + - name: swift test (IntegrationTests scaffold) + run: swift test --package-path ios/IntegrationTests diff --git a/docs/PLAN_IOS_CLIENT.md b/docs/PLAN_IOS_CLIENT.md index e41d042..0e1d4fa 100644 --- a/docs/PLAN_IOS_CLIENT.md +++ b/docs/PLAN_IOS_CLIENT.md @@ -97,7 +97,7 @@ **为什么 SwiftTerm 而不是 WKWebView + xterm.js**:原生渲染、原生手势与选择、软键盘/IME 全走系统栈;`TerminalViewDelegate.send(source:data:)` / `sizeChanged` / `feed(byteArray:)` 与 `input`/`resize`/`output` 帧 1:1。WKWebView 方案两位评审均否决(见 §0 不做)。SwiftTerm 历史上最麻烦的是自绘文本选择与 first-responder——Day-1 spike + 验收里专门压这块。 -**为什么 URLSessionWebSocketTask 而不是 Starscream**:系统框架、iOS 13+ 内建;Starscream 最后一版 4.0.8 已 ~2 年未动,仅作 spike 失败时的备胎。三个已知坑在 P0 直接立规矩:**`maximumMessageSize` 默认 1 MiB,而 ring-buffer 回放是单帧全量(`buffer.snapshot()`,src/session/session.ts:165-171),JSON 会把控制字节 `\uXXXX` 转义膨胀 1–6×(src/protocol.ts:186),最坏 ≈ 6 × SCROLLBACK_BYTES(默认 2 MiB)——必须设 `Tunables.maxWSMessageBytes = 16 MiB`(≥ 6×默认 + 帧包络)**;即便如此 SCROLLBACK_BYTES 是服务器 env 可调、客户端运行时无法得知(协议无 config 握手),**超限时 `receive()` 以 NSPOSIXErrorDomain code 40(ENOBUFS,"Message too long")失败——必须归类为不可重试的 `connection(.failed(.replayTooLarge))` 显式错误态并给可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"),绝不喂进 backoff 重连循环**(否则确定性无限重试);**`receive()` 一次只交付一条消息,必须循环 re-arm**,忘了就静默断流;**没有自动 ping,25 s 定时 `sendPing`**(`PingScheduler`)+ 显式 "reconnecting…" 横幅,终端绝不"看着连着其实死了"。 +**为什么 URLSessionWebSocketTask 而不是 Starscream**:系统框架、iOS 13+ 内建;Starscream 最后一版 4.0.8 已 ~2 年未动,仅作 spike 失败时的备胎。三个已知坑在 P0 直接立规矩:**`maximumMessageSize` 默认 1 MiB,而 ring-buffer 回放是单帧全量(`buffer.snapshot()`,src/session/session.ts:165-171),JSON 会把控制字节 `\uXXXX` 转义膨胀 1–6×(src/protocol.ts:186),最坏 ≈ 6 × SCROLLBACK_BYTES(默认 2 MiB)——必须设 `Tunables.maxWSMessageBytes = 16 MiB`(≥ 6×默认 + 帧包络)**;即便如此 SCROLLBACK_BYTES 是服务器 env 可调、客户端运行时无法得知(协议无 config 握手),**超限时 `receive()` 以 NSPOSIXErrorDomain code 40(EMSGSIZE,"Message too long";T-iOS-2 spike 实测勘误——原文误标 ENOBUFS,Darwin ENOBUFS=55,归类以 40 为主、55 兜底)失败——必须归类为不可重试的 `connection(.failed(.replayTooLarge))` 显式错误态并给可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"),绝不喂进 backoff 重连循环**(否则确定性无限重试);**`receive()` 一次只交付一条消息,必须循环 re-arm**,忘了就静默断流;**没有自动 ping,25 s 定时 `sendPing`**(`PingScheduler`)+ 显式 "reconnecting…" 横幅,终端绝不"看着连着其实死了"。 **为什么 4 个纯 SwiftPM 包 + 薄 App 胶水**:逻辑全部下沉到无 UIKit 依赖的包里(`WireProtocol`/`SessionCore`/`HostRegistry`/`APIClient`),`swift test` 秒级跑、80% 覆盖率门只量"值得量的逻辑";App target 只剩 UIViewRepresentable、导航和 ViewModel 粘合。依赖方向**严格单向向下**,`WireProtocol` 是唯一冻结契约(对应 `src/types.ts` 的地位)——**共享 I/O 边界类型也在这里**(`HostEndpoint`/`TermTransport`/`TransportConnection`/`HTTPTransport`/`TimelineEvent`/`Tunables`,见 §3.1):SessionCore/HostRegistry/APIClient/TestSupport 全部只 import WireProtocol,叶子包之间零耦合,W0 的 TestSupport 就能编译。 @@ -266,7 +266,7 @@ public actor SessionEngine { // 每个"打开中的会话"一 public enum SessionEvent: Sendable, Equatable { case connection(ConnectionState) // .connecting/.connected/.reconnecting(attempt:next:)/.closed // /.failed(FailureReason) —— 不可重试终态(如 .replayTooLarge: - // receive() ENOBUFS/message-too-big,绝不进 backoff 重连,UI 给可操作话术) + // receive() EMSGSIZE(40)/message-too-big,绝不进 backoff 重连,UI 给可操作话术) case adopted(sessionId: UUID) // attached 帧;未知 UUID 会拿到新 id —— 永远采用它 case output(String) // 直接 feed 给 SwiftTerm(@MainActor hop 由 VM 负责) case exited(code: Int, reason: String?) @@ -601,7 +601,7 @@ W5 验收(report-only, 并行) - [ ] `maximumMessageSize == Tunables.maxWSMessageBytes`(16 MiB)——直接断言 task 配置 - [ ] receive 循环持续 re-arm:连发 100 帧全部到达、顺序不乱 - [ ] 服务器关闭(close frame)→ frames stream finish;错误 → stream throw(两种可区分) - - [ ] receive 失败 NSPOSIXErrorDomain code 40(ENOBUFS,"Message too long"——超 `maximumMessageSize` 在 iOS 上**不是** 1009 干净关闭)→ stream throw **类型化 `.replayTooLarge` 错误**(供 engine 识别为不可重试) + - [ ] receive 失败 NSPOSIXErrorDomain code 40(EMSGSIZE,"Message too long"——T-iOS-2 spike 实测;原文 ENOBUFS 为笔误,55(ENOBUFS) 作兜底同判——超 `maximumMessageSize` 在 iOS 上**不是** 1009 干净关闭)→ stream throw **类型化 `.replayTooLarge` 错误**(供 engine 识别为不可重试) - [ ] `close()` 后 send → 显式错误,不 crash - [ ] 非文本(binary)帧 → 丢弃并继续收(服务器只发文本帧,但不信任它) - **Steps(实现, GREEN)**: [ ] `URLSessionWebSocketDelegate`(didOpen/didClose 驱动状态,不靠 receive error 猜)[ ] ping 由 PingScheduler 注入驱动 diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 3e20cc2..b540ae2 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,16 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### 🚧 iOS 客户端 P0 实施(进行中 — 2026-07-04,分支 `feat/ios-client`) +按 PLAN_IOS_CLIENT §8 批次推进;编排 = 每波一个 ultracode `Workflow`(builder 按任务派发 + 独立 verify agent 复跑验收)。 +- **[x] W0 基础(T-iOS-1/2/3/4,5 agents,verify 6/6 PASS)**: + - **T-iOS-1 脚手架**: `ios/` 全结构(project.yml/XcodeGen、5 包空壳、WebTermApp 空窗、`.github/workflows/ios.yml`);Info.plist 经 plutil 核对 = §5.2 逐字(五段 CIDR,**全程零 NSAllowsArbitraryLoads**);bundle id `com.yaojia.webterm`。**偏差**: "default MainActor isolation" 属 Swift 6.2+,本机 6.1 → Swift 6 语言模式 + strict concurrency + 显式 @MainActor 替代(已注 project.yml)。**环境事实**: 本机 Xcode 16.3 原本无 iOS 模拟器 runtime → `xcodebuild -downloadPlatform iOS` 装 iOS 18.4(22E238)后模拟器构建绿。 + - **T-iOS-2 Day-1 双 spike**: **平台事实定音——URLSessionWebSocketTask 可发自定义 Origin(①无 Origin→401 ②精确匹配→101+attached ④端口失配→401),不切 Starscream**;③ 1.5MB 回放在默认 1MiB 上限失败(withKnownIssue 记录)、16MiB 成功,ESC/C0 对抗(转义≈3.6MB)成功。**勘误(已写回 PLAN §1/§3.2/T-iOS-9)**: 超限 errno 实测 NSPOSIXErrorDomain **40=EMSGSIZE**(计划原文误标 ENOBUFS=55),T-iOS-9 归类以 40 为主、55 兜底。ServerHarness 自举真服务器(tsx spawn+death-pipe watchdog,无孤儿进程)。真机 smoke 4 项 DEFERRED(无真机),SpikeTerminalScreen 已编入 App target 待 T-iOS-18。 + - **T-iOS-3 WireProtocol(冻结)**: §3.1 全类型 + Tunables §3.2.1 全表;**59 tests 全绿、覆盖率 100%**(llvm-cov 226/226);300 轮 roundtrip property + 500 轮 fuzz(decodeServer 永不 crash);**跨实现实测**: Swift encoder 输出经 tsx 喂给真 `src/protocol.ts` parseClientMessage → 13 帧+mode 顶层恢复全 PASS。自此冻结。 + - **T-iOS-4 TestSupport**: FakeTransport(actor,scripted failures+按连接记帧)/FakeClock(class+锁,`waitForSleepers` 确定性栅栏,零真实等待)/FakeHTTPTransport(记录 headers 供 Origin-iff-G 断言);3 smoke 全绿。 + - **verify(独立 agent 复跑)**: 模拟器构建 exit 0;59+3 tests;覆盖率 100%;spike 6 tests+1 known issue;`git status` 零越界。 +- 待续: W1(T-iOS-5/6/7/8 并行)→ W2 → W3 → W4 → W5。**偏差(orchestrator 决策)**: W1+ 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + SwiftPM .build 锁自然串行化构建,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。 + ### ✅ iOS 客户端实施计划 — `docs/PLAN_IOS_CLIENT.md`(规划完成 — 2026-07-04,main,未提交;纯文档,零代码改动) **多 agent 编排 = 一个 ultracode `Workflow`(66 agents,7 阶段)**:5 读者并行(wire contract 逐 file:line / 功能矩阵 / relay 现状 / 模板规范 / iOS 平台联网调研)→ 3 架构师独立竞标(full-native / WKWebView-hybrid / phased)→ 3 评委多视角打分(UX/工程/安全,**phased 26.5 胜出**,native 23.5、hybrid 16.5,21 条 mustSteal 嫁接)→ 综合简报 → 写手出稿(843 行)→ **4 评审交叉验证**(对照 src/ 源码逐条核实 / iOS API 联网核查 / 安全 / 计划质量)→ 每条 finding 对抗式 verify(CRITICAL/HIGH 双票)→ fixer 应用。 - **方案**: Phased-Native「口袋驾驶舱」—— SwiftUI + SwiftTerm(MIT/SPM),4 个纯 SwiftPM 包(WireProtocol/SessionCore/HostRegistry/APIClient)+ 薄 App 胶水,`ios/` 顶层新包;前台会话单条活 WS + 其余 HTTP 轮询;iOS 17+/Swift 6/XcodeGen;**P0 服务器零触点**(通知复用既有 ntfy 桥 scripts/setup-hooks.mjs:227-238),P1 仅两处声明触点(APNs sender + `LiveSessionInfo.lastOutputAt`,后者为 TS 任务 T-iOS-37)。relay/E2E 明确推迟(已审计 TS 加密不得随手 Swift 重写)。 diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..fd219fd --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,10 @@ +# Generated by xcodegen — regenerate with: cd ios && xcodegen generate +*.xcodeproj +# XcodeGen-generated Info.plist (keys are declared in project.yml) +App/WebTerm/Resources/Info.plist + +# Xcode / SwiftPM build state +DerivedData/ +xcuserdata/ +.build/ +.swiftpm/ diff --git a/ios/App/WebTerm/Screens/SpikeTerminalScreen.swift b/ios/App/WebTerm/Screens/SpikeTerminalScreen.swift new file mode 100644 index 0000000..282f95a --- /dev/null +++ b/ios/App/WebTerm/Screens/SpikeTerminalScreen.swift @@ -0,0 +1,89 @@ +// +// SpikeTerminalScreen.swift — T-iOS-2 Day-1 spike ②【临时文件,W4/T-iOS-15 删除】 +// +// 目的:SwiftTerm TerminalView 的真机键盘/IME smoke 载体(plan §7 T-iOS-2): +// - 键盘弹出 / first-responder 行为 +// - 中文 IME 组合输入(不自加 keydown 监听,组合事件全交系统栈) +// - inputAccessoryView 上 Esc/Ctrl-C 可发 —— SwiftTerm iOS 端自带 +// TerminalAccessory(含 esc/ctrl/tab 键,iOSAccessoryView.swift), +// spike 直接用它验证;正式 key-bar(逐字节复刻 public/keybar.ts)归 T-iOS-11 +// - 文本选择不崩(SwiftTerm 自绘选择是历史坑点) +// +// 接线为【纯本地回显】(无服务器依赖):typed bytes → send() → feed() 原样喂回, +// CR 扩成 CRLF。真实 SessionEngine 接线是 W2/W3 的事。 +// +// 2026-07-04:无可用真机 —— 真机清单各项记 DEFERRED(真机不可用)(见 PROGRESS_LOG +// 条目)。本文件当下的验收是:作为 App target 源码在模拟器 xcodebuild 构建通过。 + +import SwiftTerm +import SwiftUI + +/// Day-1 spike 的最小 SwiftUI 壳。不接入 WebTermApp 导航(WebTermApp.swift 归 +/// T-iOS-1/T-iOS-15 所有);存在本身即证明 App target 在 Swift 6 strict +/// concurrency 下能链接并承载 SwiftTerm 的 TerminalView。 +struct SpikeTerminalScreen: View { + var body: some View { + LocalEchoTerminal() + .ignoresSafeArea(.container, edges: .bottom) + .navigationTitle("SwiftTerm spike") + } +} + +/// 承载 SwiftTerm UIKit `TerminalView` 的 UIViewRepresentable,接本地回显。 +/// 形状即 T-iOS-11 正式 TerminalScreen 的雏形(去掉 SessionEngine)。 +private struct LocalEchoTerminal: UIViewRepresentable { + func makeCoordinator() -> LocalEchoCoordinator { + LocalEchoCoordinator() + } + + func makeUIView(context: Context) -> TerminalView { + let view = TerminalView(frame: .zero) + view.terminalDelegate = context.coordinator + view.feed(text: SpikeGreeting.text) + return view + } + + func updateUIView(_ uiView: TerminalView, context: Context) { + // 本地回显无外部状态可同步。 + } +} + +private enum SpikeGreeting { + /// 行尾用 CRLF:PTY 语义下裸 LF 只下移一行不回车。 + static let text = "SwiftTerm local-echo spike (T-iOS-2)\r\n" + + "Typed bytes are echoed locally; CR -> CRLF.\r\n" + + "真机清单: 键盘弹出 / 中文 IME / accessory esc·ctrl / 文本选择\r\n\r\n> " +} + +/// 本地回显 delegate:SwiftTerm 要"发给宿主"的每个字节原样喂回终端。 +/// Enter 产生的 CR(0x0D)补一个 LF,光标才会回行首并换行。 +@MainActor +private final class LocalEchoCoordinator: NSObject, @preconcurrency TerminalViewDelegate { + private static let carriageReturn: UInt8 = 0x0D + private static let lineFeed: UInt8 = 0x0A + + func send(source: TerminalView, data: ArraySlice) { + var echoed: [UInt8] = [] + echoed.reserveCapacity(data.count + 1) + for byte in data { + echoed.append(byte) + if byte == Self.carriageReturn { + echoed.append(Self.lineFeed) + } + } + source.feed(byteArray: echoed[...]) + } + + func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {} + func setTerminalTitle(source: TerminalView, title: String) {} + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} + func scrolled(source: TerminalView, position: Double) {} + func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {} + func clipboardCopy(source: TerminalView, content: Data) {} + func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} + // bell / iTermContent 用 SwiftTerm 的默认实现。 +} + +#Preview { + SpikeTerminalScreen() +} diff --git a/ios/App/WebTerm/WebTermApp.swift b/ios/App/WebTerm/WebTermApp.swift new file mode 100644 index 0000000..cb264b3 --- /dev/null +++ b/ios/App/WebTerm/WebTermApp.swift @@ -0,0 +1,24 @@ +import SwiftUI + +/// T-iOS-1 scaffold: empty-window placeholder. Navigation assembly +/// (Pairing → SessionList → Terminal) is wired in T-iOS-15. +@main +struct WebTermApp: App { + var body: some Scene { + WindowGroup { + HelloPlaceholderView() + } + } +} + +private struct HelloPlaceholderView: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "terminal") + .font(.largeTitle) + Text("Hello WebTerm") + .font(.title2) + } + .padding() + } +} diff --git a/ios/IntegrationTests/LiveServerTests.swift b/ios/IntegrationTests/LiveServerTests.swift new file mode 100644 index 0000000..82f4ff5 --- /dev/null +++ b/ios/IntegrationTests/LiveServerTests.swift @@ -0,0 +1,13 @@ +import Testing +import WireProtocol + +/// T-iOS-1 scaffold placeholder — real integration tests against a live Node +/// server land in T-iOS-2 (OriginSpikeTests) and T-iOS-16 (CI harness). +@Test("脚手架:IntegrationTests 包可编译并能 import WireProtocol") +func integrationTestsScaffoldCompiles() { + // Arrange / Act + let contract = WireProtocolPackage.packageName + + // Assert + #expect(contract == "WireProtocol") +} diff --git a/ios/IntegrationTests/OriginSpikeTests.swift b/ios/IntegrationTests/OriginSpikeTests.swift new file mode 100644 index 0000000..71c9078 --- /dev/null +++ b/ios/IntegrationTests/OriginSpikeTests.swift @@ -0,0 +1,591 @@ +// +// OriginSpikeTests.swift — T-iOS-2 Day-1 spike(自动化部分 ①–④) +// +// 对本仓库的【真 Node 服务器】实测 URLSessionWebSocketTask 的两个 make-or-break +// 平台事实(plan §7 T-iOS-2): +// ① 无 Origin 的 WS 升级被 401 拒绝(src/server.ts:646-651,默认拒空 Origin) +// ② 自定义 `Origin` header 能被 URLSessionWebSocketTask 发出并精确匹配白名单 +// → 升级成功、attach(null) 收到 attached(这是 MED 置信度事实的一锤定音) +// ③ ring-buffer 回放是单帧全量(src/session/session.ts attachWs → buffer.snapshot()): +// 默认 maximumMessageSize=1MiB 收不下 >1MiB 回放帧(withKnownIssue 记录复现), +// 设 16 MiB 后成功;对抗变体:ESC/C0 密集输出(JSON \uXXXX 转义膨胀)仍成功 +// ④ 端口失配的 Origin(http://127.0.0.1:9999)→ 401(src/http/origin.ts:47-51 +// 端口精确比对;注意 :443 这类默认端口会被 new URL() 双向规范化,不是失配案例) +// +// 运行方式(可复现): +// A. 默认自举:`swift test --package-path ios/IntegrationTests` +// 自动从本仓库 spawn `node_modules/.bin/tsx src/server.ts`(repo 根由 +// #filePath 推导,可用 WEBTERM_REPO_ROOT 覆盖),绑定 127.0.0.1 的空闲端口, +// SHELL_PATH=/bin/bash、USE_TMUX=0。无需 ALLOWED_ORIGINS:服务器的 +// deriveAllowedOrigins 恒包含 http://127.0.0.1:(src/config.ts:187-226)。 +// 服务器进程经 atexit 回收;日志落在 $TMPDIR/webterm-spike-server-.log。 +// B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:(须为 loopback, +// 这样其自身 origin 天然在白名单内)。 +// +// 注意:本文件**刻意不 import WireProtocol**(T-iOS-3 并行建设中,契约未冻结完成); +// 16 MiB 上限在 SpikeTunables 里本地复刻,正式常量归 WireProtocol.Tunables。 + +import Darwin +import Foundation +import Testing +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// ─── 常量(无魔法数字;正式版见 WireProtocol.Tunables,T-iOS-3) ─────────────── + +private enum SpikeTunables { + /// plan §3.2.1 `Tunables.maxWSMessageBytes` 的本地复刻(不 import 并行包)。 + /// ≥ 6 × 默认 SCROLLBACK_BYTES(2 MiB) + 帧包络。 + static let maxWSMessageBytes = 16 * 1024 * 1024 + /// src/config.ts:41 DEFAULT_WS_PATH。 + static let wsPath = "/term" + /// 灌入的普通输出字节数:> URLSessionWebSocketTask 默认 1 MiB(1_048_576)。 + static let bulkOutputBytes = 1_500_000 + /// 对抗用例:"\u{1B}[0m"(4 字节)重复次数 → 原始 1.6 MB, + /// JSON 转义后 ≈ ×2.25 ≈ 3.6 MB(>1MiB、<16MiB、<2MiB ring 容量)。 + static let escSequenceRepeats = 400_000 + static let escSequenceRawBytes = escSequenceRepeats * 4 + /// spike ④ 的失配端口(若真实端口撞车则退避到 fallback)。 + static let mismatchedOriginPort = 9999 + static let mismatchedOriginPortFallback = 9998 + static let serverReadyTimeout: Duration = .seconds(40) + static let serverReadyPollInterval: Duration = .milliseconds(200) + static let frameTimeout: Duration = .seconds(15) + static let outputAccumulationTimeout: Duration = .seconds(90) +} + +private enum SpikeError: 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)" + } + } +} + +// ─── 服务器端点快照(不可变) ─────────────────────────────────────────────── + +private struct SpikeServer: Sendable { + let baseURL: URL + let origin: String + let wsURL: URL + let port: Int + + /// 显式校验 + 派生(服务器/env 是不可信输入,边界处全验证)。 + static func make(baseURL: URL) throws -> SpikeServer { + guard let scheme = baseURL.scheme, scheme == "http" || scheme == "https" else { + throw SpikeError.setup("server URL 必须是 http(s),got \(baseURL)") + } + guard let host = baseURL.host, !host.isEmpty else { + throw SpikeError.setup("server URL 缺 host: \(baseURL)") + } + guard var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + throw SpikeError.setup("server URL 无法解析: \(baseURL)") + } + comps.scheme = scheme == "https" ? "wss" : "ws" + comps.path = SpikeTunables.wsPath + comps.query = nil + guard let wsURL = comps.url else { + throw SpikeError.setup("无法派生 ws URL: \(baseURL)") + } + // 浏览器式 Origin 序列化:非默认端口显式保留(spike 环境恒为显式端口)。 + let origin = baseURL.port.map { "\(scheme)://\(host):\($0)" } ?? "\(scheme)://\(host)" + let port = baseURL.port ?? (scheme == "https" ? 443 : 80) + return SpikeServer(baseURL: baseURL, origin: origin, wsURL: wsURL, port: port) + } +} + +// ─── 服务器进程回收(death-pipe watchdog + atexit 双保险,防孤儿 tsx) ──────── +// +// 实测:Swift Testing runner 退出时不跑 atexit handler(首轮运行留下孤儿 tsx), +// 所以主回收机制是 death-pipe:spawn 一个 /bin/sh watchdog,其 stdin 是本测试 +// 进程持有的管道写端 —— 测试进程无论以何种方式退出(正常/崩溃),写端关闭 → +// watchdog 的 `read` 收到 EOF → kill 服务器 pid。atexit 仅作快速路径兜底。 + +private enum ServerProcessRegistry { + private static let lock = NSLock() + nonisolated(unsafe) private static var process: Process? + /// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。 + nonisolated(unsafe) private static var deathPipe: Pipe? + nonisolated(unsafe) private static var installedAtexit = false + + static func register(_ p: Process) { + lock.lock() + defer { lock.unlock() } + process = p + deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) + guard !installedAtexit else { return } + installedAtexit = true + atexit { ServerProcessRegistry.killNow() } + } + + static func killNow() { + lock.lock() + defer { lock.unlock() } + if let p = process, p.isRunning { p.terminate() } + process = nil + } + + private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe { + let pipe = Pipe() + let watchdog = Process() + watchdog.executableURL = URL(fileURLWithPath: "/bin/sh") + watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"] + watchdog.standardInput = pipe + watchdog.standardOutput = FileHandle.nullDevice + watchdog.standardError = FileHandle.nullDevice + try watchdog.run() + return pipe + } +} + +// ─── 服务器 harness(actor 单例:spawn 一次,全 suite 复用) ───────────────── + +private actor ServerHarness { + static let shared = ServerHarness() + private var cached: SpikeServer? + + func ensureServer() async throws -> SpikeServer { + if let cached { return cached } + let server: SpikeServer + if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] { + guard let url = URL(string: external) else { + throw SpikeError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)") + } + server = try SpikeServer.make(baseURL: url) + try await awaitReady(server, process: nil, logURL: nil) + } else { + let spawned = try spawnLocalServer() + ServerProcessRegistry.register(spawned.process) + do { + try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL) + } catch { + ServerProcessRegistry.killNow() + throw error + } + server = spawned.server + } + cached = server + return server + } + + private func spawnLocalServer() throws -> (server: SpikeServer, process: Process, logURL: URL) { + let repoRoot = try locateRepoRoot() + let tsx = repoRoot.appending(path: "node_modules/.bin/tsx") + guard FileManager.default.isExecutableFile(atPath: tsx.path) else { + throw SpikeError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install") + } + let port = try findFreeLoopbackPort() + guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else { + throw SpikeError.setup("无法构造 baseURL, port=\(port)") + } + let server = try SpikeServer.make(baseURL: baseURL) + + let logURL = FileManager.default.temporaryDirectory + .appending(path: "webterm-spike-server-\(port).log") + FileManager.default.createFile(atPath: logURL.path, contents: nil) + let logHandle = try FileHandle(forWritingTo: logURL) + + let process = Process() + process.executableURL = tsx + process.arguments = ["src/server.ts"] + process.currentDirectoryURL = repoRoot + var env = ProcessInfo.processInfo.environment + env["PORT"] = String(port) + env["BIND_HOST"] = "127.0.0.1" // 测试期间绝不监听 0.0.0.0 + env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置) + env["USE_TMUX"] = "0" + env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin" + process.environment = env + process.standardOutput = logHandle + process.standardError = logHandle + try process.run() + return (server, process, logURL) + } + + private func awaitReady(_ server: SpikeServer, process: Process?, logURL: URL?) async throws { + let probeURL = server.baseURL.appending(path: "live-sessions") + let deadline = ContinuousClock.now + SpikeTunables.serverReadyTimeout + while ContinuousClock.now < deadline { + if let process, !process.isRunning { + throw SpikeError.setup( + "服务器进程提前退出(exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")") + } + 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: SpikeTunables.serverReadyPollInterval) + } + throw SpikeError.timeout( + "服务器 \(server.baseURL) 在 \(SpikeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")") + } +} + +private func locateRepoRoot() throws -> URL { + if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] { + return URL(fileURLWithPath: override, isDirectory: true) + } + // #filePath = /ios/IntegrationTests/OriginSpikeTests.swift + return URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // IntegrationTests + .deletingLastPathComponent() // ios + .deletingLastPathComponent() // repo root +} + +/// bind(port=0) 拿一个空闲 loopback 端口(close 后交给服务器绑定;竞态窗口可忽略, +/// 若真撞车 awaitReady 会用日志报错)。 +private func findFreeLoopbackPort() throws -> Int { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw SpikeError.setup("socket() 失败: errno \(errno)") } + defer { close(fd) } + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = 0 + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { throw SpikeError.setup("bind() 失败: errno \(errno)") } + + var out = sockaddr_in() + var len = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &out) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) } + } + guard named == 0 else { throw SpikeError.setup("getsockname() 失败: errno \(errno)") } + return Int(UInt16(bigEndian: out.sin_port)) +} + +// ─── WS 客户端薄封装(每测一个;不可变持有 task) ─────────────────────────── + +private final class SpikeWSClient: @unchecked Sendable { + let task: URLSessionWebSocketTask + + /// - Parameters: + /// - origin: nil = 不带 Origin header(spike ①)。 + /// - maxMessageBytes: nil = 保持平台默认 1 MiB(spike ③ 复现分支)。 + init(server: SpikeServer, origin: String?, maxMessageBytes: Int?) { + var request = URLRequest(url: server.wsURL) + request.timeoutInterval = 15 + if let origin { + // 平台事实核心:Origin 不在 reserved-header 列表,setValue 应生效。 + request.setValue(origin, forHTTPHeaderField: "Origin") + } + let task = URLSession.shared.webSocketTask(with: request) + if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes } + self.task = task + task.resume() + } + + /// 握手响应状态码(升级被拒时应为 401;成功时 101)。 + var handshakeStatusCode: Int? { + (task.response as? HTTPURLResponse)?.statusCode + } + + func sendFrame(_ object: [String: Any]) async throws { + let data = try JSONSerialization.data(withJSONObject: object) + guard let text = String(data: data, encoding: .utf8) else { + throw SpikeError.setup("帧序列化非 UTF-8") + } + try await task.send(.string(text)) + } + + /// 收下一条文本帧;binary 帧防御性丢弃(服务器只发文本,但不信任它)。 + func receiveText(timeout: Duration) async throws -> String { + try await withTimeout(timeout) { [self] in + while true { + switch try await task.receive() { + case .string(let text): return text + case .data: continue + @unknown default: continue + } + } + } + } + + /// 升级失败探测:能收到帧/正常等待 → nil;handshake 抛错 → 返回错误。 + func handshakeFailure(timeout: Duration) async -> (any Error)? { + do { + _ = try await receiveText(timeout: timeout) + return nil + } catch { + return error + } + } + + func close() { + task.cancel(with: .normalClosure, reason: nil) + } +} + +private func withTimeout( + _ timeout: Duration, + _ operation: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: timeout) + throw SpikeError.timeout("操作超过 \(timeout)") + } + guard let first = try await group.next() else { + throw SpikeError.timeout("task group 无结果") + } + group.cancelAll() + return first + } +} + +// ─── 帧解码(镜像服务器"非法帧静默丢弃"的韧性,src/protocol.ts:45-86) ──────── + +private struct ServerFrame: Decodable { + let type: String + let data: String? + let sessionId: String? + let code: Int? +} + +private func decodeFrame(_ text: String) -> ServerFrame? { + guard let data = text.data(using: .utf8) else { return nil } + // 解码失败 → nil → 调用方跳过该帧(不 crash、不猜),与服务器同语义。 + return try? JSONDecoder().decode(ServerFrame.self, from: data) +} + +// ─── 协议流程助手 ─────────────────────────────────────────────────────────── + +/// attach 帧:sessionId 键必须显式存在(null 也要显式,src/protocol.ts:132-134)。 +private func attachFrame(sessionId: String?) -> [String: Any] { + ["type": "attach", "sessionId": sessionId ?? NSNull()] +} + +/// input 帧:Enter 是 \r(0x0D),不是 \n(CLAUDE.md gotcha)。 +private func inputFrame(command: String) -> [String: Any] { + ["type": "input", "data": command + "\r"] +} + +/// attach 后等 attached 帧;容忍先到的 output/status(attachWs 在 attached +/// 之前就回放 snapshot,src/session/session.ts:158-170 / src/server.ts:721-733)。 +private func attachAndAwaitAttached(client: SpikeWSClient, sessionId: String?) async throws -> String { + try await client.sendFrame(attachFrame(sessionId: sessionId)) + let deadline = ContinuousClock.now + SpikeTunables.frameTimeout + while ContinuousClock.now < deadline { + let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout) + guard let frame = decodeFrame(text) else { continue } + if frame.type == "attached", let id = frame.sessionId { return id } + } + throw SpikeError.timeout("attach 后未收到 attached 帧") +} + +/// 累计 output 帧的原始字节数(UTF-8),直到 ≥ minBytes。 +private func accumulateOutput(client: SpikeWSClient, minBytes: Int) async throws -> Int { + var total = 0 + let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout + while ContinuousClock.now < deadline { + let text = try await client.receiveText(timeout: SpikeTunables.outputAccumulationTimeout) + guard let frame = decodeFrame(text), frame.type == "output" else { continue } + total += (frame.data ?? "").utf8.count + if total >= minBytes { return total } + } + throw SpikeError.timeout("输出累计 \(total)/\(minBytes) 字节后超时") +} + +private struct ReplaySummary: Sendable { + let adoptedSessionId: String + let totalOutputBytes: Int + let containsSoftReset: Bool +} + +/// reattach 并接收全量回放。成功条件:收到 attached 且 output ≥ minBytes。 +private func replayResult( + client: SpikeWSClient, sessionId: String, minBytes: Int +) async -> Result { + do { + try await client.sendFrame(attachFrame(sessionId: sessionId)) + var adopted: String? + var total = 0 + var sawSoftReset = false + let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout + while ContinuousClock.now < deadline { + let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout) + guard let frame = decodeFrame(text) else { continue } + switch frame.type { + case "attached": + adopted = frame.sessionId + case "output": + let data = frame.data ?? "" + total += data.utf8.count + // 回放前缀 soft-reset \x1b[0m(src/types.ts:167-170) + if !sawSoftReset, data.contains("\u{1B}[0m") { sawSoftReset = true } + default: + break + } + if let adopted, total >= minBytes { + return .success(ReplaySummary( + adoptedSessionId: adopted, totalOutputBytes: total, containsSoftReset: sawSoftReset)) + } + } + return .failure(SpikeError.timeout( + "回放不完整: attached=\(adopted ?? "无") bytes=\(total)/\(minBytes)")) + } catch { + return .failure(error) + } +} + +/// 超 maximumMessageSize 的失败模式:NSPOSIXErrorDomain 40(Darwin errno 40 = +/// EMSGSIZE "Message too long";plan §1 记作 "ENOBUFS(40)",Darwin 上 ENOBUFS +/// 实为 55 — 两个码都接受,实测结果记录在返回日志条目里)。 +private func isMessageTooLongError(_ error: any Error) -> Bool { + func matches(_ ns: NSError) -> Bool { + ns.domain == NSPOSIXErrorDomain && (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS)) + } + let ns = error as NSError + if matches(ns) { return true } + if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { return matches(underlying) } + return false +} + +private func describeError(_ error: any Error) -> String { + let ns = error as NSError + let underlying = (ns.userInfo[NSUnderlyingErrorKey] as? NSError) + .map { " underlying=\($0.domain)#\($0.code)" } ?? "" + return "\(ns.domain)#\(ns.code)\(underlying): \(ns.localizedDescription)" +} + +/// 测后清理:DELETE /live-sessions/:id 是 G 类端点,必须带 Origin(403 否则, +/// src/server.ts:332-339)。清理尽力而为——失败只影响宿主残留一个 bash 会话, +/// 不影响断言结果,故显式忽略错误。 +private func killSession(server: SpikeServer, id: String) async { + var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)")) + request.httpMethod = "DELETE" + request.setValue(server.origin, forHTTPHeaderField: "Origin") + _ = try? await URLSession.shared.data(for: request) +} + +// ─── Spike 断言 ①–④ ──────────────────────────────────────────────────────── + +@Suite("T-iOS-2 Day-1 spike:URLSessionWebSocketTask 对真服务器", .serialized, .timeLimit(.minutes(5))) +struct OriginSpikeTests { + + @Test("spike①: 无 Origin 的 WS 升级被 401 拒绝(服务器默认拒空 Origin)") + func upgradeWithoutOriginIsRejectedWith401() async throws { + let server = try await ServerHarness.shared.ensureServer() + let client = SpikeWSClient(server: server, origin: nil, maxMessageBytes: nil) + defer { client.close() } + + let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout) + + #expect(failure != nil, "无 Origin 升级必须失败") + #expect(client.handshakeStatusCode == 401, + "应收 401(src/server.ts:646-651),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")") + } + + @Test("spike②: 自定义 Origin 精确匹配 → 升级成功,attach(null) 收到 attached") + func customOriginHeaderIsSentAndAttachSucceeds() async throws { + let server = try await ServerHarness.shared.ensureServer() + let client = SpikeWSClient( + server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) + defer { client.close() } + + let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil) + + #expect(UUID(uuidString: sessionId) != nil, "attached.sessionId 应为 UUID,got \(sessionId)") + #expect(client.handshakeStatusCode == 101, + "升级应 101,实际 \(String(describing: client.handshakeStatusCode))") + client.close() + await killSession(server: server, id: sessionId) + } + + @Test("spike③: >1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);16MiB 成功") + func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws { + let server = try await ServerHarness.shared.ensureServer() + + // 造数据:attach(null) 后灌 >1MiB 普通输出,再 detach(PTY/ring 存活)。 + let generator = SpikeWSClient( + server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) + let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil) + try await generator.sendFrame( + inputFrame(command: "perl -e 'print \"a\" x \(SpikeTunables.bulkOutputBytes)'")) + let produced = try await accumulateOutput( + client: generator, minBytes: SpikeTunables.bulkOutputBytes) + #expect(produced >= SpikeTunables.bulkOutputBytes) + generator.close() + + // 复现分支:平台默认 maximumMessageSize(1 MiB) → 回放必失败。 + let defaultClient = SpikeWSClient(server: server, origin: server.origin, maxMessageBytes: nil) + let defaultResult = await replayResult( + client: defaultClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes) + defaultClient.close() + if case .failure(let error) = defaultResult { + #expect(isMessageTooLongError(error), + "失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))") + } + withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") { + _ = try defaultResult.get() + } + + // 修复分支:16 MiB → 全量回放成功,attached 采用同一 sessionId。 + let bigClient = SpikeWSClient( + server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) + let summary = try await replayResult( + client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes).get() + #expect(summary.adoptedSessionId == sessionId) + #expect(summary.totalOutputBytes >= SpikeTunables.bulkOutputBytes) + bigClient.close() + await killSession(server: server, id: sessionId) + } + + @Test("spike③-对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍成功") + func escDenseReplayStillSucceedsAt16MiB() async throws { + let server = try await ServerHarness.shared.ensureServer() + + let generator = SpikeWSClient( + server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) + let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil) + // perl 的 \e = ESC(0x1B):每 4 原始字节 JSON 转义成 9 字节("")。 + try await generator.sendFrame( + inputFrame(command: "perl -e 'print \"\\e[0m\" x \(SpikeTunables.escSequenceRepeats)'")) + let produced = try await accumulateOutput( + client: generator, minBytes: SpikeTunables.escSequenceRawBytes) + #expect(produced >= SpikeTunables.escSequenceRawBytes) + generator.close() + + let bigClient = SpikeWSClient( + server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) + let summary = try await replayResult( + client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.escSequenceRawBytes).get() + #expect(summary.adoptedSessionId == sessionId) + #expect(summary.totalOutputBytes >= SpikeTunables.escSequenceRawBytes) + #expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)") + bigClient.close() + await killSession(server: server, id: sessionId) + } + + @Test("spike④: 端口失配的 Origin → 401(端口精确比对;默认端口规范化不是失配案例)") + func portMismatchedOriginIsRejectedWith401() async throws { + let server = try await ServerHarness.shared.ensureServer() + let mismatchPort = server.port == SpikeTunables.mismatchedOriginPort + ? SpikeTunables.mismatchedOriginPortFallback + : SpikeTunables.mismatchedOriginPort + // 注意::443/:80 这类 scheme 默认端口会被两侧 new URL() 对称规范化 + // (src/http/origin.ts:31-51),不构成失配;本测只断言显式端口失配。 + let badOrigin = "http://127.0.0.1:\(mismatchPort)" + let client = SpikeWSClient(server: server, origin: badOrigin, maxMessageBytes: nil) + defer { client.close() } + + let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout) + + #expect(failure != nil, "端口失配 Origin 升级必须失败") + #expect(client.handshakeStatusCode == 401, + "应收 401(src/http/origin.ts:47-51),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")") + } +} diff --git a/ios/IntegrationTests/Package.swift b/ios/IntegrationTests/Package.swift new file mode 100644 index 0000000..639df34 --- /dev/null +++ b/ios/IntegrationTests/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.0 +// T-iOS-1 scaffold shell. Real live-server tests land in T-iOS-2 +// (OriginSpikeTests, against `npm start`) and T-iOS-16 (CI integration). +// Flat layout (plan §2): test sources live directly in ios/IntegrationTests/. +import PackageDescription + +let package = Package( + name: "IntegrationTests", + platforms: [.iOS(.v17), .macOS(.v14)], + dependencies: [ + .package(path: "../Packages/WireProtocol"), + ], + targets: [ + .testTarget( + name: "IntegrationTests", + dependencies: [.product(name: "WireProtocol", package: "WireProtocol")], + path: "." + ), + ], + swiftLanguageModes: [.v6] +) diff --git a/ios/Packages/APIClient/Package.swift b/ios/Packages/APIClient/Package.swift new file mode 100644 index 0000000..3bd4708 --- /dev/null +++ b/ios/Packages/APIClient/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.0 +// T-iOS-1 scaffold shell. APIClient / Endpoints / Models / PairingProbe / +// PairingError land in T-iOS-8 (P1 increments via T-iOS-38, single owner). +// Dependency direction is strictly downward: APIClient → WireProtocol only. +import PackageDescription + +let package = Package( + name: "APIClient", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [ + .library(name: "APIClient", targets: ["APIClient"]), + ], + dependencies: [ + .package(path: "../WireProtocol"), + ], + targets: [ + .target( + name: "APIClient", + dependencies: [.product(name: "WireProtocol", package: "WireProtocol")] + ), + .testTarget(name: "APIClientTests", dependencies: ["APIClient"]), + ], + swiftLanguageModes: [.v6] +) diff --git a/ios/Packages/APIClient/Sources/APIClient/APIClientPlaceholder.swift b/ios/Packages/APIClient/Sources/APIClient/APIClientPlaceholder.swift new file mode 100644 index 0000000..38c364c --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/APIClientPlaceholder.swift @@ -0,0 +1,12 @@ +import WireProtocol + +/// T-iOS-1 scaffold placeholder. +/// +/// Real modules land in T-iOS-8: APIClient, Endpoints, Models, PairingProbe, +/// PairingError (Origin iff-G stamping is a frozen security semantic, §3.4/§5.1). +public enum APIClientPackage { + /// Package identity marker consumed by the scaffold smoke test. + public static let packageName = "APIClient" + /// Proves the single allowed dependency edge (APIClient → WireProtocol) compiles. + public static let contractPackageName = WireProtocolPackage.packageName +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/PackageScaffoldTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/PackageScaffoldTests.swift new file mode 100644 index 0000000..d019cfa --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/PackageScaffoldTests.swift @@ -0,0 +1,13 @@ +import Testing +import APIClient + +@Test("脚手架:APIClient 包可编译且依赖边 APIClient→WireProtocol 成立") +func apiClientPackageIsImportableAndDependsOnWireProtocol() { + // Arrange / Act + let name = APIClientPackage.packageName + let contract = APIClientPackage.contractPackageName + + // Assert + #expect(name == "APIClient") + #expect(contract == "WireProtocol") +} diff --git a/ios/Packages/HostRegistry/Package.swift b/ios/Packages/HostRegistry/Package.swift new file mode 100644 index 0000000..c526f1c --- /dev/null +++ b/ios/Packages/HostRegistry/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.0 +// T-iOS-1 scaffold shell. Host / HostStore / KeychainHostStore / SecItemShim / +// InMemoryHostStore / LastSessionStore land in T-iOS-7. +// Dependency direction is strictly downward: HostRegistry → WireProtocol only. +import PackageDescription + +let package = Package( + name: "HostRegistry", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [ + .library(name: "HostRegistry", targets: ["HostRegistry"]), + ], + dependencies: [ + .package(path: "../WireProtocol"), + ], + targets: [ + .target( + name: "HostRegistry", + dependencies: [.product(name: "WireProtocol", package: "WireProtocol")] + ), + .testTarget(name: "HostRegistryTests", dependencies: ["HostRegistry"]), + ], + swiftLanguageModes: [.v6] +) diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/HostRegistryPlaceholder.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/HostRegistryPlaceholder.swift new file mode 100644 index 0000000..7bc57bd --- /dev/null +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/HostRegistryPlaceholder.swift @@ -0,0 +1,12 @@ +import WireProtocol + +/// T-iOS-1 scaffold placeholder. +/// +/// Real modules land in T-iOS-7: Host, HostStore, KeychainHostStore (via +/// SecItemShim seam), InMemoryHostStore, LastSessionStore. +public enum HostRegistryPackage { + /// Package identity marker consumed by the scaffold smoke test. + public static let packageName = "HostRegistry" + /// Proves the single allowed dependency edge (HostRegistry → WireProtocol) compiles. + public static let contractPackageName = WireProtocolPackage.packageName +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/PackageScaffoldTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/PackageScaffoldTests.swift new file mode 100644 index 0000000..ed7a6c4 --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/PackageScaffoldTests.swift @@ -0,0 +1,13 @@ +import Testing +import HostRegistry + +@Test("脚手架:HostRegistry 包可编译且依赖边 HostRegistry→WireProtocol 成立") +func hostRegistryPackageIsImportableAndDependsOnWireProtocol() { + // Arrange / Act + let name = HostRegistryPackage.packageName + let contract = HostRegistryPackage.contractPackageName + + // Assert + #expect(name == "HostRegistry") + #expect(contract == "WireProtocol") +} diff --git a/ios/Packages/SessionCore/Package.swift b/ios/Packages/SessionCore/Package.swift new file mode 100644 index 0000000..166e47d --- /dev/null +++ b/ios/Packages/SessionCore/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.0 +// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler / +// GateState / AwayDigest / URLSessionTermTransport land in W1–W2 tasks. +// Dependency direction is strictly downward: SessionCore → WireProtocol only. +import PackageDescription + +let package = Package( + name: "SessionCore", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [ + .library(name: "SessionCore", targets: ["SessionCore"]), + ], + dependencies: [ + .package(path: "../WireProtocol"), + ], + targets: [ + .target( + name: "SessionCore", + dependencies: [.product(name: "WireProtocol", package: "WireProtocol")] + ), + .testTarget(name: "SessionCoreTests", dependencies: ["SessionCore"]), + ], + swiftLanguageModes: [.v6] +) diff --git a/ios/Packages/SessionCore/Sources/SessionCore/SessionCorePlaceholder.swift b/ios/Packages/SessionCore/Sources/SessionCore/SessionCorePlaceholder.swift new file mode 100644 index 0000000..fd11e51 --- /dev/null +++ b/ios/Packages/SessionCore/Sources/SessionCore/SessionCorePlaceholder.swift @@ -0,0 +1,13 @@ +import WireProtocol + +/// T-iOS-1 scaffold placeholder. +/// +/// Real modules land in their owning tasks: ReconnectMachine + PingScheduler +/// (T-iOS-5), GateState + AwayDigest (T-iOS-6), URLSessionTermTransport +/// (T-iOS-9), SessionEngine + SessionEvent (T-iOS-10), KeyByteMap (T-iOS-11). +public enum SessionCorePackage { + /// Package identity marker consumed by the scaffold smoke test. + public static let packageName = "SessionCore" + /// Proves the single allowed dependency edge (SessionCore → WireProtocol) compiles. + public static let contractPackageName = WireProtocolPackage.packageName +} diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/PackageScaffoldTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/PackageScaffoldTests.swift new file mode 100644 index 0000000..fdeceb6 --- /dev/null +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/PackageScaffoldTests.swift @@ -0,0 +1,13 @@ +import Testing +import SessionCore + +@Test("脚手架:SessionCore 包可编译且依赖边 SessionCore→WireProtocol 成立") +func sessionCorePackageIsImportableAndDependsOnWireProtocol() { + // Arrange / Act + let name = SessionCorePackage.packageName + let contract = SessionCorePackage.contractPackageName + + // Assert + #expect(name == "SessionCore") + #expect(contract == "WireProtocol") +} diff --git a/ios/Packages/TestSupport/Package.swift b/ios/Packages/TestSupport/Package.swift new file mode 100644 index 0000000..023cf8c --- /dev/null +++ b/ios/Packages/TestSupport/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.0 +// T-iOS-4: test doubles leaned on by every W1-W3 task. Depends ONLY on +// WireProtocol (the frozen contract) — never on SessionCore/HostRegistry/ +// APIClient, so it compiles in W0 and leaf packages stay decoupled (plan §6). +import PackageDescription + +let package = Package( + name: "TestSupport", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [ + .library(name: "TestSupport", targets: ["TestSupport"]), + ], + dependencies: [ + .package(path: "../WireProtocol"), + ], + targets: [ + .target(name: "TestSupport", dependencies: ["WireProtocol"]), + .testTarget(name: "TestSupportTests", dependencies: ["TestSupport"]), + ], + swiftLanguageModes: [.v6] +) diff --git a/ios/Packages/TestSupport/Sources/TestSupport/FakeClock.swift b/ios/Packages/TestSupport/Sources/TestSupport/FakeClock.swift new file mode 100644 index 0000000..3e37c62 --- /dev/null +++ b/ios/Packages/TestSupport/Sources/TestSupport/FakeClock.swift @@ -0,0 +1,176 @@ +import Foundation +import os + +/// Manually advanced `Clock` double (T-iOS-4). Drives +/// `ReconnectMachine` / `PingScheduler` / `SessionEngine` tests with ZERO +/// real-time waiting: code under test suspends in `sleep(until:tolerance:)` +/// and only resumes when the test calls `advance(by:)` past its deadline. +/// +/// Deterministic hand-off pattern (no yield-loops, no real sleeps): +/// +/// let task = Task { try await scheduler.run() } // sleeps on this clock +/// await clock.waitForSleepers(count: 1) // sleeper is parked +/// clock.advance(by: Tunables.pingInterval) // fire it +/// +/// Sendable: all state lives behind one `OSAllocatedUnfairLock`; continuations +/// are always resumed OUTSIDE the lock. Cancelling a sleeping task throws +/// `CancellationError` out of `sleep`, matching `ContinuousClock` semantics. +public final class FakeClock: Clock, Sendable { + /// Instant = duration offset from the clock's zero epoch. + public struct Instant: InstantProtocol, Sendable, Hashable, Comparable { + public typealias Duration = Swift.Duration + + public let offset: Swift.Duration + + public init(offset: Swift.Duration = .zero) { + self.offset = offset + } + + public func advanced(by duration: Swift.Duration) -> Instant { + Instant(offset: offset + duration) + } + + public func duration(to other: Instant) -> Swift.Duration { + other.offset - offset + } + + public static func < (lhs: Instant, rhs: Instant) -> Bool { + lhs.offset < rhs.offset + } + } + + private struct Sleeper { + let deadline: Instant + let continuation: CheckedContinuation + } + + private struct Waiter { + let targetCount: Int + let continuation: CheckedContinuation + } + + private struct State { + var now: Instant + var sleepers: [UUID: Sleeper] = [:] + var waiters: [UUID: Waiter] = [:] + } + + private enum SleepRegistration { + case alreadyDue + case cancelled + case sleeping(satisfiedWaiters: [Waiter]) + } + + private let state: OSAllocatedUnfairLock + + public init(now: Instant = Instant()) { + state = OSAllocatedUnfairLock(initialState: State(now: now)) + } + + // MARK: - Clock conformance + + public var now: Instant { + state.withLock { $0.now } + } + + public var minimumResolution: Swift.Duration { .zero } + + public func sleep(until deadline: Instant, tolerance: Swift.Duration? = nil) async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + registerSleeper(id: id, deadline: deadline, continuation: continuation) + } + } onCancel: { + let sleeper = state.withLock { $0.sleepers.removeValue(forKey: id) } + sleeper?.continuation.resume(throwing: CancellationError()) + } + } + + // MARK: - Manual control (test side) + + /// Move time forward and synchronously resume every sleeper whose deadline + /// is now due (in deadline order) — resumed outside the lock. + public func advance(by duration: Swift.Duration) { + precondition(duration >= .zero, "FakeClock cannot move backwards") + let due: [Sleeper] = state.withLock { state in + state.now = state.now.advanced(by: duration) + let dueNow = state.sleepers.filter { $0.value.deadline <= state.now } + for key in dueNow.keys { + state.sleepers.removeValue(forKey: key) + } + return dueNow.values.sorted { $0.deadline < $1.deadline } + } + for sleeper in due { + sleeper.continuation.resume() + } + } + + /// Number of tasks currently parked in `sleep` — for test assertions. + public var pendingSleeperCount: Int { + state.withLock { $0.sleepers.count } + } + + /// Suspend until at least `count` sleepers are parked. This is the + /// deterministic "task reached its sleep" barrier — never poll or + /// real-sleep to wait for the code under test. Resumes immediately if + /// already satisfied; on task cancellation it resumes without error. + public func waitForSleepers(count: Int = 1) async { + let id = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + registerWaiter(id: id, targetCount: count, continuation: continuation) + } + } onCancel: { + let waiter = state.withLock { $0.waiters.removeValue(forKey: id) } + waiter?.continuation.resume() + } + } + + // MARK: - Internals (registration under the lock, resumption outside it) + + private func registerSleeper( + id: UUID, + deadline: Instant, + continuation: CheckedContinuation + ) { + let registration: SleepRegistration = state.withLock { state in + if Task.isCancelled { return .cancelled } + guard deadline > state.now else { return .alreadyDue } + state.sleepers[id] = Sleeper(deadline: deadline, continuation: continuation) + return .sleeping(satisfiedWaiters: Self.takeSatisfiedWaiters(&state)) + } + switch registration { + case .alreadyDue: + continuation.resume() + case .cancelled: + continuation.resume(throwing: CancellationError()) + case .sleeping(let satisfiedWaiters): + for waiter in satisfiedWaiters { + waiter.continuation.resume() + } + } + } + + private func registerWaiter( + id: UUID, + targetCount: Int, + continuation: CheckedContinuation + ) { + let isAlreadySatisfied: Bool = state.withLock { state in + if Task.isCancelled || state.sleepers.count >= targetCount { return true } + state.waiters[id] = Waiter(targetCount: targetCount, continuation: continuation) + return false + } + if isAlreadySatisfied { + continuation.resume() + } + } + + private static func takeSatisfiedWaiters(_ state: inout State) -> [Waiter] { + let satisfiedKeys = state.waiters + .filter { $0.value.targetCount <= state.sleepers.count } + .map { $0.key } + return satisfiedKeys.compactMap { state.waiters.removeValue(forKey: $0) } + } +} diff --git a/ios/Packages/TestSupport/Sources/TestSupport/FakeHTTPTransport.swift b/ios/Packages/TestSupport/Sources/TestSupport/FakeHTTPTransport.swift new file mode 100644 index 0000000..4db1900 --- /dev/null +++ b/ios/Packages/TestSupport/Sources/TestSupport/FakeHTTPTransport.swift @@ -0,0 +1,118 @@ +import Foundation +import WireProtocol + +/// Errors `FakeHTTPTransport` produces on its own (Equatable for exact +/// `#expect(throws:)` assertions). +public enum FakeHTTPTransportError: Error, Equatable, Sendable { + /// `send` was called for a URL/method with no queued response — the test + /// forgot to script it. Loud and identifying, never a silent hang. + case noQueuedResponse(method: String, url: URL) + /// The request had no URL at all (malformed test input). + case requestMissingURL + /// `HTTPURLResponse` refused the scripted status/headers (should not + /// happen with sane inputs; surfaced explicitly rather than force-unwrapped). + case responseConstructionFailed(url: URL) +} + +/// In-memory `HTTPTransport` double (T-iOS-4). `APIClient` cannot tell it +/// apart from the `URLSession`-backed implementation (plan §3.4). +/// +/// - **Queue responses per URL/method** (FIFO per route): `queueSuccess` / +/// `queueFailure`. An unqueued route throws `.noQueuedResponse` immediately. +/// - **Record requests verbatim, headers included** — so Origin-iff-G tests +/// (plan §3.4 铁律) can assert exactly which requests carried `Origin`. +public actor FakeHTTPTransport: HTTPTransport { + /// Route identity: HTTP method (uppercased) + exact URL. + public struct RouteKey: Hashable, Sendable { + public let method: String + public let url: URL + + public init(method: String, url: URL) { + self.method = method.uppercased() + self.url = url + } + } + + private enum QueuedResult { + case success(status: Int, headers: [String: String], body: Data) + case failure(any Error) + } + + /// Default HTTP method for queueing/matching when a request omits one. + public static let defaultMethod = "GET" + /// Default scripted success status. + public static let defaultOKStatus = 200 + private static let httpVersion = "HTTP/1.1" + + private var queues: [RouteKey: [QueuedResult]] = [:] + + /// Every request passed to `send`, in order, verbatim (URL, method, + /// headers, body) — including ones that found no queued response. + public private(set) var recordedRequests: [URLRequest] = [] + + public init() {} + + // MARK: - Scripting + + /// Queue one successful response for `method url` (FIFO per route). + public func queueSuccess( + method: String = FakeHTTPTransport.defaultMethod, + url: URL, + status: Int = FakeHTTPTransport.defaultOKStatus, + headers: [String: String] = [:], + body: Data = Data() + ) { + enqueue(.success(status: status, headers: headers, body: body), + for: RouteKey(method: method, url: url)) + } + + /// Queue one transport-level failure for `method url` (e.g. connection + /// refused → `PairingError.hostUnreachable` classification tests). + public func queueFailure( + method: String = FakeHTTPTransport.defaultMethod, + url: URL, + error: any Error + ) { + enqueue(.failure(error), for: RouteKey(method: method, url: url)) + } + + // MARK: - HTTPTransport + + public func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + recordedRequests.append(request) + guard let url = request.url else { + throw FakeHTTPTransportError.requestMissingURL + } + let key = RouteKey(method: request.httpMethod ?? Self.defaultMethod, url: url) + guard var queue = queues[key], !queue.isEmpty else { + throw FakeHTTPTransportError.noQueuedResponse(method: key.method, url: url) + } + let next = queue.removeFirst() + queues[key] = queue + switch next { + case .failure(let error): + throw error + case .success(let status, let headers, let body): + return (body, try Self.makeResponse(url: url, status: status, headers: headers)) + } + } + + // MARK: - Internals + + private func enqueue(_ result: QueuedResult, for key: RouteKey) { + queues[key, default: []].append(result) + } + + private static func makeResponse( + url: URL, + status: Int, + headers: [String: String] + ) throws -> HTTPURLResponse { + guard let response = HTTPURLResponse( + url: url, statusCode: status, httpVersion: httpVersion, headerFields: headers + ) else { + throw FakeHTTPTransportError.responseConstructionFailed(url: url) + } + return response + } +} diff --git a/ios/Packages/TestSupport/Sources/TestSupport/FakeTransport.swift b/ios/Packages/TestSupport/Sources/TestSupport/FakeTransport.swift new file mode 100644 index 0000000..df5fab6 --- /dev/null +++ b/ios/Packages/TestSupport/Sources/TestSupport/FakeTransport.swift @@ -0,0 +1,161 @@ +import WireProtocol + +/// Errors `FakeTransport` produces on its own (Equatable so tests can +/// `#expect(throws:)` on exact values). +public enum FakeTransportError: Error, Equatable, Sendable { + /// Default error thrown by a scripted connect failure. + case scriptedConnectFailure + /// `send` was called on a connection that already terminated + /// (client `close()`, server finish, or server error). + case sendAfterClose +} + +/// In-memory `TermTransport` double (T-iOS-4). `SessionEngine` cannot tell it +/// apart from `URLSessionTermTransport` — same frozen contract (plan §3.2). +/// +/// Capabilities: +/// - **Scripted connect failures**: `scriptConnectFailure(_:)` queues errors +/// thrown by subsequent `connect` calls, FIFO (reconnect/backoff tests). +/// - **Manual frame injection**: `emit(frame:)` / `emitError(_:)` / +/// `finishFrames()` drive the latest connection's `frames` stream. With no +/// live connection the event is queued and flushed, in order, into the next +/// successful `connect` — so tests can script server behavior up front +/// (mirrors attach→replay) and nothing is ever silently dropped. +/// - **Recording**: every connect attempt (including scripted failures), every +/// sent frame (per connection and flattened), and every `close()` call. +/// +/// Async-safe by construction (actor), Sendable, zero real-time sleeps: +/// injected frames buffer unboundedly until the consumer iterates. +public actor FakeTransport: TermTransport { + private enum ServerEvent { + case frame(String) + case failure(any Error) + case finish + } + + private struct ConnectionState { + let continuation: AsyncThrowingStream.Continuation + var sentFrames: [String] = [] + var isTerminated = false + } + + private var scriptedConnectFailures: [any Error] = [] + private var pendingEvents: [ServerEvent] = [] + private var connections: [ConnectionState] = [] + + /// Every endpoint `connect` was called with, in order — scripted failures + /// included (so backoff tests can count attempts). + public private(set) var connectAttempts: [HostEndpoint] = [] + /// Total `close()` calls across all connections (double-close included). + public private(set) var closeCallCount = 0 + + public init() {} + + // MARK: - Scripting + + /// Queue an error for the next `connect` call (FIFO across calls). + public func scriptConnectFailure( + _ error: any Error = FakeTransportError.scriptedConnectFailure + ) { + scriptedConnectFailures.append(error) + } + + // MARK: - TermTransport + + public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection { + connectAttempts.append(endpoint) + if !scriptedConnectFailures.isEmpty { + throw scriptedConnectFailures.removeFirst() + } + let (stream, continuation) = AsyncThrowingStream.makeStream() + let index = connections.count + connections.append(ConnectionState(continuation: continuation)) + flushPendingEvents(into: index) + return TransportConnection( + frames: stream, + send: { [self] frame in try await self.recordSend(frame, connection: index) }, + close: { [self] in await self.recordClose(connection: index) } + ) + } + + // MARK: - Manual injection (server side of the wire) + + /// Yield one server JSON text frame into the latest connection + /// (or queue it for the next connect — see type doc). + public func emit(frame: String) { + deliver(.frame(frame)) + } + + /// Terminate the latest connection's stream with `error` + /// (transport failure — the "stream throw" half of the contract). + public func emitError(_ error: any Error) { + deliver(.failure(error)) + } + + /// Finish the latest connection's stream cleanly + /// (server close — the "stream finish" half of the contract). + public func finishFrames() { + deliver(.finish) + } + + // MARK: - Recorded traffic + + /// All frames sent by the client, flattened in connection order. + public var sentFrames: [String] { connections.flatMap(\.sentFrames) } + + /// Frames sent by the client, grouped per successful connection + /// (reconnect tests assert the re-attach frame landed on connection 1). + public var sentFramesByConnection: [[String]] { connections.map(\.sentFrames) } + + // MARK: - Internals + + private func deliver(_ event: ServerEvent) { + guard let index = liveConnectionIndex() else { + pendingEvents.append(event) + return + } + apply(event, to: index) + } + + private func liveConnectionIndex() -> Int? { + guard let last = connections.indices.last, !connections[last].isTerminated else { + return nil + } + return last + } + + private func apply(_ event: ServerEvent, to index: Int) { + switch event { + case .frame(let frame): + connections[index].continuation.yield(frame) + case .failure(let error): + connections[index].isTerminated = true + connections[index].continuation.finish(throwing: error) + case .finish: + connections[index].isTerminated = true + connections[index].continuation.finish() + } + } + + private func flushPendingEvents(into index: Int) { + let events = pendingEvents + pendingEvents = [] + for event in events { + apply(event, to: index) + } + } + + private func recordSend(_ frame: String, connection index: Int) throws { + guard !connections[index].isTerminated else { + throw FakeTransportError.sendAfterClose + } + connections[index].sentFrames.append(frame) + } + + private func recordClose(connection index: Int) { + closeCallCount += 1 + guard !connections[index].isTerminated else { return } + connections[index].isTerminated = true + connections[index].continuation.finish() + } +} diff --git a/ios/Packages/TestSupport/Tests/TestSupportTests/FakeClockTests.swift b/ios/Packages/TestSupport/Tests/TestSupportTests/FakeClockTests.swift new file mode 100644 index 0000000..2d9ac05 --- /dev/null +++ b/ios/Packages/TestSupport/Tests/TestSupportTests/FakeClockTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import TestSupport + +@Suite("FakeClock") +struct FakeClockTests { + private static let sleepDuration: Duration = .seconds(25) + private static let partialAdvance: Duration = .seconds(10) + private static let remainingAdvance: Duration = .seconds(15) + + @Test("sleeper wakes only after the clock is manually advanced past its deadline") + func sleeperWakesOnlyAfterAdvancePastDeadline() async throws { + // Arrange + let clock = FakeClock() + let deadline = clock.now.advanced(by: Self.sleepDuration) + let sleeper = Task { + try await clock.sleep(until: deadline, tolerance: nil) + } + await clock.waitForSleepers(count: 1) + + // Act: advance short of the deadline — the sleeper must stay asleep. + clock.advance(by: Self.partialAdvance) + #expect(clock.pendingSleeperCount == 1) + + // Act: advance up to the deadline — the sleeper must wake. + clock.advance(by: Self.remainingAdvance) + try await sleeper.value + + // Assert: zero real-time waiting, purely manual time. + #expect(clock.pendingSleeperCount == 0) + #expect(clock.now == FakeClock.Instant(offset: Self.sleepDuration)) + } +} diff --git a/ios/Packages/TestSupport/Tests/TestSupportTests/FakeHTTPTransportTests.swift b/ios/Packages/TestSupport/Tests/TestSupportTests/FakeHTTPTransportTests.swift new file mode 100644 index 0000000..9bde592 --- /dev/null +++ b/ios/Packages/TestSupport/Tests/TestSupportTests/FakeHTTPTransportTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import TestSupport + +@Suite("FakeHTTPTransport") +struct FakeHTTPTransportTests { + private static let originValue = "http://192.168.1.5:3000" + + @Test("replays queued responses per URL/method, records requests with headers, and fails loudly when unqueued") + func replaysQueuedResponsesAndRecordsRequests() async throws { + // Arrange + let transport = FakeHTTPTransport() + let listURL = try #require(URL(string: "http://192.168.1.5:3000/live-sessions")) + let unqueuedURL = try #require(URL(string: "http://192.168.1.5:3000/other")) + let body = Data("[]".utf8) + await transport.queueSuccess(url: listURL, body: body) + + var request = URLRequest(url: listURL) + request.httpMethod = "GET" + request.setValue(Self.originValue, forHTTPHeaderField: "Origin") + + // Act + let (data, response) = try await transport.send(request) + + // Assert: the queued response came back for that URL/method. + #expect(data == body) + #expect(response.statusCode == FakeHTTPTransport.defaultOKStatus) + #expect(response.url == listURL) + + // Assert: the request was recorded verbatim, headers included + // (this is what lets APIClient tests assert Origin-iff-G, plan §3.4). + let recorded = await transport.recordedRequests + #expect(recorded.count == 1) + #expect(recorded.first?.url == listURL) + #expect(recorded.first?.value(forHTTPHeaderField: "Origin") == Self.originValue) + + // Assert: an unqueued route throws an explicit, identifying error. + await #expect(throws: FakeHTTPTransportError.noQueuedResponse(method: "GET", url: unqueuedURL)) { + _ = try await transport.send(URLRequest(url: unqueuedURL)) + } + } +} diff --git a/ios/Packages/TestSupport/Tests/TestSupportTests/FakeTransportTests.swift b/ios/Packages/TestSupport/Tests/TestSupportTests/FakeTransportTests.swift new file mode 100644 index 0000000..73b35ba --- /dev/null +++ b/ios/Packages/TestSupport/Tests/TestSupportTests/FakeTransportTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +import WireProtocol +@testable import TestSupport + +@Suite("FakeTransport") +struct FakeTransportTests { + @Test("scripts connect failures, delivers injected frames, records sends and closes") + func scriptsFailuresDeliversFramesAndRecordsTraffic() async throws { + // Arrange + let transport = FakeTransport() + let baseURL = try #require(URL(string: "http://192.168.1.5:3000")) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let attachFrame = #"{"type":"attach","sessionId":null}"# + + // Act + Assert: a scripted failure makes the next connect throw it. + await transport.scriptConnectFailure() + await #expect(throws: FakeTransportError.scriptedConnectFailure) { + _ = try await transport.connect(to: endpoint) + } + + // Act: connect for real, send one frame, inject one frame, close cleanly. + let connection = try await transport.connect(to: endpoint) + try await connection.send(attachFrame) + await transport.emit(frame: "server-frame-1") + await transport.finishFrames() + + var received: [String] = [] + for try await frame in connection.frames { + received.append(frame) + } + await connection.close() + + // Assert: injected frames arrived in order and ended with a clean finish. + #expect(received == ["server-frame-1"]) + // Assert: the double recorded everything the client did. + #expect(await transport.sentFrames == [attachFrame]) + #expect(await transport.closeCallCount == 1) + #expect(await transport.connectAttempts == [endpoint, endpoint]) + } +} diff --git a/ios/Packages/WireProtocol/Package.swift b/ios/Packages/WireProtocol/Package.swift new file mode 100644 index 0000000..12dab2d --- /dev/null +++ b/ios/Packages/WireProtocol/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 6.0 +// T-iOS-1 scaffold shell. The frozen wire contract (types + Tunables + shared +// I/O boundary types) lands in T-iOS-3, which owns ios/Packages/WireProtocol/**. +import PackageDescription + +let package = Package( + name: "WireProtocol", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [ + .library(name: "WireProtocol", targets: ["WireProtocol"]), + ], + targets: [ + .target(name: "WireProtocol"), + .testTarget(name: "WireProtocolTests", dependencies: ["WireProtocol"]), + ], + swiftLanguageModes: [.v6] +) diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/ClientMessage.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/ClientMessage.swift new file mode 100644 index 0000000..ad1847a --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/ClientMessage.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Client → server WS frames (frozen contract, plan §3.1; mirrors +/// `src/types.ts:87-92` `ClientMessage`). Immutable value type. +/// +/// Server-side validation the caller must respect (the server SILENTLY DISCARDS +/// invalid frames, `src/protocol.ts:45-86` — no error reply comes back): +/// - `attach` must be the FIRST frame on a connection (src/server.ts:707-711). +/// - `resize` cols/rows must be integers in `WireConstants.resizeRange` (1...1000). +/// - `attach.cwd`, when present, must be an absolute path (`Validation.isAbsoluteCwd`). +/// - `input.data` is raw keyboard bytes, passed through verbatim — never filtered. +public enum ClientMessage: Sendable, Equatable { + /// First frame. `sessionId == nil` spawns a new session (encoded as an + /// explicit JSON `"sessionId":null` — the key is REQUIRED by the server, + /// src/protocol.ts:132-134). `cwd` = "new tab here" spawn directory (M6). + case attach(sessionId: UUID?, cwd: String?) + /// Raw keyboard bytes, verbatim (invariant #9 — no content filtering). + case input(data: String) + /// Own message type so the server can `ioctl(TIOCSWINSZ)` → SIGWINCH. + case resize(cols: Int, rows: Int) + /// Resolve a held permission gate with allow. `mode` is only meaningful for + /// a `plan` gate and is encoded as a TOP-LEVEL `mode` key — the server's WS + /// wiring re-parses the raw frame for it (src/server.ts:91-102). + case approve(mode: ApproveMode?) + /// Resolve a held permission gate with deny. + case reject +} + +/// Permission mode written back when resolving a `plan` gate. Raw values mirror +/// the server whitelist `PERMISSION_MODES` (src/server.ts:76 / src/types.ts:365). +/// +/// Note: the plan-gate three-way UI only ever sends `.acceptEdits` / `.default` +/// (mirroring public/tabs.ts:345-347). Raw `.auto` is gated by ALLOW_AUTO_MODE +/// (default false) and is server-downgraded to `default` otherwise +/// (src/server.ts:765-766); it is reserved for a future permission-mode switcher. +public enum ApproveMode: String, Sendable, CaseIterable { + case `default` + case acceptEdits + case plan + case auto +} diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/HTTPTransport.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/HTTPTransport.swift new file mode 100644 index 0000000..988a3f2 --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/HTTPTransport.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Thin HTTP I/O boundary (frozen contract, plan §3.1). `APIClient` builds +/// `URLRequest`s (Origin stamped iff the endpoint is a mutating `G` route, +/// plan §3.4 铁律) and sends them through this seam; the production +/// implementation wraps `URLSession`, `FakeHTTPTransport` (TestSupport) +/// queues canned responses. +public protocol HTTPTransport: Sendable { + /// Perform one HTTP exchange. Implementations throw on transport-level + /// failure; non-2xx statuses are returned, not thrown — classification + /// (e.g. `PairingError`) is the caller's job. + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) +} diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/HostEndpoint.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/HostEndpoint.swift new file mode 100644 index 0000000..0beaefb --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/HostEndpoint.swift @@ -0,0 +1,89 @@ +import Foundation + +/// A paired web-terminal host (frozen contract, plan §3.1). The SINGLE point of +/// derivation for the `Origin` header and the WS URL — hand-assembling either +/// anywhere else is a review CRITICAL (plan §5.1 / T-iOS-9 安全注). +/// +/// Derivations are computed once at init and stored immutably: +/// - `originHeader` = `://[:]`, omitting the scheme's +/// default port (http/80, https/443), scheme+host lowercased — identical to +/// browser Origin serialization. The server normalises BOTH sides via +/// `new URL()` before comparing protocol/hostname/port (src/http/origin.ts:31-51). +/// - `wsURL` = same host+port, scheme http→ws / https→wss, path replaced by +/// `WireConstants.wsPath`; query/fragment/credentials dropped. +public struct HostEndpoint: Sendable, Equatable, Codable { + /// The URL the user dialed: `http(s)://[:]`. Any path, query, + /// fragment or credentials it carries are ignored by the derivations. + public let baseURL: URL + /// Derived WS endpoint (`ws(s)://…/term`). Stored at init; contract-wise a + /// read-only property, per plan §3.1. + public let wsURL: URL + /// Derived `Origin` header value — see type doc. Never hand-assemble. + public let originHeader: String + + private static let wsSchemeByHTTPScheme = ["http": "ws", "https": "wss"] + private static let defaultPortByScheme = ["http": 80, "https": 443] + + /// Validating init: the URL must be http(s) with a non-empty host, else nil + /// (QR-scan payloads are untrusted external input — reject early, plan §5). + public init?(baseURL: URL) { + guard let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true), + let scheme = components.scheme?.lowercased(), + let wsScheme = Self.wsSchemeByHTTPScheme[scheme], + let rawHost = components.host, !rawHost.isEmpty, + let wsURL = Self.deriveWSURL(from: components, wsScheme: wsScheme) + else { return nil } + + self.baseURL = baseURL + self.wsURL = wsURL + self.originHeader = Self.deriveOrigin( + scheme: scheme, host: rawHost.lowercased(), port: components.port + ) + } + + private static func deriveOrigin(scheme: String, host: String, port: Int?) -> String { + // IPv6 literals need brackets in an Origin. URLComponents.host has + // returned them both bare and pre-bracketed across Foundation versions — + // wrap idempotently. + let needsBrackets = host.contains(":") && !host.hasPrefix("[") + let serializedHost = needsBrackets ? "[\(host)]" : host + guard let port, port != defaultPortByScheme[scheme] else { + return "\(scheme)://\(serializedHost)" + } + return "\(scheme)://\(serializedHost):\(port)" + } + + private static func deriveWSURL(from components: URLComponents, wsScheme: String) -> URL? { + var wsComponents = components + wsComponents.scheme = wsScheme + wsComponents.path = WireConstants.wsPath + wsComponents.query = nil + wsComponents.fragment = nil + wsComponents.user = nil + wsComponents.password = nil + return wsComponents.url + } + + // MARK: - Codable (persisted via HostRegistry/Keychain — re-validate on decode) + + private enum CodingKeys: String, CodingKey { + case baseURL + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let url = try container.decode(URL.self, forKey: .baseURL) + guard let endpoint = HostEndpoint(baseURL: url) else { + throw DecodingError.dataCorruptedError( + forKey: .baseURL, in: container, + debugDescription: "baseURL is not an http(s) URL with a host" + ) + } + self = endpoint + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(baseURL, forKey: .baseURL) + } +} diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/MessageCodec.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/MessageCodec.swift new file mode 100644 index 0000000..7e29817 --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/MessageCodec.swift @@ -0,0 +1,153 @@ +import Foundation + +/// Pure static codec for the WS text-frame protocol (frozen contract, plan §3.1). +/// NEVER throws in either direction: +/// - `encode` is total — every `ClientMessage` has a JSON representation. +/// - `decodeServer` mirrors the server's "invalid frames are silently discarded" +/// resilience (src/protocol.ts:45-86, src/server.ts:698-701): malformed input → `nil`. +public enum MessageCodec { + // MARK: - encode (client → server) + + /// Encode a `ClientMessage` as a JSON text frame accepted by the server's + /// `parseClientMessage` (src/protocol.ts). String escaping matches + /// `JSON.stringify` byte-for-byte (short escapes for \b \t \n \f \r, and + /// `\u00XX` lowercase-hex for other C0 control bytes) so a Swift client is + /// indistinguishable from the web client on the wire. + /// + /// Key shapes (each pinned by CodecRoundtripTests): + /// - `attach` ALWAYS carries an explicit `sessionId` key — JSON `null` when + /// nil (the server requires the key's presence, src/protocol.ts:132-134); + /// UUIDs are lowercased (crypto.randomUUID style; SESSION_ID_RE is /i). + /// - `approve` puts `mode` as a TOP-LEVEL key: the server's WS wiring + /// re-parses the RAW frame for `obj['mode']` (src/server.ts:91-102). + /// - `resize` emits bare integers; `input.data` is escaped verbatim. + public static func encode(_ message: ClientMessage) -> String { + switch message { + case let .attach(sessionId, cwd): + return encodeAttach(sessionId: sessionId, cwd: cwd) + case let .input(data): + return "{\"type\":\"input\",\"data\":\(jsonStringLiteral(data))}" + case let .resize(cols, rows): + return "{\"type\":\"resize\",\"cols\":\(cols),\"rows\":\(rows)}" + case let .approve(mode): + guard let mode else { return "{\"type\":\"approve\"}" } + return "{\"type\":\"approve\",\"mode\":\"\(mode.rawValue)\"}" + case .reject: + return "{\"type\":\"reject\"}" + } + } + + private static func encodeAttach(sessionId: UUID?, cwd: String?) -> String { + let sessionIdJSON = sessionId.map { "\"\($0.uuidString.lowercased())\"" } ?? "null" + let cwdPart = cwd.map { ",\"cwd\":\(jsonStringLiteral($0))" } ?? "" + return "{\"type\":\"attach\",\"sessionId\":\(sessionIdJSON)\(cwdPart)}" + } + + /// JSON string literal with `JSON.stringify`-identical escaping. + private static func jsonStringLiteral(_ value: String) -> String { + var out = "\"" + for scalar in value.unicodeScalars { + switch scalar { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\u{08}": out += "\\b" + case "\t": out += "\\t" + case "\n": out += "\\n" + case "\u{0C}": out += "\\f" + case "\r": out += "\\r" + default: + if scalar.value < 0x20 { + out += String(format: "\\u%04x", Int(scalar.value)) + } else { + out.unicodeScalars.append(scalar) + } + } + } + return out + "\"" + } + + // MARK: - decodeServer (server → client, untrusted) + + /// Decode a server JSON text frame. Whitelist semantics, never throws: + /// bad JSON, non-object frames, unknown `type`, or missing/wrong-typed + /// REQUIRED fields → `nil` (drop the frame). Wrong-typed OPTIONAL fields + /// are tolerated as absent (see per-case doc on `ServerMessage`). + public static func decodeServer(_ text: String) -> ServerMessage? { + guard let data = text.data(using: .utf8), + let frame = try? JSONDecoder().decode(ServerFrame.self, from: data) + else { return nil } + return frame.message + } +} + +// MARK: - Internal decoding scaffolding + +/// Decodable shim so one JSONDecoder pass yields `ServerMessage?` without ever +/// surfacing a throw for merely-malformed content (only structurally non-JSON +/// input makes JSONDecoder itself throw, which `decodeServer` catches). +private struct ServerFrame: Decodable { + let message: ServerMessage? + + private enum Keys: String, CodingKey { + case type, sessionId, data, code, reason, status, detail, pending, gate, telemetry + } + + init(from decoder: any Decoder) throws { + guard let container = try? decoder.container(keyedBy: Keys.self), + let type = try? container.decode(String.self, forKey: .type) + else { + message = nil + return + } + message = Self.decodeBody(type: type, from: container) + } + + private static func decodeBody( + type: String, from container: KeyedDecodingContainer + ) -> ServerMessage? { + switch type { + case "attached": + return decodeAttached(container) + case "output": + guard let data = try? container.decode(String.self, forKey: .data) else { return nil } + return .output(data: data) + case "exit": + return decodeExit(container) + case "status": + return decodeStatus(container) + case "telemetry": + guard let telemetry = try? container.decode(StatusTelemetry.self, forKey: .telemetry) + else { return nil } + return .telemetry(telemetry) + default: + return nil + } + } + + /// `attached.sessionId` must pass the server's own SESSION_ID_RE (M7) — + /// `UUID(uuidString:)` alone would accept non-v4 UUIDs. + private static func decodeAttached(_ container: KeyedDecodingContainer) -> ServerMessage? { + guard let raw = try? container.decode(String.self, forKey: .sessionId), + Validation.isValidSessionId(raw), + let sessionId = UUID(uuidString: raw) + else { return nil } + return .attached(sessionId: sessionId) + } + + private static func decodeExit(_ container: KeyedDecodingContainer) -> ServerMessage? { + guard let code = try? container.decode(Int.self, forKey: .code) else { return nil } + let reason = try? container.decode(String.self, forKey: .reason) + return .exit(code: code, reason: reason) + } + + private static func decodeStatus(_ container: KeyedDecodingContainer) -> ServerMessage? { + guard let rawStatus = try? container.decode(String.self, forKey: .status), + let status = ClaudeStatus(rawValue: rawStatus) + else { return nil } + let detail = try? container.decode(String.self, forKey: .detail) + let pending = (try? container.decode(Bool.self, forKey: .pending)) ?? false + let gate = (try? container.decode(String.self, forKey: .gate)) + .flatMap(GateKind.init(rawValue:)) + return .status(status, detail: detail, pending: pending, gate: gate) + } +} diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/ServerMessage.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/ServerMessage.swift new file mode 100644 index 0000000..0a95457 --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/ServerMessage.swift @@ -0,0 +1,118 @@ +import Foundation + +/// Server → client WS frames (frozen contract, plan §3.1; mirrors +/// `src/types.ts:109-120` `ServerMessage`). Decoded ONLY via +/// `MessageCodec.decodeServer` — the server is an untrusted input source and +/// malformed frames become `nil`, never a crash. +public enum ServerMessage: Sendable, Equatable { + /// Attach confirmation. ALWAYS adopt the server-issued id — attaching with + /// an unknown UUID yields a fresh session id (src/session/manager.ts:108-174). + case attached(sessionId: UUID) + /// Opaque ANSI/UTF-8 bytes; ring-buffer replay and live stream share this shape. + case output(data: String) + /// Shell exit. `code == WireConstants.spawnFailedExitCode` (-1) means the + /// spawn never succeeded (M4) and `reason` is required server-side. + case exit(code: Int, reason: String?) + /// Claude Code activity derived from hooks (H2/H3/B4). `pending` = a tool + /// approval is held server-side; `gate` says which kind. Absent `pending` + /// decodes as `false`; an unrecognized `gate` value decodes as `nil` + /// (tolerated as absent so the pending signal is never lost). + case status(ClaudeStatus, detail: String?, pending: Bool, gate: GateKind?) + /// Latest statusLine telemetry broadcast (B2). + case telemetry(StatusTelemetry) +} + +/// Mirrors `src/types.ts:97` `ClaudeStatus`. `unknown` = no hook signal yet; +/// `stuck` (A5) = output silent past STUCK_TTL while not idle/exited. +public enum ClaudeStatus: String, Sendable { + case working, waiting, idle, unknown, stuck +} + +/// Mirrors `src/types.ts:101` `PermissionGate`. `plan` = ExitPlanMode three-way +/// gate; `tool` = ordinary tool gate. +public enum GateKind: String, Sendable { + case tool, plan +} + +/// Mirrors `src/types.ts:406-416` `StatusTelemetry`: every metric optional, +/// `at` (server receive time, ms since epoch) required. Decoding is tolerant — +/// a wrong-typed OPTIONAL field is treated as absent (mirrors the server's own +/// tolerant statusLine parsing); a missing/wrong-typed `at` fails the decode. +public struct StatusTelemetry: Sendable, Equatable, Decodable { + public let contextUsedPct: Double? + public let costUsd: Double? + public let linesAdded: Int? + public let linesRemoved: Int? + public let model: String? + public let effort: String? + public let pr: PrInfo? + public let rate: RateInfo? + /// Server receive timestamp (ms). REQUIRED — frames without it are dropped. + public let at: Int + + public init( + contextUsedPct: Double? = nil, + costUsd: Double? = nil, + linesAdded: Int? = nil, + linesRemoved: Int? = nil, + model: String? = nil, + effort: String? = nil, + pr: PrInfo? = nil, + rate: RateInfo? = nil, + at: Int + ) { + self.contextUsedPct = contextUsedPct + self.costUsd = costUsd + self.linesAdded = linesAdded + self.linesRemoved = linesRemoved + self.model = model + self.effort = effort + self.pr = pr + self.rate = rate + self.at = at + } + + private enum CodingKeys: String, CodingKey { + case contextUsedPct, costUsd, linesAdded, linesRemoved + case model, effort, pr, rate, at + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // `at` is the only required field; its absence invalidates the frame. + at = try container.decode(Int.self, forKey: .at) + // Optional fields: `try?` treats wrong-typed values as absent (tolerant). + contextUsedPct = try? container.decode(Double.self, forKey: .contextUsedPct) + costUsd = try? container.decode(Double.self, forKey: .costUsd) + linesAdded = try? container.decode(Int.self, forKey: .linesAdded) + linesRemoved = try? container.decode(Int.self, forKey: .linesRemoved) + model = try? container.decode(String.self, forKey: .model) + effort = try? container.decode(String.self, forKey: .effort) + pr = try? container.decode(PrInfo.self, forKey: .pr) + rate = try? container.decode(RateInfo.self, forKey: .rate) + } +} + +/// Mirrors `src/types.ts:413` `StatusTelemetry.pr`. +public struct PrInfo: Sendable, Equatable, Decodable { + public let number: Int + public let url: String + public let reviewState: String? + + public init(number: Int, url: String, reviewState: String? = nil) { + self.number = number + self.url = url + self.reviewState = reviewState + } +} + +/// Mirrors `src/types.ts:414` `StatusTelemetry.rate`. +public struct RateInfo: Sendable, Equatable, Decodable { + public let fiveHourPct: Double? + public let sevenDayPct: Double? + + public init(fiveHourPct: Double? = nil, sevenDayPct: Double? = nil) { + self.fiveHourPct = fiveHourPct + self.sevenDayPct = sevenDayPct + } +} diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/TermTransport.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/TermTransport.swift new file mode 100644 index 0000000..f62f202 --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/TermTransport.swift @@ -0,0 +1,30 @@ +/// The ONLY WS I/O boundary (frozen contract, plan §3.1/§3.2). +/// `URLSessionTermTransport` (SessionCore, T-iOS-9) and `FakeTransport` +/// (TestSupport, T-iOS-4) both implement this; `SessionEngine` cannot tell +/// them apart. +public protocol TermTransport: Sendable { + /// Open a WS connection to `endpoint.wsURL`, stamping + /// `Origin: endpoint.originHeader` on the upgrade (plan §5.1). + func connect(to endpoint: HostEndpoint) async throws -> TransportConnection +} + +/// One live WS connection, as immutable capability handles (frozen contract). +public struct TransportConnection: Sendable { + /// Server JSON text frames, in arrival order. Stream finish = clean close; + /// stream throw = transport error (the two are distinguishable, T-iOS-9). + public let frames: AsyncThrowingStream + /// Send one client JSON text frame (produced by `MessageCodec.encode`). + public let send: @Sendable (String) async throws -> Void + /// Close the connection (client detach — the server-side PTY keeps running). + public let close: @Sendable () async -> Void + + public init( + frames: AsyncThrowingStream, + send: @escaping @Sendable (String) async throws -> Void, + close: @escaping @Sendable () async -> Void + ) { + self.frames = frames + self.send = send + self.close = close + } +} diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/TimelineEvent.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/TimelineEvent.swift new file mode 100644 index 0000000..876426d --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/TimelineEvent.swift @@ -0,0 +1,53 @@ +import Foundation + +/// One activity-timeline entry from `GET /live-sessions/:id/events` (frozen +/// contract, plan §3.1; mirrors src/types.ts:428-433). The server derives +/// `class` semantically (src/types.ts:423: tool/waiting/done/stuck/user) — the +/// client never re-derives it from hook names. `class` stays a raw `String` so +/// the SHAPE decodes even for future/unknown classes; consumers drop unknowns +/// (use `decodeList`, which does exactly that). +public struct TimelineEvent: Sendable, Equatable, Decodable { + /// Server ingest timestamp (ms since epoch). + public let at: Int + /// Server-derived semantic class; see `knownClasses`. + public let `class`: String + /// Sanitized tool name (server-side: ≤200 chars, control chars stripped). + public let toolName: String? + /// Server-derived human phrase ("ran Bash", "edited 3 files"). + public let label: String + + /// The server's `TimelineClass` union (src/types.ts:423). + public static let knownClasses: Set = ["tool", "waiting", "done", "stuck", "user"] + + public init(at: Int, class className: String, toolName: String?, label: String) { + self.at = at + self.`class` = className + self.toolName = toolName + self.label = label + } + + /// True when `class` is one the server currently emits. + public var hasKnownClass: Bool { + Self.knownClasses.contains(`class`) + } + + /// Decode a `/events` response body. NEVER throws: the server is an + /// untrusted input source — malformed entries and unknown-class entries are + /// silently dropped; a non-array body yields `[]`. + public static func decodeList(from data: Data) -> [TimelineEvent] { + guard let entries = try? JSONDecoder().decode([LossyEntry].self, from: data) else { + return [] + } + return entries.compactMap(\.value).filter(\.hasKnownClass) + } +} + +/// Per-element tolerance shim: a malformed element becomes `nil` instead of +/// failing the whole array decode. +private struct LossyEntry: Decodable { + let value: TimelineEvent? + + init(from decoder: any Decoder) { + value = try? TimelineEvent(from: decoder) + } +} diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/Tunables.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/Tunables.swift new file mode 100644 index 0000000..2b399a3 --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/Tunables.swift @@ -0,0 +1,44 @@ +/// Client-side tuning constants (frozen contract; SOLE value source is the +/// plan §3.2.1 table — adding/changing a constant goes back through T-iOS-3). +/// All named — no magic numbers anywhere downstream. +public enum Tunables { + /// WS keep-alive ping period. `URLSessionWebSocketTask` has NO automatic + /// ping (plan §1) — `PingScheduler` drives an explicit `sendPing` at this + /// interval so the terminal is never "looks connected but dead". + public static let pingInterval: Duration = .seconds(25) + + /// Consecutive missed pongs after which the connection is declared dead + /// (T-iOS-5): 1 missed pong is tolerated, the 2nd is a disconnect signal. + public static let pongMissLimit = 2 + + /// Foreground `/live-sessions` polling period. Mirrors the web launcher's + /// refresh cadence (public/launcher.ts:30 `REFRESH_MS = 5000`). + public static let listPollInterval: Duration = .seconds(5) + + /// Telemetry chips grey out when `StatusTelemetry.at` is older than this. + /// Mirrors public/tabs.ts:45 `STATUSLINE_TTL_MS` (= server default, + /// src/config.ts:63). The server value is env-overridable at runtime while + /// iOS bakes the default — accepted drift (plan §3.2.1). + public static let telemetryStaleTtlMs: Int = 30_000 + + /// Away-digest banner auto-fade delay (T-iOS-14). + public static let digestFadeDelay: Duration = .seconds(8) + + /// Max session-title length after sanitisation (T-iOS-23; OSC titles are + /// host/attacker-controlled input). + public static let titleMaxLength = 256 + + /// `URLSessionWebSocketTask.maximumMessageSize`. The default (1 MiB) is too + /// small: ring-buffer replay arrives as ONE full-snapshot frame + /// (src/session/session.ts:165-171) and JSON escaping inflates control + /// bytes to `\uXXXX` by 1-6x (src/protocol.ts:186), so worst case is + /// ≈ 6 × SCROLLBACK_BYTES (default 2 MiB) plus frame envelope → 16 MiB. + /// + /// COUPLING WARNING: SCROLLBACK_BYTES is a server env knob the client + /// cannot discover at runtime (no config handshake). If the host raises it + /// past ~2.7 MB (or replay is extremely escape-dense), `receive()` fails + /// with NSPOSIXErrorDomain code 40 (ENOBUFS "Message too long") — that MUST + /// be classified as the non-retryable `.replayTooLarge` failure and never + /// fed into the backoff reconnect loop (plan §3.2 / T-iOS-9/10). + public static let maxWSMessageBytes = 16 * 1024 * 1024 +} diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/Validation.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/Validation.swift new file mode 100644 index 0000000..051309a --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/Validation.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Boundary validation, same rules as the server (frozen contract, plan §3.1). +/// Sources: src/protocol.ts:22-23 (SESSION_ID_RE), :113-115 (dimensions), +/// :142-149 (cwd). Pure functions, never throw. +public enum Validation { + /// Byte-identical port of the server's SESSION_ID_RE + /// (`/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i`): + /// UUID v4 — 8-4-4-4-12 hex groups, version nibble '4', variant nibble + /// 8/9/a/b, case-insensitive (M7). Vectors in ServerVectorTests pin the + /// equivalence against a test-side mirror of the server regex. + public static func isValidSessionId(_ candidate: String) -> Bool { + let sessionIdRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + .ignoresCase() + return candidate.wholeMatch(of: sessionIdRegex) != nil + } + + /// Mirrors the server's `isValidDimension` range check for `resize` + /// (integers in `WireConstants.resizeRange`). Frames outside this range + /// are silently discarded by the server — validate before sending. + public static func isValidResize(cols: Int, rows: Int) -> Bool { + WireConstants.resizeRange.contains(cols) && WireConstants.resizeRange.contains(rows) + } + + /// Mirrors the server's `attach.cwd` check: must start with "/" + /// (src/protocol.ts:142-149; deeper normalisation stays server-side). + public static func isAbsoluteCwd(_ candidate: String) -> Bool { + candidate.hasPrefix("/") + } +} diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/WireConstants.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/WireConstants.swift new file mode 100644 index 0000000..9860857 --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/WireConstants.swift @@ -0,0 +1,17 @@ +/// Wire-level constants shared with the server (frozen contract, plan §3.1). +public enum WireConstants { + /// WS endpoint path (src/config.ts:41 `DEFAULT_WS_PATH`). + public static let wsPath = "/term" + + /// Soft-reset prefix the server prepends to ring-buffer replay as a safety + /// net (src/types.ts:167-170 / M2). Clients may use it to recognise the + /// replay boundary; never strip it — it is valid ANSI for the terminal. + public static let replaySoftResetPrefix = "\u{1B}[0m" + + /// `exit.code` value meaning the PTY spawn never succeeded (M4); + /// `exit.reason` is required server-side in that case. Not a retryable state. + public static let spawnFailedExitCode = -1 + + /// Valid `resize` cols/rows range, inclusive (src/protocol.ts:113-115). + public static let resizeRange: ClosedRange = 1...1000 +} diff --git a/ios/Packages/WireProtocol/Sources/WireProtocol/WireProtocolPlaceholder.swift b/ios/Packages/WireProtocol/Sources/WireProtocol/WireProtocolPlaceholder.swift new file mode 100644 index 0000000..8c523fb --- /dev/null +++ b/ios/Packages/WireProtocol/Sources/WireProtocol/WireProtocolPlaceholder.swift @@ -0,0 +1,11 @@ +/// T-iOS-1 scaffold placeholder. +/// +/// The frozen wire contract (`ClientMessage` / `ServerMessage` / `MessageCodec` / +/// `Validation` / `WireConstants` / `Tunables` plus the shared I/O boundary types +/// `HostEndpoint` / `TermTransport` / `HTTPTransport` / `TimelineEvent`) lands in +/// T-iOS-3 and is owned exclusively by that task — do not add contract types here. +public enum WireProtocolPackage { + /// Package identity marker consumed by the scaffold smoke tests (downstream + /// packages reference it to prove their dependency edge compiles). + public static let packageName = "WireProtocol" +} diff --git a/ios/Packages/WireProtocol/Tests/WireProtocolTests/CodecRoundtripTests.swift b/ios/Packages/WireProtocol/Tests/WireProtocolTests/CodecRoundtripTests.swift new file mode 100644 index 0000000..74a95a3 --- /dev/null +++ b/ios/Packages/WireProtocol/Tests/WireProtocolTests/CodecRoundtripTests.swift @@ -0,0 +1,215 @@ +import Foundation +import Testing +import WireProtocol + +// T-iOS-3 · MessageCodec.encode 与服务器 parseClientMessage 的逐键契约 + roundtrip +// property + fuzz。向量出处:src/protocol.ts、src/server.ts:91-102、test/protocol.test.ts。 + +// MARK: - encode 形状(服务器视角逐键一致) + +@Test("encode(attach) 无 sessionId 时也显式携带 sessionId:null(src/protocol.ts:132-134)") +func encodeAttachCarriesExplicitNullSessionId() { + // Arrange / Act + let frame = MessageCodec.encode(.attach(sessionId: nil, cwd: nil)) + + // Assert — 逐字节 + 服务器视角双重断言 + #expect(frame == "{\"type\":\"attach\",\"sessionId\":null}") + #expect(ServerViewParser.parse(frame) == .attach(sessionId: nil, cwd: nil)) +} + +@Test("encode(attach) 带 UUID → 服务器收到小写 UUID 字符串") +func encodeAttachSerializesLowercasedUUID() { + // Arrange + let uuid = UUID(uuidString: "F47AC10B-58CC-4372-A567-0E02B2C3D479")! + + // Act + let frame = MessageCodec.encode(.attach(sessionId: uuid, cwd: nil)) + + // Assert + #expect(frame == "{\"type\":\"attach\",\"sessionId\":\"f47ac10b-58cc-4372-a567-0e02b2c3d479\"}") + #expect(ServerViewParser.parse(frame) + == .attach(sessionId: "f47ac10b-58cc-4372-a567-0e02b2c3d479", cwd: nil)) +} + +@Test("encode(attach) 带 cwd → cwd 键出现且为绝对路径原文;无 cwd 时键不出现") +func encodeAttachCwdKeyAppearsOnlyWhenPresent() throws { + // Arrange / Act + let withCwd = MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj")) + let withoutCwd = MessageCodec.encode(.attach(sessionId: nil, cwd: nil)) + + // Assert + #expect(ServerViewParser.parse(withCwd) == .attach(sessionId: nil, cwd: "/Users/dev/proj")) + let withoutObj = try #require(jsonObject(withoutCwd)) + #expect(withoutObj.index(forKey: "cwd") == nil) +} + +@Test("encode(input) 原始键盘字节逐字节透传(Esc/^C/CR/Tab/Shift+Tab)") +func encodeInputPassesRawKeyboardBytesVerbatim() { + // Arrange — test/protocol.test.ts:79 的 verbatim 向量 + let raw = "\u{1B}[A\u{03}\r\t\u{1B}[Z" + + // Act + let frame = MessageCodec.encode(.input(data: raw)) + + // Assert + #expect(ServerViewParser.parse(frame) == .input(data: raw)) +} + +@Test("encode(input) 控制字节按 JSON.stringify 规则转义(\\r 短转义、ESC → \\u001b)") +func encodeInputEscapesControlBytesLikeJSONStringify() { + // Arrange / Act + let frame = MessageCodec.encode(.input(data: "hi\r\u{1B}[A\"\\")) + + // Assert — 与 JS 客户端 JSON.stringify 输出逐字节一致 + #expect(frame == "{\"type\":\"input\",\"data\":\"hi\\r\\u001b[A\\\"\\\\\"}") +} + +@Test("encode(resize) cols/rows 为 JSON 整数,服务器按 [1,1000] 接受") +func encodeResizeEmitsIntegers() { + // Arrange / Act + let frame = MessageCodec.encode(.resize(cols: 120, rows: 40)) + + // Assert + #expect(frame == "{\"type\":\"resize\",\"cols\":120,\"rows\":40}") + #expect(ServerViewParser.parse(frame) == .resize(cols: 120, rows: 40)) + #expect(ServerViewParser.parse(MessageCodec.encode(.resize(cols: 1, rows: 1))) + == .resize(cols: 1, rows: 1)) + #expect(ServerViewParser.parse(MessageCodec.encode(.resize(cols: 1000, rows: 1000))) + == .resize(cols: 1000, rows: 1000)) +} + +@Test("encode(approve) 无 mode → 裸 {type:approve};encode(reject) → {type:reject}") +func encodeApproveRejectBareFrames() { + // Arrange / Act / Assert + #expect(MessageCodec.encode(.approve(mode: nil)) == "{\"type\":\"approve\"}") + #expect(MessageCodec.encode(.reject) == "{\"type\":\"reject\"}") + #expect(ServerViewParser.parse("{\"type\":\"approve\"}") == .approve) + #expect(ServerViewParser.parse("{\"type\":\"reject\"}") == .reject) +} + +@Test("encode(approve.mode) 把 mode 放在顶层键(src/server.ts:94-102 读原始帧 obj['mode'])", + arguments: ApproveMode.allCases) +func encodeApproveModeIsTopLevelKey(mode: ApproveMode) throws { + // Arrange / Act + let frame = MessageCodec.encode(.approve(mode: mode)) + + // Assert — 服务器 parseClientMessage 接受该帧形状 + #expect(ServerViewParser.parse(frame) == .approve) + // …且 WS 接线层的 parseApproveMode 能从顶层恢复 mode + #expect(ServerViewParser.topLevelMode(frame) == mode.rawValue) + let obj = try #require(jsonObject(frame)) + #expect(obj["mode"] as? String == mode.rawValue) +} + +@Test("ApproveMode rawValue 与服务器 PERMISSION_MODES 白名单一致(src/server.ts:76)") +func approveModeRawValuesMatchServerWhitelist() { + // Arrange / Act + let rawValues = Set(ApproveMode.allCases.map(\.rawValue)) + + // Assert + #expect(rawValues == ServerViewParser.permissionModes) +} + +// MARK: - roundtrip property(approve.mode 豁免:服务器刻意丢 mode,src/protocol.ts:77-79) + +@Test("roundtrip property:任意合法 ClientMessage encode→(服务器视角)decode 不变形") +func roundtripPropertyHoldsForRandomClientMessages() { + // Arrange — 固定种子保证可复现 + var rng = SplitMix64(seed: 0xC0FF_EE00_0003) + let iterations = 300 + + for _ in 0.. [String: Any]? { + guard let data = text.data(using: .utf8) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] +} diff --git a/ios/Packages/WireProtocol/Tests/WireProtocolTests/ContractConstantsTests.swift b/ios/Packages/WireProtocol/Tests/WireProtocolTests/ContractConstantsTests.swift new file mode 100644 index 0000000..e4a0d0e --- /dev/null +++ b/ios/Packages/WireProtocol/Tests/WireProtocolTests/ContractConstantsTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +import WireProtocol + +// T-iOS-3 · 冻结契约常量绊线:Tunables(§3.2.1 唯一取值表)与 WireConstants。 +// 这些断言故意逐字重复取值表——改常量必须回 T-iOS-3 改契约并同步这里。 + +@Test("Tunables 取值与 §3.2.1 表逐项一致") +func tunablesMatchFrozenValueTable() { + #expect(Tunables.pingInterval == .seconds(25)) + #expect(Tunables.pongMissLimit == 2) + #expect(Tunables.listPollInterval == .seconds(5)) // public/launcher.ts:30 REFRESH_MS + #expect(Tunables.telemetryStaleTtlMs == 30_000) // public/tabs.ts:45 STATUSLINE_TTL_MS + #expect(Tunables.digestFadeDelay == .seconds(8)) + #expect(Tunables.titleMaxLength == 256) + #expect(Tunables.maxWSMessageBytes == 16 * 1024 * 1024) +} + +@Test("maxWSMessageBytes ≥ 6 × 默认 SCROLLBACK_BYTES(2MiB)(JSON 转义最坏膨胀系数)") +func maxWSMessageBytesCoversWorstCaseReplayExpansion() { + // Arrange — src/config.ts:39 DEFAULT_SCROLLBACK_BYTES = 2MiB;\uXXXX 转义最坏 6× + let defaultScrollbackBytes = 2 * 1024 * 1024 + let worstCaseEscapeFactor = 6 + + // Assert + #expect(Tunables.maxWSMessageBytes >= worstCaseEscapeFactor * defaultScrollbackBytes) +} + +@Test("WireConstants:wsPath/soft-reset 前缀/spawn 失败码/resize 区间") +func wireConstantsMatchServer() { + #expect(WireConstants.wsPath == "/term") // src/config.ts:41 DEFAULT_WS_PATH + #expect(WireConstants.replaySoftResetPrefix == "\u{1B}[0m") // src/types.ts:167-170 + #expect(WireConstants.spawnFailedExitCode == -1) // M4 + #expect(WireConstants.resizeRange == 1...1000) // src/protocol.ts:113-115 +} + +@Test("TransportConnection:能力句柄原样保存;frames finish = 干净断线") +func transportConnectionHoldsCapabilityHandles() async throws { + // Arrange + actor Recorder { + var sentFrames: [String] = [] + var isClosed = false + func recordSend(_ frame: String) { sentFrames.append(frame) } + func recordClose() { isClosed = true } + } + let recorder = Recorder() + let connection = TransportConnection( + frames: AsyncThrowingStream { continuation in + continuation.yield("{\"type\":\"output\",\"data\":\"x\"}") + continuation.finish() + }, + send: { await recorder.recordSend($0) }, + close: { await recorder.recordClose() } + ) + + // Act + var received: [String] = [] + for try await frame in connection.frames { + received.append(frame) + } + try await connection.send("{\"type\":\"reject\"}") + await connection.close() + + // Assert + #expect(received == ["{\"type\":\"output\",\"data\":\"x\"}"]) + #expect(await recorder.sentFrames == ["{\"type\":\"reject\"}"]) + #expect(await recorder.isClosed) +} + +@Test("ClaudeStatus/GateKind rawValue 与服务器字面量一致") +func statusAndGateRawValuesMatchServer() { + #expect(ClaudeStatus.working.rawValue == "working") + #expect(ClaudeStatus.waiting.rawValue == "waiting") + #expect(ClaudeStatus.idle.rawValue == "idle") + #expect(ClaudeStatus.unknown.rawValue == "unknown") + #expect(ClaudeStatus.stuck.rawValue == "stuck") + #expect(GateKind.tool.rawValue == "tool") + #expect(GateKind.plan.rawValue == "plan") +} diff --git a/ios/Packages/WireProtocol/Tests/WireProtocolTests/HostEndpointTests.swift b/ios/Packages/WireProtocol/Tests/WireProtocolTests/HostEndpointTests.swift new file mode 100644 index 0000000..b73fa49 --- /dev/null +++ b/ios/Packages/WireProtocol/Tests/WireProtocolTests/HostEndpointTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing +import WireProtocol + +// T-iOS-3 · HostEndpoint originHeader/wsURL 派生向量(含原 T-iOS-7 用例,plan §7)。 +// Origin 铁律:单点派生,禁止手拼(plan §5.1);默认端口省略与浏览器 Origin 序列化一致。 + +private func endpoint(_ urlString: String) -> HostEndpoint? { + guard let url = URL(string: urlString) else { return nil } + return HostEndpoint(baseURL: url) +} + +// MARK: - originHeader 派生向量 + +@Test("originHeader:http + IPv4 + 非默认端口 → 与 baseURL 同串") +func originHeaderKeepsNonDefaultPortIPv4() throws { + let sut = try #require(endpoint("http://192.168.1.5:3000")) + #expect(sut.originHeader == "http://192.168.1.5:3000") +} + +@Test("originHeader:https + 非标端口保留端口") +func originHeaderKeepsNonStandardHTTPSPort() throws { + let sut = try #require(endpoint("https://mac.example:8443")) + #expect(sut.originHeader == "https://mac.example:8443") +} + +@Test("originHeader:https + 443 → 无端口后缀(浏览器 Origin 序列化)") +func originHeaderOmitsDefaultHTTPSPort() throws { + let sut = try #require(endpoint("https://mac.example:443")) + #expect(sut.originHeader == "https://mac.example") +} + +@Test("originHeader:http + 80 → 无端口后缀") +func originHeaderOmitsDefaultHTTPPort() throws { + let sut = try #require(endpoint("http://mac.example:80")) + #expect(sut.originHeader == "http://mac.example") +} + +@Test("originHeader:无端口 URL → 无端口后缀;localhost 保留非默认端口") +func originHeaderWithoutExplicitPort() throws { + let bare = try #require(endpoint("https://mac.tailnet-1234.ts.net")) + #expect(bare.originHeader == "https://mac.tailnet-1234.ts.net") + let localhost = try #require(endpoint("http://localhost:3000")) + #expect(localhost.originHeader == "http://localhost:3000") +} + +@Test("originHeader:scheme 与 host 小写规范化(服务器两侧 new URL() 规范化对称)") +func originHeaderLowercasesSchemeAndHost() throws { + let sut = try #require(endpoint("HTTP://Mac.Example:3000")) + #expect(sut.originHeader == "http://mac.example:3000") +} + +@Test("originHeader:baseURL 的 path/query 不进入 Origin") +func originHeaderIgnoresPathAndQuery() throws { + let sut = try #require(endpoint("http://192.168.1.5:3000/index.html?x=1")) + #expect(sut.originHeader == "http://192.168.1.5:3000") +} + +@Test("originHeader:IPv6 host 保留方括号") +func originHeaderBracketsIPv6Host() throws { + let sut = try #require(endpoint("http://[fe80::1]:3000")) + #expect(sut.originHeader == "http://[fe80::1]:3000") +} + +// MARK: - wsURL 派生向量(scheme http→ws / https→wss + WireConstants.wsPath) + +@Test("wsURL:http → ws 同 host 同 port + /term") +func wsURLDerivesWsFromHttp() throws { + let sut = try #require(endpoint("http://192.168.1.5:3000")) + #expect(sut.wsURL.absoluteString == "ws://192.168.1.5:3000/term") +} + +@Test("wsURL:https → wss + /term(非标端口保留)") +func wsURLDerivesWssFromHttps() throws { + let sut = try #require(endpoint("https://mac.example:8443")) + #expect(sut.wsURL.absoluteString == "wss://mac.example:8443/term") +} + +@Test("wsURL:无端口 https → wss 无端口;path 恒为 WireConstants.wsPath") +func wsURLWithoutPort() throws { + let sut = try #require(endpoint("https://mac.tailnet-1234.ts.net")) + #expect(sut.wsURL.absoluteString == "wss://mac.tailnet-1234.ts.net/term") + #expect(sut.wsURL.path == WireConstants.wsPath) +} + +@Test("wsURL:baseURL 带尾斜杠/path/query 时仍只保留 /term") +func wsURLReplacesPathAndDropsQuery() throws { + let sut = try #require(endpoint("http://192.168.1.5:3000/launcher?join=abc")) + #expect(sut.wsURL.absoluteString == "ws://192.168.1.5:3000/term") +} + +// MARK: - 输入边界:非 http(s) / 无 host 拒绝(扫码结果是不可信输入,plan §5) + +@Test("init:非 http(s) scheme 或无 host → nil", + arguments: ["ftp://mac.example", "file:///tmp/x", "mailto:hi@example.com", "http://", "ws://mac.example:3000"]) +func initRejectsNonHTTPBaseURLs(urlString: String) { + #expect(endpoint(urlString) == nil) +} + +// MARK: - Codable(HostRegistry Keychain 持久化经由它) + +@Test("Codable roundtrip:encode→decode 恒等") +func codableRoundtripPreservesEquality() throws { + // Arrange + let original = try #require(endpoint("https://mac.example:8443")) + + // Act + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(HostEndpoint.self, from: data) + + // Assert + #expect(decoded == original) + #expect(decoded.originHeader == original.originHeader) + #expect(decoded.wsURL == original.wsURL) +} + +@Test("Codable:持久化数据被篡改成非 http(s) URL → decode 显式 throw(边界再验证)") +func codableDecodeRevalidatesBaseURL() { + // Arrange + let tampered = Data("{\"baseURL\":\"ftp://mac.example\"}".utf8) + + // Act / Assert + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(HostEndpoint.self, from: tampered) + } +} diff --git a/ios/Packages/WireProtocol/Tests/WireProtocolTests/PackageScaffoldTests.swift b/ios/Packages/WireProtocol/Tests/WireProtocolTests/PackageScaffoldTests.swift new file mode 100644 index 0000000..96c5c86 --- /dev/null +++ b/ios/Packages/WireProtocol/Tests/WireProtocolTests/PackageScaffoldTests.swift @@ -0,0 +1,11 @@ +import Testing +import WireProtocol + +@Test("脚手架:WireProtocol 包可编译、可导入") +func wireProtocolPackageIsImportable() { + // Arrange / Act + let name = WireProtocolPackage.packageName + + // Assert + #expect(name == "WireProtocol") +} diff --git a/ios/Packages/WireProtocol/Tests/WireProtocolTests/ServerVectorTests.swift b/ios/Packages/WireProtocol/Tests/WireProtocolTests/ServerVectorTests.swift new file mode 100644 index 0000000..9ea6743 --- /dev/null +++ b/ios/Packages/WireProtocol/Tests/WireProtocolTests/ServerVectorTests.swift @@ -0,0 +1,259 @@ +import Foundation +import Testing +import WireProtocol + +// T-iOS-3 · 从 test/protocol.test.ts 移植的跨实现向量 + decodeServer 白名单解码。 +// 双实现(TS/Swift)防漂移:这里锁当前语义,真服务器回归由 T-iOS-16 CI 常驻看护。 + +private let validUUID = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + +// MARK: - Validation.isValidSessionId(SESSION_ID_RE 向量, test/protocol.test.ts:19-49) + +@Test("isValidSessionId:合法 UUID v4 通过") +func sessionIdAcceptsValidUUIDv4() { + #expect(Validation.isValidSessionId(validUUID)) +} + +@Test("isValidSessionId:大写十六进制也通过(服务器正则 /i)") +func sessionIdAcceptsUppercaseHex() { + #expect(Validation.isValidSessionId("F47AC10B-58CC-4372-A567-0E02B2C3D479")) +} + +@Test("isValidSessionId:variant=8 的 UUID v4 通过") +func sessionIdAcceptsVariant8() { + #expect(Validation.isValidSessionId("550e8400-e29b-41d4-8716-446655440000")) +} + +@Test("isValidSessionId:非 UUID / 空串 / v1 / 错误 variant 全部拒绝(M7)", + arguments: [ + "abc123", + "", + "550e8400-e29b-11d4-a716-446655440000", // v1:版本位=1 + "f47ac10b-58cc-4372-c567-0e02b2c3d479", // variant 'c'(非 8/9/a/b) + "f47ac10b58cc4372a5670e02b2c3d479", // 缺分隔符 + "f47ac10b-58cc-4372-a567-0e02b2c3d47", // 少一位 + "g47ac10b-58cc-4372-a567-0e02b2c3d479", // 非法字符 + ]) +func sessionIdRejectsInvalid(candidate: String) { + #expect(!Validation.isValidSessionId(candidate)) +} + +@Test("isValidSessionId 与测试侧服务器正则镜像逐向量同判(防移植走样)") +func sessionIdAgreesWithServerRegexMirror() { + // Arrange — 正反两面向量各若干 + let candidates = [ + validUUID, "F47AC10B-58CC-4372-A567-0E02B2C3D479", + "550e8400-e29b-41d4-8716-446655440000", "abc123", "", + "550e8400-e29b-11d4-a716-446655440000", + "f47ac10b-58cc-4372-c567-0e02b2c3d479", + ] + + for candidate in candidates { + // Act / Assert + #expect(Validation.isValidSessionId(candidate) + == ServerViewParser.isServerValidSessionId(candidate), + "分歧向量: \(candidate)") + } +} + +// MARK: - isValidResize / isAbsoluteCwd(src/protocol.ts:113-115,142-149) + +@Test("isValidResize:边界 1/1000 通过,0/1001 拒绝") +func resizeValidationBoundaries() { + #expect(Validation.isValidResize(cols: 1, rows: 1)) + #expect(Validation.isValidResize(cols: 1000, rows: 1000)) + #expect(Validation.isValidResize(cols: 120, rows: 40)) + #expect(!Validation.isValidResize(cols: 0, rows: 40)) + #expect(!Validation.isValidResize(cols: 1001, rows: 40)) + #expect(!Validation.isValidResize(cols: 80, rows: 0)) + #expect(!Validation.isValidResize(cols: 80, rows: 1001)) +} + +@Test("isAbsoluteCwd:'/a' 通过;'a' 与空串拒绝") +func absoluteCwdValidation() { + #expect(Validation.isAbsoluteCwd("/a")) + #expect(!Validation.isAbsoluteCwd("a")) + #expect(!Validation.isAbsoluteCwd("")) +} + +// MARK: - 服务器 parseClientMessage 向量 ↔ 测试侧镜像(transcribed from test/protocol.test.ts) + +@Test("服务器视角镜像:合法客户端帧向量全部接受(test/protocol.test.ts:53-124)") +func serverMirrorAcceptsValidClientVectors() { + #expect(ServerViewParser.parse("{\"type\":\"attach\",\"sessionId\":null}") + == .attach(sessionId: nil, cwd: nil)) + #expect(ServerViewParser.parse("{\"type\":\"attach\",\"sessionId\":\"\(validUUID)\"}") + == .attach(sessionId: validUUID, cwd: nil)) + #expect(ServerViewParser.parse("{\"type\":\"input\",\"data\":\"ls -la\\r\"}") + == .input(data: "ls -la\r")) + #expect(ServerViewParser.parse("{\"type\":\"resize\",\"cols\":1,\"rows\":1}") + == .resize(cols: 1, rows: 1)) + #expect(ServerViewParser.parse("{\"type\":\"resize\",\"cols\":1000,\"rows\":1000}") + == .resize(cols: 1000, rows: 1000)) + #expect(ServerViewParser.parse("{\"type\":\"approve\"}") == .approve) + #expect(ServerViewParser.parse("{\"type\":\"reject\"}") == .reject) + // 已移除的 blur 类型必须被拒(test/protocol.test.ts:120-123) + #expect(ServerViewParser.parse("{\"type\":\"blur\"}") == nil) + // 多余字段忽略(test/protocol.test.ts:284) + #expect(ServerViewParser.parse("{\"type\":\"resize\",\"cols\":80,\"rows\":40,\"extra\":\"ignored\"}") + == .resize(cols: 80, rows: 40)) +} + +@Test("服务器视角镜像:非法客户端帧向量全部拒绝(test/protocol.test.ts:128-266)", + arguments: [ + "not json at all", "", "null", "[]", "42", + "{\"type\":\"ping\"}", "{\"data\":\"hello\"}", "{\"type\":42}", + "{\"type\":\"resize\",\"cols\":0,\"rows\":40}", + "{\"type\":\"resize\",\"cols\":1001,\"rows\":40}", + "{\"type\":\"resize\",\"cols\":80,\"rows\":0}", + "{\"type\":\"resize\",\"cols\":80,\"rows\":1001}", + "{\"type\":\"resize\",\"cols\":80.5,\"rows\":40}", + "{\"type\":\"resize\",\"cols\":80,\"rows\":24.9}", + "{\"type\":\"resize\",\"cols\":\"80\",\"rows\":40}", + "{\"type\":\"resize\",\"rows\":40}", + "{\"type\":\"resize\",\"cols\":80}", + "{\"type\":\"input\",\"data\":42}", + "{\"type\":\"input\",\"data\":null}", + "{\"type\":\"input\"}", + "{\"type\":\"input\",\"data\":{}}", + "{\"type\":\"attach\",\"sessionId\":\"abc123\"}", + "{\"type\":\"attach\",\"sessionId\":\"\"}", + "{\"type\":\"attach\",\"sessionId\":42}", + "{\"type\":\"attach\"}", + "{\"type\":\"attach\",\"sessionId\":null,\"cwd\":\"relative\"}", + "{\"type\":\"attach\",\"sessionId\":null,\"cwd\":42}", + ]) +func serverMirrorRejectsInvalidClientVectors(frame: String) { + #expect(ServerViewParser.parse(frame) == nil) +} + +// MARK: - decodeServer 合法帧(5 种 case) + +@Test("decodeServer(attached):合法 UUID → .attached;大写 UUID 同样接受") +func decodeAttachedFrames() { + // Arrange + let expected = UUID(uuidString: validUUID)! + + // Act / Assert + #expect(MessageCodec.decodeServer("{\"type\":\"attached\",\"sessionId\":\"\(validUUID)\"}") + == .attached(sessionId: expected)) + #expect(MessageCodec.decodeServer( + "{\"type\":\"attached\",\"sessionId\":\"F47AC10B-58CC-4372-A567-0E02B2C3D479\"}") + == .attached(sessionId: expected)) +} + +@Test("decodeServer(output):ANSI 字节原样进 data(serialize 向量, test/protocol.test.ts:303-307)") +func decodeOutputFrame() { + #expect(MessageCodec.decodeServer("{\"type\":\"output\",\"data\":\"\\u001b[1;32mHello\\u001b[0m\"}") + == .output(data: "\u{1B}[1;32mHello\u{1B}[0m")) +} + +@Test("decodeServer(exit):仅 code / code+reason 两形状(-1 = spawn 失败)") +func decodeExitFrames() { + #expect(MessageCodec.decodeServer("{\"type\":\"exit\",\"code\":0}") + == .exit(code: 0, reason: nil)) + #expect(MessageCodec.decodeServer( + "{\"type\":\"exit\",\"code\":-1,\"reason\":\"spawn failed: /bin/badshell\"}") + == .exit(code: WireConstants.spawnFailedExitCode, reason: "spawn failed: /bin/badshell")) +} + +@Test("decodeServer(status):5 个 ClaudeStatus 值全解;缺省 detail/pending/gate 取安全默认", + arguments: ["working", "waiting", "idle", "unknown", "stuck"]) +func decodeStatusAllClaudeStatuses(raw: String) throws { + // Arrange / Act + let message = MessageCodec.decodeServer("{\"type\":\"status\",\"status\":\"\(raw)\"}") + + // Assert + let expectedStatus = try #require(ClaudeStatus(rawValue: raw)) + #expect(message == .status(expectedStatus, detail: nil, pending: false, gate: nil)) +} + +@Test("decodeServer(status):pending/gate/detail 全携带(tool 与 plan 两种 gate)") +func decodeStatusWithGate() { + #expect(MessageCodec.decodeServer( + "{\"type\":\"status\",\"status\":\"waiting\",\"detail\":\"Bash\",\"pending\":true,\"gate\":\"tool\"}") + == .status(.waiting, detail: "Bash", pending: true, gate: .tool)) + #expect(MessageCodec.decodeServer( + "{\"type\":\"status\",\"status\":\"waiting\",\"pending\":true,\"gate\":\"plan\"}") + == .status(.waiting, detail: nil, pending: true, gate: .plan)) +} + +@Test("decodeServer(status):未知 gate 值按缺席容忍(保留 pending 信号,不丢帧)") +func decodeStatusToleratesUnknownGate() { + #expect(MessageCodec.decodeServer( + "{\"type\":\"status\",\"status\":\"waiting\",\"pending\":true,\"gate\":\"alien\"}") + == .status(.waiting, detail: nil, pending: true, gate: nil)) + // pending 非 bool → 安全默认 false + #expect(MessageCodec.decodeServer( + "{\"type\":\"status\",\"status\":\"working\",\"pending\":\"yes\"}") + == .status(.working, detail: nil, pending: false, gate: nil)) +} + +@Test("decodeServer(telemetry):全字段样本逐字段解出(src/types.ts:406-416 镜像)") +func decodeTelemetryFullSample() throws { + // Arrange + let frame = """ + {"type":"telemetry","telemetry":{"contextUsedPct":42.5,"costUsd":1.23,\ + "linesAdded":10,"linesRemoved":2,"model":"Fable 5","effort":"high",\ + "pr":{"number":7,"url":"https://github.com/x/y/pull/7","reviewState":"APPROVED"},\ + "rate":{"fiveHourPct":12.5,"sevenDayPct":33},"at":1751600000000}} + """ + + // Act + let message = try #require(MessageCodec.decodeServer(frame)) + + // Assert + let expected = StatusTelemetry( + contextUsedPct: 42.5, costUsd: 1.23, linesAdded: 10, linesRemoved: 2, + model: "Fable 5", effort: "high", + pr: PrInfo(number: 7, url: "https://github.com/x/y/pull/7", reviewState: "APPROVED"), + rate: RateInfo(fiveHourPct: 12.5, sevenDayPct: 33), + at: 1_751_600_000_000 + ) + #expect(message == .telemetry(expected)) +} + +@Test("decodeServer(telemetry):全可选字段缺省仍可解(只有 at)") +func decodeTelemetryMinimalSample() { + #expect(MessageCodec.decodeServer("{\"type\":\"telemetry\",\"telemetry\":{\"at\":123}}") + == .telemetry(StatusTelemetry(at: 123))) +} + +@Test("decodeServer(telemetry):可选字段类型错误按缺席容忍(帧保留)") +func decodeTelemetryToleratesWrongTypedOptionalField() { + #expect(MessageCodec.decodeServer( + "{\"type\":\"telemetry\",\"telemetry\":{\"at\":5,\"costUsd\":\"lots\",\"pr\":42}}") + == .telemetry(StatusTelemetry(at: 5))) +} + +// MARK: - decodeServer 非法帧全表 → nil(永不 throw) + +@Test("decodeServer:非法帧全表 → nil(坏 JSON/未知 type/字段缺失或错型/at 缺失)", + arguments: [ + "not json at all", "", "null", "[]", "42", "true", + "{\"type\":\"ping\"}", "{\"data\":\"hello\"}", "{\"type\":42}", + // attached:非 UUID / v1 / 缺失 / 错型(M7) + "{\"type\":\"attached\",\"sessionId\":\"abc123\"}", + "{\"type\":\"attached\",\"sessionId\":\"550e8400-e29b-11d4-a716-446655440000\"}", + "{\"type\":\"attached\"}", + "{\"type\":\"attached\",\"sessionId\":42}", + // output:data 缺失 / 错型 + "{\"type\":\"output\"}", + "{\"type\":\"output\",\"data\":42}", + // exit:code 缺失 / 字符串 / 非整数 + "{\"type\":\"exit\"}", + "{\"type\":\"exit\",\"code\":\"0\"}", + "{\"type\":\"exit\",\"code\":1.5}", + // status:status 缺失 / 未知值 / 错型(白名单) + "{\"type\":\"status\"}", + "{\"type\":\"status\",\"status\":\"sleeping\"}", + "{\"type\":\"status\",\"status\":42}", + // telemetry:telemetry 键缺失 / 非对象 / at 缺失 / at 错型 + "{\"type\":\"telemetry\"}", + "{\"type\":\"telemetry\",\"telemetry\":\"x\"}", + "{\"type\":\"telemetry\",\"telemetry\":{\"costUsd\":1}}", + "{\"type\":\"telemetry\",\"telemetry\":{\"at\":\"now\"}}", + ]) +func decodeServerReturnsNilForMalformedFrames(frame: String) { + #expect(MessageCodec.decodeServer(frame) == nil) +} diff --git a/ios/Packages/WireProtocol/Tests/WireProtocolTests/ServerViewParser.swift b/ios/Packages/WireProtocol/Tests/WireProtocolTests/ServerViewParser.swift new file mode 100644 index 0000000..86a07e5 --- /dev/null +++ b/ios/Packages/WireProtocol/Tests/WireProtocolTests/ServerViewParser.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Test-side mini-port of the server's `parseClientMessage` (src/protocol.ts:45-176) +/// plus `parseApproveMode` (src/server.ts:94-102). Used by the roundtrip property +/// test and the cross-implementation vector tests: `MessageCodec.encode` output is +/// fed through THIS parser, which itself is pinned against the vectors transcribed +/// from test/protocol.test.ts — so the Swift encoder and the TS server parser +/// cannot drift silently (the live tripwire is T-iOS-16's real-server CI). +enum ServerViewMessage: Equatable { + case attach(sessionId: String?, cwd: String?) + case input(data: String) + case resize(cols: Int, rows: Int) + case approve + case reject +} + +enum ServerViewParser { + /// Mirror of src/protocol.ts SESSION_ID_RE (UUID v4, /i). + private static let sessionIdPattern = + "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + + /// Mirror of src/server.ts:76 PERMISSION_MODES. + static let permissionModes: Set = ["default", "acceptEdits", "plan", "auto"] + + /// Mirror of src/protocol.ts:27 ALLOWED_TYPES. + private static let allowedTypes: Set = ["attach", "input", "resize", "approve", "reject"] + + // MARK: - parseClientMessage mirror (never throws; invalid → nil) + + static func parse(_ raw: String) -> ServerViewMessage? { + guard let data = raw.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), + let obj = parsed as? [String: Any], + let type = obj["type"] as? String, + allowedTypes.contains(type) + else { return nil } + + switch type { + case "resize": return parseResize(obj) + case "input": return parseInput(obj) + case "approve": return .approve + case "reject": return .reject + default: return parseAttach(obj) + } + } + + /// Mirror of src/server.ts:94-102 — reads `mode` from the RAW frame's top level. + static func topLevelMode(_ raw: String) -> String? { + guard let data = raw.data(using: .utf8), + let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let mode = obj["mode"] as? String, + permissionModes.contains(mode) + else { return nil } + return mode + } + + // MARK: - Per-type validators (src/protocol.ts:90-176) + + private static func parseResize(_ obj: [String: Any]) -> ServerViewMessage? { + guard let cols = integerDimension(obj["cols"]), + let rows = integerDimension(obj["rows"]) + else { return nil } + return .resize(cols: cols, rows: rows) + } + + /// Mirror of isValidDimension: number, integer, 1...1000 (src/protocol.ts:113-115). + private static func integerDimension(_ value: Any?) -> Int? { + guard let number = value as? NSNumber, !isJSONBoolean(number) else { return nil } + let doubleValue = number.doubleValue + guard doubleValue == doubleValue.rounded(.towardZero), + doubleValue >= 1, doubleValue <= 1000 + else { return nil } + return number.intValue + } + + private static func isJSONBoolean(_ number: NSNumber) -> Bool { + CFGetTypeID(number) == CFBooleanGetTypeID() + } + + private static func parseInput(_ obj: [String: Any]) -> ServerViewMessage? { + guard let data = obj["data"] as? String else { return nil } + return .input(data: data) + } + + private static func parseAttach(_ obj: [String: Any]) -> ServerViewMessage? { + // sessionId key must be explicitly present (src/protocol.ts:132-134). + guard obj.index(forKey: "sessionId") != nil else { return nil } + + var cwd: String? + if let rawCwd = obj["cwd"] { + guard let cwdString = rawCwd as? String, cwdString.hasPrefix("/") else { return nil } + cwd = cwdString + } + + if obj["sessionId"] is NSNull { + return .attach(sessionId: nil, cwd: cwd) + } + guard let sessionId = obj["sessionId"] as? String, isServerValidSessionId(sessionId) else { + return nil + } + return .attach(sessionId: sessionId, cwd: cwd) + } + + static func isServerValidSessionId(_ candidate: String) -> Bool { + candidate.range( + of: sessionIdPattern, + options: [.regularExpression, .caseInsensitive] + ) != nil + } +} diff --git a/ios/Packages/WireProtocol/Tests/WireProtocolTests/TestHelpers.swift b/ios/Packages/WireProtocol/Tests/WireProtocolTests/TestHelpers.swift new file mode 100644 index 0000000..220c5a4 --- /dev/null +++ b/ios/Packages/WireProtocol/Tests/WireProtocolTests/TestHelpers.swift @@ -0,0 +1,71 @@ +import Foundation +import WireProtocol + +/// Deterministic RNG for the roundtrip property test (reproducible failures: +/// the seed is baked into the test; change it only with a note in the test). +struct SplitMix64: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } +} + +enum MessageGen { + /// Character pool that stresses the JSON escaper: quotes, backslashes, + /// C0 control bytes (ESC / CR / TAB / ^C / NUL), CJK, emoji, plain ASCII. + private static let stressPool: [Character] = [ + "a", "Z", "0", " ", "\"", "\\", "/", "{", "}", + "\u{1B}", "\r", "\n", "\t", "\u{03}", "\u{00}", "\u{7F}", + "中", "文", "終", "端", "🚀", "🧪", "é", "ß", + ] + + static func randomString(using rng: inout some RandomNumberGenerator, maxLength: Int = 48) -> String { + let length = Int.random(in: 0...maxLength, using: &rng) + return String((0.. String { + "/" + randomString(using: &rng, maxLength: 24) + } + + /// Seeded UUID v4 (version nibble 4, variant 10xx) — passes SESSION_ID_RE. + static func randomUUIDv4(using rng: inout some RandomNumberGenerator) -> UUID { + var bytes = (0..<16).map { _ in UInt8.random(in: 0...255, using: &rng) } + bytes[6] = (bytes[6] & 0x0F) | 0x40 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] + )) + } + + static func randomClientMessage(using rng: inout some RandomNumberGenerator) -> ClientMessage { + switch Int.random(in: 0...4, using: &rng) { + case 0: + let sessionId = Bool.random(using: &rng) ? randomUUIDv4(using: &rng) : nil + let cwd = Bool.random(using: &rng) ? randomAbsolutePath(using: &rng) : nil + return .attach(sessionId: sessionId, cwd: cwd) + case 1: + return .input(data: randomString(using: &rng)) + case 2: + return .resize( + cols: Int.random(in: 1...1000, using: &rng), + rows: Int.random(in: 1...1000, using: &rng) + ) + case 3: + let mode = Bool.random(using: &rng) ? ApproveMode.allCases.randomElement(using: &rng) : nil + return .approve(mode: mode) + default: + return .reject + } + } +} diff --git a/ios/Packages/WireProtocol/Tests/WireProtocolTests/TimelineEventTests.swift b/ios/Packages/WireProtocol/Tests/WireProtocolTests/TimelineEventTests.swift new file mode 100644 index 0000000..104b2f7 --- /dev/null +++ b/ios/Packages/WireProtocol/Tests/WireProtocolTests/TimelineEventTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +import WireProtocol + +// T-iOS-3 · TimelineEvent(GET /live-sessions/:id/events 条目,src/types.ts:428-433 镜像)。 +// 服务器是不可信输入源:decodeList 永不 throw,非法/未知 class 条目静默丢弃。 + +@Test("TimelineEvent:合法条目全字段解码") +func decodesValidEntryWithAllFields() throws { + // Arrange + let json = Data("{\"at\":1751600000000,\"class\":\"tool\",\"toolName\":\"Bash\",\"label\":\"ran Bash\"}".utf8) + + // Act + let event = try JSONDecoder().decode(TimelineEvent.self, from: json) + + // Assert + #expect(event == TimelineEvent(at: 1_751_600_000_000, class: "tool", toolName: "Bash", label: "ran Bash")) + #expect(event.hasKnownClass) +} + +@Test("TimelineEvent:toolName 可选缺省") +func decodesEntryWithoutToolName() throws { + // Arrange + let json = Data("{\"at\":1,\"class\":\"waiting\",\"label\":\"waiting for approval\"}".utf8) + + // Act + let event = try JSONDecoder().decode(TimelineEvent.self, from: json) + + // Assert + #expect(event.toolName == nil) + #expect(event.hasKnownClass) +} + +@Test("knownClasses 与服务器 TimelineClass 全集一致(src/types.ts:423)") +func knownClassesMirrorServerUnion() { + #expect(TimelineEvent.knownClasses == ["tool", "waiting", "done", "stuck", "user"]) +} + +@Test("decodeList:未知 class 条目被丢弃,合法条目保留(消费方丢弃语义)") +func decodeListDropsUnknownClassEntries() { + // Arrange — 1 合法 + 1 未知 class + 1 缺 label + 1 形状完全错误 + let json = Data(""" + [{"at":1,"class":"tool","label":"ran Bash"}, + {"at":2,"class":"alien","label":"???"}, + {"at":3,"class":"done"}, + 42] + """.utf8) + + // Act + let events = TimelineEvent.decodeList(from: json) + + // Assert + #expect(events == [TimelineEvent(at: 1, class: "tool", toolName: nil, label: "ran Bash")]) +} + +@Test("decodeList:5 个已知 class 全部保留") +func decodeListKeepsAllKnownClasses() { + // Arrange + let json = Data(""" + [{"at":1,"class":"tool","label":"a"},{"at":2,"class":"waiting","label":"b"}, + {"at":3,"class":"done","label":"c"},{"at":4,"class":"stuck","label":"d"}, + {"at":5,"class":"user","label":"e"}] + """.utf8) + + // Act + let events = TimelineEvent.decodeList(from: json) + + // Assert + #expect(events.count == 5) + #expect(events.allSatisfy { $0.hasKnownClass }) +} + +@Test("decodeList:坏 JSON / 顶层非数组 → 空数组,永不 throw", + arguments: ["not json", "{\"at\":1}", "42", ""]) +func decodeListNeverThrowsOnMalformedInput(text: String) { + #expect(TimelineEvent.decodeList(from: Data(text.utf8)) == []) +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..59b7ad9 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,93 @@ +# XcodeGen project spec — regenerate with: cd ios && xcodegen generate +# The generated WebTerm.xcodeproj is NOT committed (see ios/.gitignore). +# +# TOOLCHAIN DEVIATION (recorded per plan §7 T-iOS-1): the plan names +# "default MainActor isolation" (SWIFT_DEFAULT_ACTOR_ISOLATION), which is +# Swift 6.2+/Xcode 26 only. On Swift 6.1 / Xcode 16.3 the closest supported +# equivalent is Swift 6 language mode + strict concurrency, with explicit +# @MainActor annotations where isolation is required. +name: WebTerm + +options: + bundleIdPrefix: com.yaojia + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true + +settings: + base: + SWIFT_VERSION: "6.0" # Swift 6 language mode + SWIFT_STRICT_CONCURRENCY: complete + IPHONEOS_DEPLOYMENT_TARGET: "17.0" + TARGETED_DEVICE_FAMILY: "1" # iPhone only (iPad layout is out of v1 scope) + CODE_SIGN_STYLE: Automatic + +packages: + WireProtocol: + path: Packages/WireProtocol + SessionCore: + path: Packages/SessionCore + HostRegistry: + path: Packages/HostRegistry + APIClient: + path: Packages/APIClient + # SwiftTerm is the ONLY third-party dependency, attached to the App target + # ONLY (plan §2) — packages must never import it. + SwiftTerm: + url: https://github.com/migueldeicaza/SwiftTerm + from: 1.13.0 + +targets: + WebTerm: + type: application + platform: iOS + sources: + - App/WebTerm + dependencies: + - package: WireProtocol + - package: SessionCore + - package: HostRegistry + - package: APIClient + - package: SwiftTerm + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.yaojia.webterm + info: + path: App/WebTerm/Resources/Info.plist + properties: + CFBundleDisplayName: WebTerm + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + # ── Security-critical keys, transcribed verbatim from PLAN_IOS_CLIENT §5.2 ── + # NO NSAllowsArbitraryLoads anywhere (release OR debug): the five CIDR + # exceptions below cover LAN + hotspot + Tailscale CGNAT + simulator + # loopback, so no arbitrary-loads escape hatch is needed in P0. + # T-iOS-19 re-verifies all five CIDR blocks in the release ipa. + NSAppTransportSecurity: + NSAllowsLocalNetworking: true # only covers .local / dotless hostnames, not bare IPs + NSExceptionDomains: # bare IPs use CIDR exceptions (iOS 17+ semantics) + "192.168.0.0/16": + NSExceptionAllowsInsecureHTTPLoads: true + "10.0.0.0/8": + NSExceptionAllowsInsecureHTTPLoads: true + "172.16.0.0/12": # RFC1918 third block (iPhone hotspot 172.20.10.x / corp LAN) + NSExceptionAllowsInsecureHTTPLoads: true + "100.64.0.0/10": # Tailscale CGNAT + NSExceptionAllowsInsecureHTTPLoads: true + "127.0.0.0/8": # simulator dev-loop (CIDR, not a single-IP key — DevForums 6205) + NSExceptionAllowsInsecureHTTPLoads: true + NSLocalNetworkUsageDescription: "连接你自己电脑上的 web-terminal 服务器" + NSCameraUsageDescription: "扫描 web 终端的配对二维码" + +schemes: + WebTerm: + build: + targets: + WebTerm: all + run: + config: Debug + test: + config: Debug