feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles
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
This commit is contained in:
@@ -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 注入驱动
|
||||
|
||||
@@ -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 重写)。
|
||||
|
||||
Reference in New Issue
Block a user