diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 559fdd8..0acba67 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -30,7 +30,19 @@ - **T-iOS-20 server APNs**: `src/push/apns.ts`(576 行)——env 三件套 all-or-disabled(缺失→路由不挂载,启动不 crash,密钥材料零日志)、token store 照抄 subscription-store 约定(幂等/上限/0600)、**手写 ES256 JWT(node:crypto ieee-p1363,零新依赖)**、NEEDS-INPUT(priority 10+WEBTERM_GATE+capability token)/DONE(priority 5)、payload 最小化结构性保证(构造函数只读 meta.id,cwd/命令内容不可能出现)、410/BadDeviceToken 逐出、`combineNotifyServices` 与 web-push 并联互不阻塞;`hasPushTarget` 扩展(仅 APNs token 也能 hold gate)。**65 测**含双 e2e(本地 h2c 假 APNs:held gate→双通道同发同 token→/hook/decision 放行)。新增 `APNS_HOST` env(sandbox 切换+测试零网络)。**npm test 53 files/1470 全绿,tsc 干净**。 - **T-iOS-38 APIClient P1 契约**: APNs token builders(hex 校验镜像服务器、联网前拒绝、显式 ASCII 字符集防全角)、projects/detail(**证实线上无 namespace 字段**——纯 web 端分组概念;lossy 解码;percent-encode 单点+裸 + 规避)、prefs(**未知顶层键往返逐字节存活**——防 iOS 覆写 web 偏好,关键陷阱已测;JSONValue 独立 integer case 防 42→42.0)、公开四层 HostNetworkTier(W3 dedup 欠账,PairingVM 后续 pass 可切换)。76/76 绿,覆盖 95.26%。 - 遗留(LOW/cosmetic): Swift doc comment 里 server 行号引用漂移;PairingVM 分类去重待后续 pass。 -- 待续: P1-B UI 九连链(22→24→25→27→26→23→29→28→21,串行,共享 App target)→ P1-C(T-iOS-30 验收)。**协调点**: `webterminal://` scheme 的 CFBundleURLTypes 由 orchestrator 预注册进 project.yml;T-iOS-23 获窄授权在 APIClient LiveSessionInfo 补 lastOutputAt 可选解码(38 已完工,串行无竞态)。 +- **[x] P1-B UI 九连链(22→24→25→27→26→23→29→28→21 全 DONE,10 agents;verify 判 BLOCKED→orchestrator 修复后放行)**: + - **T-iOS-22 DeepLinkRouter**: 全字段白名单(scheme/action/path 空/query 键精确/重复键拒)、UUID 复用冻结 Validation(v4);冷启动单槽 stash;`route(from:)` 供 push-tap 复用;非法链接计数不回显内容(防日志注入)。21 测。 + - **T-iOS-24 Timeline sheet**: 呈现顺序逐行镜像 web render()(slice 50→reverse);`[]`=disabled→空态非错误;复用 AwayDigestView 既有 onExpand(T-iOS-14 零改动);label Text(verbatim:)。14 测。 + - **T-iOS-25 Quick-reply**: 内置 6 chips 逐项镜像 quick-reply.ts(Esc/Enter 经 KeyByteMap 零手写转义);显示条件=持牌 gate(流上唯一 waiting 信号)&&!readOnly;UserDefaults lossy 载入。16 测。 + - **T-iOS-27 Diff 查看器**: App 层 DiffFetcher(RO 无 Origin;并注记归 T-iOS-38 后续收编);hunks 着色/staged 切换/truncated 横幅/lazy list;diff 字节 verbatim 渲染。 + - **T-iOS-26 Projects**: 分组逐条镜像 projects.ts 且**组 key 与 web 逐字节一致**(collapsed 经 /prefs 跨端共享,key 漂移=两端互丢折叠);**prefs clobber 防线**——GET 失败→toggle 仅本地绝不 PUT(空底盘 PUT=清库)、PUT 采纳服务器 echo;`claude\r` bootstrap 镜像 tabs.ts:679。 + - **T-iOS-23 切换器**: UnreadLedger(单调水位,严格 >)+TitleSanitizer(C0/C1/DEL+bidi/零宽全表,字素簇截断 256)入 SessionCore(+15 包测);LiveSessionInfo.lastOutputAt 容错解码(授权增量);列表边界二次净化。 + - **T-iOS-29 杂项**: new-in-cwd(cwd 不可信,非绝对路径按未知;**bootstrap 绝不复注入**——开新 shell≠起 claude,专项断言);exit 横幅"开新会话";**加建修复潜在 bug**: RootView 给容器补 `.id(controller.id)`(呈现中换 controller 不换 SwiftTerm 视图→终端假死)。 + - **T-iOS-28 缩略图**: 离屏 SwiftTerm 渲染管线(LRU 32/并发闸 2/预览 256KiB 上限/网格钳制),缓存键 (sessionId,lastOutputAt),CADisplayLink 注销不泄漏。 + - **T-iOS-21 PushRegistrar+锁屏**: WEBTERM_GATE category(Allow=.authenticationRequired、Deny 免认证 fail-safe、均无 .foreground,形状测试钉死);device token 对每 host 注册+记账重试;NotificationActionHandler(sessionId 复用 DeepLinkRouter 白名单、token 校验后仅入参绝不落盘、beginBackgroundTask 包 POST、403→本地通知兜底)。锁屏真机行为 DEFERRED(需付费账号)。 + - **verify(独立 agent)**: 包 261(SessionCore 92/APIClient 77/…)+App 247+集成 10 全绿;7 项语义抽查全 PASS(含恶意标题/putPrefs 唯一调用点走保留路径/token 零持久化 grep);Owns 审计干净;**但抓到 CRITICAL——生产模式启动即崩**: @MainActor UNNotificationCenterAdapter 的 completion 闭包继承主 actor 隔离,UN center 在后台队列回调→Swift 6 executor 断言 trap(测试全绿盖不住:push 接线在测试宿主下被跳过——**测试盲区实证**)。**orchestrator 修复**: 三处闭包加 `@Sendable` 字面量标注(不继承隔离);重建+实机启动验证:进程存活、崩溃报告零新增、通知授权弹窗弹出(=走过原 trap 点)、弹窗期间隐私遮罩正确覆盖(scenePhase .inactive)。 + - 遗留(LOW,归 P1-C 复核): deep link 未断言 userinfo/port(`webterminal://user:pass@open` 仍路由;id 均 v4 校验+host 只经 store 解析,影响可忽略);Diff fetcher 收编 APIClient 待后续 pass。 +- 待续: P1-C(T-iOS-30 P1 验收+安全复核,report-only)→ P1 收官。 ### ✅ iOS 客户端 P0 实施(完成 — 2026-07-05,分支 `feat/ios-client`,7 commits,未推送/未合并) 按 PLAN_IOS_CLIENT §8 批次推进;编排 = 每波一个 ultracode `Workflow`(builder 按任务派发 + 独立 verify agent 复跑验收)。 diff --git a/ios/App/WebTerm/Components/QuickReply.swift b/ios/App/WebTerm/Components/QuickReply.swift new file mode 100644 index 0000000..e7362ff --- /dev/null +++ b/ios/App/WebTerm/Components/QuickReply.swift @@ -0,0 +1,251 @@ +import SessionCore +import SwiftUI + +/// T-iOS-25 · Quick-reply chip row + 常用语面板 (plan §7; behavior mirror of +/// `public/quick-reply.ts`, data layer in `QuickReplyStore.swift`). +/// +/// Visibility decision (documented): the plan step says "waiting 状态才浮出". +/// On the iOS `SessionEvent` stream the raw `ClaudeStatus` is folded away by +/// the engine (`SessionEngine.applyGateFrame` keeps only pending/gate), so the +/// stream's ONLY waiting projection is the HELD GATE — a `status:'waiting', +/// pending:true` frame surfaces as `.gate(GateState)` and the lift as +/// `.gate(nil)`. Chips therefore float while a gate is held AND the terminal +/// is live (not exited/failed). Waiting-without-pending (a Notification +/// permission_prompt with no held relay) never reaches the stream — accepted +/// limitation; the observation point stays the SAME fan-out branches the +/// Gate/Terminal VMs already consume (no new SessionCore surface, no extra +/// fan-out branch needed). +/// +/// Send path: a chip tap goes through `TerminalViewModel.sendInput` — the one +/// ordered send pump — so a rapid double-tap yields two frames in tap order, +/// never interleaved, and the read-only guard drops taps on a dead terminal. +struct QuickReplyBar: View { + enum Copy { + static let managePhrases = "管理常用语" + } + + private enum Metrics { + static let chipSpacing: CGFloat = 6 + static let rowHorizontalPadding: CGFloat = 8 + static let rowVerticalPadding: CGFloat = 6 + } + + let terminalViewModel: TerminalViewModel + let gateViewModel: GateViewModel + let store: QuickReplyStore + + @State private var isPanelPresented = false + + /// Pure visibility rule (see type doc): held gate = the stream's waiting + /// signal; read-only (exited/failed) always hides. + static func isVisible(gate: GateState?, isReadOnly: Bool) -> Bool { + gate != nil && !isReadOnly + } + + /// The production tap path (also exercised directly by tests): compose the + /// payload via `QuickReplyPalette` and hand it to the VM's ordered pump. + static func send(_ chip: QuickReplyChip, through viewModel: TerminalViewModel) { + viewModel.sendInput(QuickReplyPalette.payload(for: chip)) + } + + var body: some View { + if Self.isVisible( + gate: gateViewModel.currentGate, + isReadOnly: terminalViewModel.isReadOnly + ) { + chipRow + .sheet(isPresented: $isPanelPresented) { + QuickReplyPanel(store: store) + } + } + } + + private var chipRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Metrics.chipSpacing) { + ForEach(store.allChips) { chip in + chipButton(chip) + } + managePhrasesButton + } + .padding(.horizontal, Metrics.rowHorizontalPadding) + .padding(.vertical, Metrics.rowVerticalPadding) + } + .background(.thinMaterial, in: Capsule()) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + private func chipButton(_ chip: QuickReplyChip) -> some View { + Button { + Self.send(chip, through: terminalViewModel) + } label: { + // Text(verbatim:) — user/label strings render as inert text, never + // LocalizedStringKey/Markdown (SEC-L3 mirror; T-iOS-23 convention). + Text(verbatim: chip.label) + .lineLimit(1) + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.small) + } + + private var managePhrasesButton: some View { + Button { + isPanelPresented = true + } label: { + Image(systemName: "plus") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.small) + .accessibilityLabel(Copy.managePhrases) + } +} + +/// 常用语面板: user-phrase CRUD + drag reorder (增删改序), presented from the +/// chip row's `+`. The add/edit form mirrors the web inline editor: text, +/// optional label (defaults to text) and an append-Enter toggle that starts +/// on. Tapping an existing row loads it into the form for editing (改). +struct QuickReplyPanel: View { + enum Copy { + static let title = "常用语" + static let addSection = "添加新常用语" + static let customSection = "自定义常用语" + static let textPlaceholder = "要发送的文本" + static let labelPlaceholder = "标签(可选,默认同文本)" + static let appendEnterToggle = "发送后自动回车" + static let addButton = "添加" + static let saveButton = "保存修改" + static let cancelEditButton = "取消编辑" + static let doneButton = "完成" + static let emptyState = "还没有自定义常用语" + /// Payload preview suffix for append-Enter chips (web: `${text}⏎`). + static let enterSuffixSymbol = "⏎" + } + + let store: QuickReplyStore + + @Environment(\.dismiss) private var dismiss + @State private var draftText = "" + @State private var draftLabel = "" + /// Mirrors the web editor default: `enterCheck.checked = true`. + @State private var draftAppendEnter = true + /// Non-nil while the form edits an existing chip instead of adding. + @State private var editingChipId: String? + + var body: some View { + NavigationStack { + List { + customSection + editorSection + } + .navigationTitle(Copy.title) + .toolbar { + ToolbarItem(placement: .topBarLeading) { EditButton() } + ToolbarItem(placement: .topBarTrailing) { + Button(Copy.doneButton) { dismiss() } + } + } + } + } + + // MARK: - Custom phrases (delete / reorder / tap-to-edit) + + @ViewBuilder private var customSection: some View { + Section(Copy.customSection) { + if store.userChips.isEmpty { + Text(Copy.emptyState) + .foregroundStyle(.secondary) + } else { + ForEach(store.userChips) { chip in + Button { + beginEditing(chip) + } label: { + chipRow(chip) + } + .tint(.primary) + } + .onDelete { offsets in + // Resolve ids FIRST — removing while iterating offsets + // would shift indices under us. + let ids = offsets.map { store.userChips[$0].id } + for id in ids { + store.removeChip(id: id) + } + } + .onMove { fromOffsets, toOffset in + store.moveChips(fromOffsets: fromOffsets, toOffset: toOffset) + } + } + } + } + + private func chipRow(_ chip: QuickReplyChip) -> some View { + VStack(alignment: .leading) { + // Text(verbatim:) — stored strings are boundary data (SEC-L3 mirror). + Text(verbatim: chip.label) + .lineLimit(1) + Text(verbatim: chip.appendEnter + ? chip.text + Copy.enterSuffixSymbol + : chip.text) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + // MARK: - Add / edit form + + @ViewBuilder private var editorSection: some View { + Section(Copy.addSection) { + TextField(Copy.textPlaceholder, text: $draftText) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + TextField(Copy.labelPlaceholder, text: $draftLabel) + Toggle(Copy.appendEnterToggle, isOn: $draftAppendEnter) + Button(editingChipId == nil ? Copy.addButton : Copy.saveButton) { + commitDraft() + } + .disabled(trimmedDraftText.isEmpty) + if editingChipId != nil { + Button(Copy.cancelEditButton, role: .cancel) { + clearDraft() + } + } + } + } + + private var trimmedDraftText: String { + draftText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func beginEditing(_ chip: QuickReplyChip) { + editingChipId = chip.id + draftText = chip.text + draftLabel = chip.label + draftAppendEnter = chip.appendEnter + } + + /// Web `onSaveClick` semantics: empty text is a no-op (button is disabled + /// anyway — belt and braces), empty label defaults to the text. + private func commitDraft() { + let text = trimmedDraftText + guard !text.isEmpty else { return } + let trimmedLabel = draftLabel.trimmingCharacters(in: .whitespacesAndNewlines) + let label = trimmedLabel.isEmpty ? text : trimmedLabel + if let id = editingChipId { + store.updateChip(id: id, text: text, label: label, + appendEnter: draftAppendEnter) + } else { + store.addChip(text: text, label: label, appendEnter: draftAppendEnter) + } + clearDraft() + } + + private func clearDraft() { + editingChipId = nil + draftText = "" + draftLabel = "" + draftAppendEnter = true + } +} diff --git a/ios/App/WebTerm/Components/QuickReplyStore.swift b/ios/App/WebTerm/Components/QuickReplyStore.swift new file mode 100644 index 0000000..7dac09d --- /dev/null +++ b/ios/App/WebTerm/Components/QuickReplyStore.swift @@ -0,0 +1,200 @@ +import Foundation +import Observation +import SessionCore + +/// T-iOS-25 · Quick-reply data layer: chip model, pure immutable CRUD and the +/// UserDefaults-backed palette store — a behavior mirror of +/// `public/quick-reply.ts` (Chip / addChip / removeChip / reorderChip / +/// updateChip / loadPalette / savePalette). +/// +/// Storage decision (plan §5.3 split): the palette is NON-SECRET UI prefs → +/// UserDefaults with an injectable suite (same pattern as +/// `UserDefaultsLastSessionStore`), never Keychain. + +/// One sendable snippet (mirror of the web `Chip` interface). +struct QuickReplyChip: Sendable, Equatable, Codable, Identifiable { + /// Stable identifier (built-ins use the `__` prefix, user chips `user_`). + let id: String + /// Raw bytes to send (without the Enter suffix). + let text: String + /// Display label on the chip button. Rendered via `Text(verbatim:)` only — + /// the SwiftUI analogue of the web's textContent-never-innerHTML (SEC-L3). + let label: String + /// If true, Enter (`\r`) is appended to `text` when sending. + let appendEnter: Bool +} + +/// Pure palette operations — every function returns a NEW array and never +/// mutates its input (web quick-reply.ts CRUD, verbatim semantics). +enum QuickReplyPalette { + /// The six built-in chips for Claude Code interaction, order and values + /// mirroring web `BUILT_IN_CHIPS`. Esc bytes resolve through `KeyByteMap` + /// — hand-writing an escape sequence in the App layer is a review finding. + static let builtInChips: [QuickReplyChip] = [ + QuickReplyChip(id: "__yes", text: "yes", label: "yes", appendEnter: true), + QuickReplyChip(id: "__continue", text: "continue", label: "continue", + appendEnter: true), + QuickReplyChip(id: "__1", text: "1", label: "1", appendEnter: true), + QuickReplyChip(id: "__2", text: "2", label: "2", appendEnter: true), + QuickReplyChip(id: "__3", text: "3", label: "3", appendEnter: true), + QuickReplyChip(id: "__esc", text: KeyByteMap.bytes(for: .esc), label: "Esc", + appendEnter: false), + ] + + /// Prefix for user-created chip ids (web: `user__`; iOS: UUID). + static let userChipIdPrefix = "user_" + + /// The full byte string a chip tap sends: text plus — when `appendEnter` — + /// the Enter byte from `KeyByteMap` (`\r`, 0x0D, NOT `\n`; CLAUDE.md gotcha). + static func payload(for chip: QuickReplyChip) -> String { + chip.text + (chip.appendEnter ? KeyByteMap.bytes(for: .enter) : "") + } + + /// New array with `chip` appended (web `addChip`). + static func adding(_ chips: [QuickReplyChip], _ chip: QuickReplyChip) -> [QuickReplyChip] { + chips + [chip] + } + + /// New array with the chip matching `id` removed (web `removeChip`). + static func removing(_ chips: [QuickReplyChip], id: String) -> [QuickReplyChip] { + chips.filter { $0.id != id } + } + + /// New array with the chip at `fromIndex` moved to `toIndex` (web + /// `reorderChip` splice semantics). Either index out of bounds → the input + /// returned unchanged. Operates on a local copy — value semantics + /// guarantee the caller's array is never touched. + static func reordering( + _ chips: [QuickReplyChip], fromIndex: Int, toIndex: Int + ) -> [QuickReplyChip] { + guard chips.indices.contains(fromIndex), chips.indices.contains(toIndex) else { + return chips + } + var result = chips + let moved = result.remove(at: fromIndex) + result.insert(moved, at: toIndex) + return result + } + + /// New array where the chip with `id` has non-nil fields patched (web + /// `updateChip`: shallow merge, id immutable). Unknown id → unchanged. + static func updating( + _ chips: [QuickReplyChip], id: String, + text: String? = nil, label: String? = nil, appendEnter: Bool? = nil + ) -> [QuickReplyChip] { + chips.map { chip in + guard chip.id == id else { return chip } + return QuickReplyChip( + id: chip.id, + text: text ?? chip.text, + label: label ?? chip.label, + appendEnter: appendEnter ?? chip.appendEnter + ) + } + } +} + +/// Per-item lossy decode wrapper (mirror of the web `filter(isValidChip)`): +/// one malformed entry is dropped, the well-formed rest survive — stored data +/// crosses a storage boundary and is never trusted (LastSessionStore pattern). +private struct LossyQuickReplyChip: Decodable { + let chip: QuickReplyChip? + + init(from decoder: any Decoder) throws { + chip = try? QuickReplyChip(from: decoder) + } +} + +/// Observable palette store: holds the user chips, persists every change to +/// the injected `UserDefaults` and exposes the render list (built-ins first, +/// then user chips — web render order). +@MainActor +@Observable +final class QuickReplyStore { + /// Single UserDefaults key for the encoded user palette + /// (web: localStorage `web-terminal:quick-reply-palette`). + static let paletteDefaultsKey = "quickReplyPalette" + + private(set) var userChips: [QuickReplyChip] + + @ObservationIgnored private let defaults: UserDefaults + + /// Built-in chips first, then the user palette (web `render()` order). + var allChips: [QuickReplyChip] { + QuickReplyPalette.builtInChips + userChips + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + userChips = Self.loadPalette(from: defaults) + } + + /// Add a new phrase (web editor `onSaveClick` semantics): text/label are + /// trimmed, empty text is a no-op (returns false), empty label defaults to + /// the text, the id is minted with the `user_` prefix. + @discardableResult + func addChip(text: String, label: String, appendEnter: Bool) -> Bool { + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty else { return false } + let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + let chip = QuickReplyChip( + id: QuickReplyPalette.userChipIdPrefix + UUID().uuidString, + text: trimmedText, + label: trimmedLabel.isEmpty ? trimmedText : trimmedLabel, + appendEnter: appendEnter + ) + setUserChips(QuickReplyPalette.adding(userChips, chip)) + return true + } + + func removeChip(id: String) { + setUserChips(QuickReplyPalette.removing(userChips, id: id)) + } + + func moveChip(fromIndex: Int, toIndex: Int) { + setUserChips(QuickReplyPalette.reordering( + userChips, fromIndex: fromIndex, toIndex: toIndex + )) + } + + /// SwiftUI `List.onMove` adapter (IndexSet + insert-before destination on + /// the ORIGINAL array — `Array.move` implements that convention). + func moveChips(fromOffsets: IndexSet, toOffset: Int) { + var next = userChips + next.move(fromOffsets: fromOffsets, toOffset: toOffset) + setUserChips(next) + } + + func updateChip( + id: String, text: String? = nil, label: String? = nil, appendEnter: Bool? = nil + ) { + setUserChips(QuickReplyPalette.updating( + userChips, id: id, text: text, label: label, appendEnter: appendEnter + )) + } + + /// Load the persisted palette. Missing key, non-JSON data or a non-array + /// root → `[]`; malformed items are dropped per-entry. Never throws + /// (web `loadPalette` contract). + static func loadPalette(from defaults: UserDefaults) -> [QuickReplyChip] { + guard let data = defaults.data(forKey: paletteDefaultsKey) else { return [] } + guard let lossy = try? JSONDecoder().decode([LossyQuickReplyChip].self, from: data) + else { return [] } + return lossy.compactMap(\.chip) + } + + // MARK: - Private + + private func setUserChips(_ chips: [QuickReplyChip]) { + userChips = chips + persist(chips) + } + + /// Best-effort save (web `savePalette` contract: storage failure never + /// throws or crashes — the in-memory palette stays authoritative for the + /// session). Encoding a `[QuickReplyChip]` cannot practically fail. + private func persist(_ chips: [QuickReplyChip]) { + guard let data = try? JSONEncoder().encode(chips) else { return } + defaults.set(data, forKey: Self.paletteDefaultsKey) + } +} diff --git a/ios/App/WebTerm/Components/ReconnectBanner.swift b/ios/App/WebTerm/Components/ReconnectBanner.swift index 67a5edf..fb0842e 100644 --- a/ios/App/WebTerm/Components/ReconnectBanner.swift +++ b/ios/App/WebTerm/Components/ReconnectBanner.swift @@ -19,6 +19,11 @@ struct ReconnectBanner: View { } let model: Model + /// T-iOS-29 · "开新会话" on the EXITED banner only (session over → the + /// natural next step; manager.ts:145-153 — the old session is done for + /// good). nil = affordance hidden. Not offered on `.failed`: that copy + /// asks the user to change a knob first, not to spawn again. + var onNewSession: (@MainActor () -> Void)? = nil private enum Metrics { static let contentSpacing: CGFloat = 8 @@ -27,6 +32,10 @@ struct ReconnectBanner: View { static let backgroundOpacity = 0.9 } + private enum Copy { + static let newSession = "开新会话" + } + var body: some View { HStack(spacing: Metrics.contentSpacing) { if isSpinning { @@ -38,6 +47,12 @@ struct ReconnectBanner: View { Text(text) .font(.footnote) .multilineTextAlignment(.leading) + if let onNewSession, Self.isNewSessionActionAvailable(for: model) { + Button(Copy.newSession) { onNewSession() } + .font(.footnote.bold()) + .buttonStyle(.plain) + .accessibilityIdentifier("terminal.exitNewSessionButton") + } } .padding(.horizontal, Metrics.horizontalPadding) .padding(.vertical, Metrics.verticalPadding) @@ -46,6 +61,13 @@ struct ReconnectBanner: View { .accessibilityElement(children: .combine) } + /// The new-session affordance appears on the `.exited` banner only — + /// pure predicate so the T-iOS-29 tests pin the rule. + static func isNewSessionActionAvailable(for model: Model) -> Bool { + if case .exited = model { return true } + return false + } + private var isSpinning: Bool { switch model { case .connecting, .reconnecting: return true diff --git a/ios/App/WebTerm/Components/SessionThumbnail.swift b/ios/App/WebTerm/Components/SessionThumbnail.swift new file mode 100644 index 0000000..335b3e9 --- /dev/null +++ b/ios/App/WebTerm/Components/SessionThumbnail.swift @@ -0,0 +1,365 @@ +import APIClient +import Foundation +import os +import SwiftUI +import UIKit +import WireProtocol + +/// T-iOS-28 · 会话缩略图管线(`GET /live-sessions/:id/preview` → 离屏 +/// SwiftTerm → 快照 UIImage → 列表行)。本文件是纯逻辑侧:请求/键、LRU 缓存、 +/// 并发上限闸、管线与 SwiftUI 槽位;真正的离屏渲染在 +/// `SessionThumbnailRenderer.swift`(唯一 import SwiftTerm 的一侧)。 +/// +/// 设计要点(对应 Steps): +/// - **缓存键 = (sessionId, lastOutputAt)**:`lastOutputAt`(T-iOS-37)不变 = +/// 会话无新输出 = 缩略图不可能变 → 绝不重渲染。nil(P1 前旧服务器)意味着 +/// 没有失效信号:渲染一次后永久命中——宁可陈旧也不随每次 5s 轮询无界重 +/// 渲染(文档化降级)。 +/// - **并发上限**:离屏渲染要 spawn 一个完整 SwiftTerm 终端,列表滚动绝不能 +/// 无界并发——`SessionThumbnailRenderGate`(FIFO、permit 转移)把「取数 + +/// 渲染」整段管线钳在 `maxConcurrentRenders`。 +/// - **失败也缓存**:404/网络错/校验失败 → `.placeholder` 同键入缓存,滚动 +/// 不会打爆故障主机;会话一有新输出(新 lastOutputAt)自然重试。 +/// - **服务器是不可信输入源**:preview 的 `data` 只喂 SwiftTerm(它就是 ANSI +/// 解释器),永不字符串处理;但 id/几何/字节数在边界校验,不合格拒渲染。 +struct SessionThumbnailRequest: Equatable, Sendable { + let endpoint: HostEndpoint + let sessionId: UUID + /// 服务器 `LiveSessionInfo.lastOutputAt`(ms since epoch;可选新增字段)。 + let lastOutputAt: Int? + + var key: SessionThumbnailKey { + SessionThumbnailKey(sessionId: sessionId, lastOutputAt: lastOutputAt) + } +} + +/// 缓存身份:同键 = 「同一会话且自上次渲染以来没有任何新输出」。 +struct SessionThumbnailKey: Hashable, Sendable { + let sessionId: UUID + let lastOutputAt: Int? +} + +/// 管线产物。`Equatable` 对 `.rendered` 按图像身份比较(同一缓存条目 ===)。 +enum SessionThumbnailImage: Sendable { + case rendered(UIImage) + case placeholder + + var isPlaceholder: Bool { + if case .placeholder = self { return true } + return false + } + + var uiImage: UIImage? { + if case .rendered(let image) = self { return image } + return nil + } +} + +extension SessionThumbnailImage: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.placeholder, .placeholder): return true + case (.rendered(let a), .rendered(let b)): return a === b + default: return false + } + } +} + +/// 本 feature 的具名常量(局部常量;WireProtocol 的 `Tunables` 已冻结,不动)。 +enum SessionThumbnailTunable { + /// 同时在飞的「取数 + 离屏渲染」管线数上限——滚动不得无界 spawn 终端。 + static let maxConcurrentRenders = 2 + /// LRU 缓存条目上限(每条 ≈ 一张 2x 小快照,内存有界)。 + static let maxCacheEntries = 32 + /// 防御性字节上限:服务器默认 tail 24 KiB(src/config.ts:46 + /// DEFAULT_PREVIEW_BYTES,env `PREVIEW_BYTES` 可调)——留 ~10× 余量; + /// 超限视为异常主机,拒渲染(喂给解释器前唯一的一次尺寸检查)。 + static let maxPreviewDataBytes = 256 * 1024 + /// 缩略图渲染网格上限:几何是不可信输入,钳制它把离屏绘制的行数/位图 + /// 尺寸钳在有界范围(真实终端极少超过 300 列;超宽会话只损失换行保真)。 + static let maxRenderCols = 300 + static let maxRenderRows = 120 + /// 镜像 web 预览的 `Math.max(2, …)`(public/preview-grid.ts:112-117)。 + static let minRenderGrid = 2 +} + +// MARK: - LRU 缓存(不可变快照;持有方整体替换) + +/// 定容 LRU:`order` 尾部为最近使用。所有操作返回新副本(不可变风格)。 +struct SessionThumbnailCache { + let maxEntries: Int + private let entries: [SessionThumbnailKey: SessionThumbnailImage] + private let order: [SessionThumbnailKey] + + init(maxEntries: Int) { + self.init(maxEntries: maxEntries, entries: [:], order: []) + } + + private init( + maxEntries: Int, + entries: [SessionThumbnailKey: SessionThumbnailImage], + order: [SessionThumbnailKey] + ) { + self.maxEntries = max(1, maxEntries) + self.entries = entries + self.order = order + } + + func value(for key: SessionThumbnailKey) -> SessionThumbnailImage? { + entries[key] + } + + /// 命中后前移(most-recently-used)。未含该键则原样返回。 + func bumping(_ key: SessionThumbnailKey) -> SessionThumbnailCache { + guard entries[key] != nil else { return self } + return SessionThumbnailCache( + maxEntries: maxEntries, entries: entries, + order: order.filter { $0 != key } + [key] + ) + } + + /// 插入并按容量逐出 least-recently-used。 + func inserting( + _ image: SessionThumbnailImage, for key: SessionThumbnailKey + ) -> SessionThumbnailCache { + var newEntries = entries.merging([key: image]) { _, new in new } + var newOrder = order.filter { $0 != key } + [key] + while newOrder.count > maxEntries, let oldest = newOrder.first { + newOrder = Array(newOrder.dropFirst()) + newEntries = newEntries.filter { $0.key != oldest } + } + return SessionThumbnailCache( + maxEntries: maxEntries, entries: newEntries, order: newOrder + ) + } +} + +// MARK: - 并发上限闸(FIFO、permit 转移) + +/// 渲染管线的并发闸:`acquire` 超限即按 FIFO 排队;`release` 时若有排队者, +/// permit 原地转移(activeCount 不变)。测试屏障 `waitUntilWaiting` 与 +/// FakeClock.waitForSleepers 同手法——continuation,零轮询零真睡。 +@MainActor +final class SessionThumbnailRenderGate { + let limit: Int + private(set) var activeCount = 0 + private(set) var peakActiveCount = 0 + private var waiters: [CheckedContinuation] = [] + private var barriers: [(target: Int, cont: CheckedContinuation)] = [] + + var waitingCount: Int { waiters.count } + + init(limit: Int) { + self.limit = max(1, limit) + } + + func acquire() async { + if activeCount < limit { + activeCount += 1 + peakActiveCount = max(peakActiveCount, activeCount) + return + } + await withCheckedContinuation { cont in + waiters = waiters + [cont] + notifyBarriers() + } + // 被 release() 唤醒 = permit 已转移给我们,activeCount 保持不变。 + peakActiveCount = max(peakActiveCount, activeCount) + } + + func release() { + guard waiters.isEmpty else { + let next = waiters[0] + waiters = Array(waiters.dropFirst()) + next.resume() // permit 转移,activeCount 不动 + return + } + activeCount = max(0, activeCount - 1) + } + + /// 测试屏障:挂起直到至少 `count` 个 acquire 在排队。 + func waitUntilWaiting(count: Int) async { + if waiters.count >= count { return } + await withCheckedContinuation { cont in + barriers = barriers + [(count, cont)] + } + } + + private func notifyBarriers() { + let met = barriers.filter { $0.target <= waiters.count } + barriers = barriers.filter { $0.target > waiters.count } + for barrier in met { barrier.cont.resume() } + } +} + +// MARK: - 管线 + +@MainActor +final class SessionThumbnailPipeline { + /// 取数缝:生产侧 = `APIClient.preview(id:)`(RO GET,无 Origin)。 + typealias PreviewLoader = @MainActor (SessionThumbnailRequest) async throws -> SessionPreview + /// 渲染缝:生产侧 = `SessionThumbnailRenderer.render`(离屏 SwiftTerm)。 + /// 入参几何已经过 `clampedGeometry` 钳制。 + typealias PreviewRenderer = @MainActor (_ data: String, _ cols: Int, _ rows: Int) -> UIImage? + + private let loader: PreviewLoader + private let renderer: PreviewRenderer + private let gate: SessionThumbnailRenderGate + private var cache: SessionThumbnailCache + /// 同键并发去重:第二个请求 await 第一个的任务,绝不双渲染。 + private var inFlight: [SessionThumbnailKey: Task] = [:] + private let logger = Logger( + subsystem: SessionThumbnailLog.subsystem, category: SessionThumbnailLog.category + ) + + init( + loader: @escaping PreviewLoader, + renderer: @escaping PreviewRenderer, + gate: SessionThumbnailRenderGate? = nil, + maxCacheEntries: Int = SessionThumbnailTunable.maxCacheEntries + ) { + self.loader = loader + self.renderer = renderer + self.gate = gate + ?? SessionThumbnailRenderGate(limit: SessionThumbnailTunable.maxConcurrentRenders) + cache = SessionThumbnailCache(maxEntries: maxCacheEntries) + } + + /// 生产装配:共享一个 ephemeral URLSession(preview 字节可能含屏上密钥, + /// 内存缓存 only——同 T-iOS-19 对 RO GET 的裁定)。 + static func live() -> SessionThumbnailPipeline { + SessionThumbnailPipeline( + loader: { request in + try await APIClient(endpoint: request.endpoint, http: liveTransport) + .preview(id: request.sessionId) + }, + renderer: { data, cols, rows in + SessionThumbnailRenderer.render(data: data, cols: cols, rows: rows) + } + ) + } + + private static let liveTransport = URLSessionHTTPTransport() + + /// 取(或渲染)一张缩略图。永不抛错:任何失败显式降级为 `.placeholder` + /// (占位图就是缩略图的错误 UI;细节进内部日志,绝不静默无痕)。 + func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage { + let key = request.key + if let hit = cache.value(for: key) { + cache = cache.bumping(key) + return hit + } + if let running = inFlight[key] { + return await running.value + } + let task = Task { await produce(request) } + inFlight = inFlight.merging([key: task]) { _, new in new } + let result = await task.value + inFlight = inFlight.filter { $0.key != key } + cache = cache.inserting(result, for: key) + return result + } + + private func produce(_ request: SessionThumbnailRequest) async -> SessionThumbnailImage { + await gate.acquire() + defer { gate.release() } + let preview: SessionPreview + do { + preview = try await loader(request) + } catch { + logger.debug("preview fetch failed: \(String(describing: error), privacy: .public)") + return .placeholder + } + guard Self.isAcceptable(preview, for: request), + let grid = Self.clampedGeometry(cols: preview.cols, rows: preview.rows) else { + logger.debug("preview rejected at boundary (id/bytes/geometry)") + return .placeholder + } + guard let image = renderer(preview.data, grid.cols, grid.rows) else { + logger.debug("offscreen snapshot returned nil") + return .placeholder + } + return .rendered(image) + } + + // MARK: - 不可信边界校验(data 本身只喂 SwiftTerm,绝不内容处理) + + /// id 必须回显请求的会话;字节数必须在防御上限内。 + static func isAcceptable( + _ preview: SessionPreview, for request: SessionThumbnailRequest + ) -> Bool { + preview.id == request.sessionId + && preview.data.utf8.count <= SessionThumbnailTunable.maxPreviewDataBytes + } + + /// 几何校验 + 钳制:出服务器 resize 契约(`WireConstants.resizeRange`, + /// 1...1000)→ nil(拒渲染);合法值钳到缩略图网格界(min 镜像 web + /// `max(2,·)`,max 钳位图/绘制成本)。 + static func clampedGeometry(cols: Int, rows: Int) -> (cols: Int, rows: Int)? { + guard Validation.isValidResize(cols: cols, rows: rows) else { return nil } + let clampedCols = min( + max(cols, SessionThumbnailTunable.minRenderGrid), + SessionThumbnailTunable.maxRenderCols + ) + let clampedRows = min( + max(rows, SessionThumbnailTunable.minRenderGrid), + SessionThumbnailTunable.maxRenderRows + ) + return (clampedCols, clampedRows) + } +} + +// MARK: - 列表行槽位(SwiftUI) + +/// 会话列表行里的缩略图槽。`.task(id: key)` 驱动:lastOutputAt 变化自动换键 +/// 重取;缓存命中时立即出图。占位态(加载中 / 失败 / 会话已无预览)统一为 +/// 终端图标卡片——对一张缩略图,占位就是全部错误 UI。 +struct SessionThumbnailView: View { + let request: SessionThumbnailRequest + let pipeline: SessionThumbnailPipeline + @State private var image: SessionThumbnailImage? + + private enum Metrics { + static let width: CGFloat = 88 + static let height: CGFloat = 56 + static let cornerRadius: CGFloat = 6 + /// 镜像 web 预览卡的暗底(public/preview-grid.ts PREVIEW_THEME)。 + static let placeholderBackground = Color( + red: 14 / 255, green: 15 / 255, blue: 19 / 255 + ) + } + + var body: some View { + content + .frame(width: Metrics.width, height: Metrics.height) + .clipShape(RoundedRectangle(cornerRadius: Metrics.cornerRadius)) + .accessibilityLabel(SessionThumbnailCopy.thumbnailLabel) + .task(id: request.key) { + image = await pipeline.thumbnail(for: request) + } + } + + @ViewBuilder private var content: some View { + if let snapshot = image?.uiImage { + Image(uiImage: snapshot) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + ZStack { + Metrics.placeholderBackground + Image(systemName: "terminal") + .foregroundStyle(.secondary) + } + } + } +} + +/// 用户可见文案(中文具名常量)。 +enum SessionThumbnailCopy { + static let thumbnailLabel = "会话画面缩略图" +} + +private enum SessionThumbnailLog { + static let subsystem = "com.yaojia.webterm" + static let category = "session-thumbnail" +} diff --git a/ios/App/WebTerm/Components/SessionThumbnailRenderer.swift b/ios/App/WebTerm/Components/SessionThumbnailRenderer.swift new file mode 100644 index 0000000..1959c09 --- /dev/null +++ b/ios/App/WebTerm/Components/SessionThumbnailRenderer.swift @@ -0,0 +1,85 @@ +import SwiftTerm +import UIKit + +/// T-iOS-28 · 离屏 SwiftTerm 渲染器:把一段 preview tail(不可信 ANSI 字节, +/// **只喂终端解释器,绝不字符串处理**)喂进一个从不进视图层级的 +/// `TerminalView`,再用 `UIGraphicsImageRenderer` 对已布局的 view 出快照。 +/// +/// 与 web 预览逐点对齐(public/preview-grid.ts makePreviewCard/renderPreview): +/// - 网格 = 服务器回报的 cols×rows(上游已钳制)——换行与真屏一致; +/// - `scrollback: 0` ↔ `changeScrollback(nil)`:buffer 只有当前屏, +/// `contentOffset` 恒为 0,离屏 draw 恰取「现在这一屏」; +/// - 暗底主题 + 隐藏光标(web 用 cursor==background;此处喂 DECTCEM 隐藏序列, +/// iOS 端直接把 caret 子视图摘掉); +/// - 快照按 `snapshotTargetWidth` 缩放——缓存的是小位图,不是整格点阵。 +/// +/// 生命周期:`TerminalView` 在初始化时向主 runloop 挂 CADisplayLink(暂停态), +/// 离屏用完必须 `updateUiClosed()` 注销,否则每次渲染泄漏一个 runloop 源。 +@MainActor +enum SessionThumbnailRenderer { + /// 快照宽度预算(pt):列表槽位 88pt 的 2 倍,配 `snapshotScale`=2 后 + /// 像素密度足够,单张缓存 ≈ 数百 KB 有界。 + static let snapshotTargetWidth: CGFloat = 176 + /// 固定 2x:快照是缩略图,不追设备 3x(内存 × 2.25 不值)。 + static let snapshotScale: CGFloat = 2 + /// 镜像 web 预览字号(public/preview-grid.ts fontSize: 12)。 + static let fontSize: CGFloat = 12 + /// 布局余量(pt):frame 取 optimal + slack,抵消 `Int(width/cellW)` 的 + /// 浮点截断——必须严格小于一个 cell 宽,否则网格会多出一列。 + static let layoutSlack: CGFloat = 0.5 + /// DECTCEM 隐藏光标(数据喂完后追加;iOS 端会摘掉 caret 子视图)。 + /// 这是我们自己的常量序列,不是对服务器数据的处理。 + static let hideCursorSequence = "\u{1b}[?25l" + /// 镜像 web PREVIEW_THEME(#0e0f13 / #e7e8ec)。 + static let backgroundColor = UIColor( + red: 14 / 255, green: 15 / 255, blue: 19 / 255, alpha: 1 + ) + static let foregroundColor = UIColor( + red: 231 / 255, green: 232 / 255, blue: 236 / 255, alpha: 1 + ) + private static let initialProbeFrame = CGRect(x: 0, y: 0, width: 64, height: 64) + + /// 渲染一张快照。`cols`/`rows` 必须已过 `clampedGeometry`(有界);失败 + /// 返回 nil(上游显式降级为占位图)。 + static func render(data: String, cols: Int, rows: Int) -> UIImage? { + let font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + let terminal = TerminalView(frame: initialProbeFrame, font: font) + defer { terminal.updateUiClosed() } // 注销 CADisplayLink,绝不泄漏 + terminal.changeScrollback(nil) // ↔ web scrollback: 0 + terminal.resize(cols: cols, rows: rows) + terminal.nativeBackgroundColor = backgroundColor + terminal.nativeForegroundColor = foregroundColor + + let optimal = terminal.getOptimalFrameSize() + guard optimal.width > 0, optimal.height > 0 else { return nil } + terminal.frame = CGRect( + x: 0, y: 0, + width: optimal.width + layoutSlack, height: optimal.height + layoutSlack + ) + terminal.layoutIfNeeded() + + terminal.feed(text: data) // 唯一合法去处:ANSI 解释器 + terminal.feed(text: hideCursorSequence) + + return snapshot(of: terminal, contentSize: optimal.size) + } + + /// `UIGraphicsImageRenderer` + `layer.render(in:)`:离屏(无 window)视图 + /// 走 CoreGraphics draw 路径(SwiftTerm 默认不启 Metal——Metal 层无法被 + /// `layer.render` 捕获,此处也永不 `setUseMetal`)。 + private static func snapshot(of view: UIView, contentSize: CGSize) -> UIImage? { + let scale = min(1, snapshotTargetWidth / contentSize.width) + let size = CGSize( + width: contentSize.width * scale, height: contentSize.height * scale + ) + guard size.width >= 1, size.height >= 1 else { return nil } + let format = UIGraphicsImageRendererFormat() + format.scale = snapshotScale + format.opaque = true + let renderer = UIGraphicsImageRenderer(size: size, format: format) + return renderer.image { context in + context.cgContext.scaleBy(x: scale, y: scale) + view.layer.render(in: context.cgContext) + } + } +} diff --git a/ios/App/WebTerm/DeepLinkRouter.swift b/ios/App/WebTerm/DeepLinkRouter.swift new file mode 100644 index 0000000..fb26f3b --- /dev/null +++ b/ios/App/WebTerm/DeepLinkRouter.swift @@ -0,0 +1,251 @@ +import Foundation +import HostRegistry +import Observation +import OSLog +import WireProtocol + +/// T-iOS-22 · Deep-link routing. +/// +/// Two external entry points share ONE validation surface: +/// - `webterminal://open?host=&join=` (scheme registered in +/// project.yml `CFBundleURLTypes`; web 分享 QR 互通的 `?join=` 解析是 +/// T-iOS-35 的增量), and +/// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses +/// `route(from:)` — same UUID rules, no second parser). +/// +/// 安全注 (plan T-iOS-22): a deep link is EXTERNAL input. Every field is +/// whitelist-validated (scheme / action / query keys / UUID v4 via the frozen +/// `Validation.isValidSessionId` — never re-regexed); ANY invalid part → +/// `.ignore` + counter (never crash, never partially apply). The host id +/// resolves ONLY through a `HostStore` lookup (`DeepLinkHandler`) — no URL or +/// request is ever built from link contents. +enum DeepLinkRouter { + /// Parse outcome. `.openSession` carries RAW validated ids — host + /// EXISTENCE is resolved later against the store (`DeepLinkHandler`). + enum Route: Equatable, Sendable { + /// `webterminal://open` with both ids syntactically valid. + case openSession(hostId: UUID, sessionId: UUID) + /// Push payload with a valid `sessionId` (no host id in the payload — + /// T-iOS-21's notification handler owns the resolution strategy). + case gateSession(sessionId: UUID) + /// Anything else. Callers count + log; they never partially apply. + case ignore + } + + /// Whitelisted URL shape (`webterminal://open`). Scheme and URL host are + /// case-insensitive per RFC 3986 → compared lowercased. + private enum LinkShape { + static let scheme = "webterminal" + static let action = "open" + static let emptyPaths: Set = ["", "/"] + } + + /// Whitelisted query keys (exact match; unknown keys are ignored). + private enum QueryKey { + static let host = "host" + static let join = "join" + } + + /// Root key of the APNs payload (server contract, T-iOS-20 final shape). + private enum PushKey { + static let sessionId = "sessionId" + } + + // MARK: - URL entry (onOpenURL) + + static func route(url: URL) -> Route { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme?.lowercased() == LinkShape.scheme, + components.host?.lowercased() == LinkShape.action, + LinkShape.emptyPaths.contains(components.path), + let hostId = uniqueValidatedId(in: components, key: QueryKey.host), + let sessionId = uniqueValidatedId(in: components, key: QueryKey.join) + else { return .ignore } + return .openSession(hostId: hostId, sessionId: sessionId) + } + + // MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21) + + static func route(from payload: [AnyHashable: Any]) -> Route { + guard let raw = payload[PushKey.sessionId] as? String, + let sessionId = validatedId(raw) + else { return .ignore } + return .gateSession(sessionId: sessionId) + } + + // MARK: - Field validation + + /// Exactly ONE occurrence of `key`, and its value is a v4 UUID. + /// Duplicates are ambiguous input → nil (never partially apply). + private static func uniqueValidatedId(in components: URLComponents, key: String) -> UUID? { + let matches = (components.queryItems ?? []).filter { $0.name == key } + guard matches.count == 1, let raw = matches.first?.value else { return nil } + return validatedId(raw) + } + + /// Frozen-contract v4 check (`Validation.isValidSessionId`, plan §3.1) + /// FIRST — `UUID(uuidString:)` alone would admit non-v4 ids the server's + /// SESSION_ID_RE rejects. + private static func validatedId(_ raw: String) -> UUID? { + guard Validation.isValidSessionId(raw) else { return nil } + return UUID(uuidString: raw) + } +} + +/// User-facing deep-link copy (plan §4: 显式、可操作的话术). +enum DeepLinkCopy { + static let hintTitle = "无法打开链接" + static let unknownHostHint = "链接指向的主机尚未配对,请先扫码完成配对。" + static let hostLoadFailed = "读取已配对主机失败,无法打开链接,请重进 App 后再试。" + static let hintConfirm = "好" +} + +/// Applies parsed routes to the app: host-store lookup (the ONLY host-id +/// resolution point), the cold-launch stash, the unknown-host → pairing hint, +/// and the invalid-link counter. Pure routing stays in `DeepLinkRouter`; this +/// class owns the stateful edges so `AppCoordinator`'s diff stays minimal. +@MainActor +@Observable +final class DeepLinkHandler { + /// Coordinator-provided effects (closures — the handler never reaches + /// into navigation state itself). + struct Actions { + /// Open `sessionId` on a RESOLVED, store-known host. + let openSession: @MainActor (HostRegistry.Host, UUID) -> Void + /// Unknown host id → surface the pairing flow. + let showPairing: @MainActor () -> Void + } + + /// Alert copy for RootView (unknown host / store failure). nil = no alert. + private(set) var hintMessage: String? + /// Invalid deep links dropped so far (plan: `.ignore` + log counter). + private(set) var ignoredCount = 0 + + @ObservationIgnored private let loadHosts: @Sendable () async throws -> [HostRegistry.Host] + @ObservationIgnored private let actions: Actions + /// Cold-launch gate: `handle(url:)` before `markReady()` stashes instead + /// of applying (the host list / root route may not exist yet). + @ObservationIgnored private var isReady = false + /// Single-slot stash — a newer link before readiness replaces the older + /// one (two half-applied navigations would be worse than dropping one). + @ObservationIgnored private var pendingRoute: DeepLinkRouter.Route? + @ObservationIgnored private let logger = Logger( + subsystem: DeepLinkLog.subsystem, category: DeepLinkLog.category + ) + + init( + loadHosts: @escaping @Sendable () async throws -> [HostRegistry.Host], + actions: Actions + ) { + self.loadHosts = loadHosts + self.actions = actions + } + + // MARK: - Entry points + + /// `.onOpenURL` lands here (via `AppCoordinator.handleDeepLink`). Invalid + /// links are counted and dropped — they are NEVER stashed. + func handle(url: URL) async { + let route = DeepLinkRouter.route(url: url) + guard route != .ignore else { + recordIgnored() + return + } + guard isReady else { + pendingRoute = route + return + } + await apply(route) + } + + /// Cold-start bootstrap finished (root route decided) → flush the stash. + /// Idempotent: the slot is cleared before applying, so a second call + /// never replays. + func markReady() async { + isReady = true + guard let route = pendingRoute else { return } + pendingRoute = nil + await apply(route) + } + + func clearHint() { + hintMessage = nil + } + + // MARK: - Apply (host id resolves ONLY through the store) + + private func apply(_ route: DeepLinkRouter.Route) async { + // `.gateSession` never reaches here in P1: it only exists for the + // push path, whose handling (host resolution incl.) is T-iOS-21. + guard case let .openSession(hostId, sessionId) = route else { return } + do { + let hosts = try await loadHosts() + guard let host = hosts.first(where: { $0.id == hostId }) else { + hintMessage = DeepLinkCopy.unknownHostHint + actions.showPairing() + return + } + actions.openSession(host, sessionId) + } catch { + // Explicit failure copy — a broken store must never look like a + // silently dead link (plan §4 error-handling rule). + hintMessage = DeepLinkCopy.hostLoadFailed + logger.error("deep link host-store read failed: \(error)") + } + } + + private func recordIgnored() { + ignoredCount += 1 + // URL contents are untrusted external input — log the counter only, + // never echo the link (log-injection / privacy hygiene). + logger.notice("ignored invalid deep link (total: \(self.ignoredCount))") + } +} + +private enum DeepLinkLog { + static let subsystem = "com.yaojia.webterm" + static let category = "deep-link" +} + +// MARK: - AppCoordinator wiring (kept here so the coordinator diff stays tiny) + +extension AppCoordinator { + /// Factory for the coordinator's lazy `deepLink` property. + func makeDeepLinkHandler() -> DeepLinkHandler { + DeepLinkHandler( + loadHosts: { [environment] in try await environment.hostStore.loadAll() }, + actions: DeepLinkHandler.Actions( + openSession: { [weak self] host, sessionId in + self?.openDeepLinkedSession(host: host, sessionId: sessionId) + }, + showPairing: { [weak self] in + self?.showPairingForDeepLink() + } + ) + ) + } + + /// RootView's `.onOpenURL` entry (sync SwiftUI context → async apply). + /// Fires for BOTH warm links and cold launches — on cold start the + /// handler stashes until `bootstrap()` calls `markReady()`. + func handleDeepLink(url: URL) { + Task { await deepLink.handle(url: url) } + } + + /// Deep links may target a session while another terminal is open: + /// detach the current one first (`open` is a no-op otherwise — its + /// one-foreground-session guard would swallow the link). + private func openDeepLinkedSession(host: HostRegistry.Host, sessionId: UUID) { + if terminalController != nil { + closeTerminal() + } + open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId)) + } + + /// Unknown host id → pairing. On the first-run pairing route the pairing + /// screen is already frontmost — only the list route needs the sheet. + private func showPairingForDeepLink() { + guard route == .sessions else { return } + presentAddHost() + } +} diff --git a/ios/App/WebTerm/DiffFetcher.swift b/ios/App/WebTerm/DiffFetcher.swift new file mode 100644 index 0000000..30b769d --- /dev/null +++ b/ios/App/WebTerm/DiffFetcher.swift @@ -0,0 +1,234 @@ +import Foundation +import WireProtocol + +// T-iOS-27 · App-layer fetcher for `GET /projects/diff?path=&staged=` —— the +// read-only structured git diff (server route src/server.ts:601-618, parser +// src/http/diff.ts). iOS mirrors the web split: parsing lives ONLY server-side; +// this file fetches + tolerantly decodes, the screen renders verbatim. +// +// 为什么不在 APIClient 包里:APIClient 是 T-iOS-38 单一 owner(PLAN §6 Owns +// 铁律),本任务不得改包。此 fetcher 走同一枚注入的 `HTTPTransport`,语义与 +// APIClient 路由构建完全同构 —— **回收进 APIClient 是 T-iOS-38 owner 的 +// follow-up**(届时本文件的路由/编码/解码可整体搬迁,测试随行)。 +// +// Origin 铁律(plan §3.4/§5.1,iff-G):`/projects/diff` 是 RO 端点 —— 路由层 +// 没有 requireAllowedOrigin(src/server.ts:601 注释 "no Origin guard; same +// threat model as /projects"),因此请求**绝不携带 Origin**。若服务器未来把它 +// 改为 G,测试先红而不是靠巧合通过。 +// +// 服务器是不可信输入源(plan §4):解码逐层宽容,镜像 web 的 +// normalizeDiffResult(public/diff.ts:38-102)—— 顶层三键缺一 → 整体无效; +// 畸形 file/hunk/line 逐条丢弃;未知枚举值降级(kind → .context、status → +// .modified),绝不 crash。 + +// MARK: - 模型(镜像 src/types.ts:445-479;App 内部,待 T-iOS-38 回收) + +/// One diff line's semantic kind. Unknown wire values degrade to `.context` +/// (the web renderer's `?? 'df-context'` fallback, public/diff.ts:192). +enum DiffLineKind: String, Sendable, Equatable { + case added, removed, context, hunk, meta +} + +struct DiffLine: Sendable, Equatable { + let kind: DiffLineKind + /// Verbatim server bytes (marker already stripped server-side). UNTRUSTED + /// display input — render via `Text(verbatim:)` only, never Markdown/links. + let text: String +} + +struct DiffHunk: Sendable, Equatable { + let header: String + let lines: [DiffLine] +} + +/// File-level change status. Unknown wire values degrade to `.modified` +/// (display-only field; the web keeps the raw string for a CSS class). +enum DiffFileStatus: String, Sendable, Equatable { + case modified, added, deleted, renamed, binary, untracked +} + +struct DiffFile: Sendable, Equatable { + let oldPath: String + let newPath: String + let status: DiffFileStatus + let added: Int + let removed: Int + let binary: Bool + let hunks: [DiffHunk] +} + +struct DiffResult: Sendable, Equatable { + let files: [DiffFile] + let staged: Bool + let truncated: Bool +} + +// MARK: - 宽容解码(Decodable 在 extension 里,保留成员逐一初始化器) + +extension DiffLine: Decodable { + private enum CodingKeys: String, CodingKey { case kind, text } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // kind/text 必须是字符串,否则整行丢弃(镜像 normalizeLine)。 + let rawKind = try container.decode(String.self, forKey: .kind) + text = try container.decode(String.self, forKey: .text) + kind = DiffLineKind(rawValue: rawKind) ?? .context + } +} + +extension DiffHunk: Decodable { + private enum CodingKeys: String, CodingKey { case header, lines } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + header = try container.decode(String.self, forKey: .header) + let boxes = try container.decode([DiffLossyBox].self, forKey: .lines) + lines = boxes.compactMap(\.value) + } +} + +extension DiffFile: Decodable { + private enum CodingKeys: String, CodingKey { + case oldPath, newPath, status, added, removed, binary, hunks + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + oldPath = try container.decode(String.self, forKey: .oldPath) + newPath = try container.decode(String.self, forKey: .newPath) + added = try container.decode(Int.self, forKey: .added) + removed = try container.decode(Int.self, forKey: .removed) + binary = try container.decode(Bool.self, forKey: .binary) + let rawStatus = try container.decode(String.self, forKey: .status) + status = DiffFileStatus(rawValue: rawStatus) ?? .modified + let boxes = try container.decode([DiffLossyBox].self, forKey: .hunks) + hunks = boxes.compactMap(\.value) + } +} + +extension DiffResult: Decodable { + private enum CodingKeys: String, CodingKey { case files, staged, truncated } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + staged = try container.decode(Bool.self, forKey: .staged) + truncated = try container.decode(Bool.self, forKey: .truncated) + let boxes = try container.decode([DiffLossyBox].self, forKey: .files) + files = boxes.compactMap(\.value) + } +} + +/// Per-element tolerance shim: a malformed element becomes nil instead of +/// failing the whole array. Local twin of APIClient's internal `LossyBox` +/// (not importable across the package boundary) — folds together with the +/// fetcher in the T-iOS-38 follow-up. +private struct DiffLossyBox: Decodable { + let value: Wrapped? + + init(from decoder: any Decoder) { + value = try? Wrapped(from: decoder) + } +} + +// MARK: - 错误分类学(route 层语义,src/server.ts:602-618) + +enum DiffFetchError: Error, Equatable { + /// 空 path / 无法编码 / URL 构建失败 —— 客户端先拒,零网络。 + case invalidRequest + /// 服务器 400:`path query parameter is required`。 + case pathInvalid + /// 服务器 404:SEC-H7 三叉校验失败(绝对路径 + 目录 + 有 .git)。 + case projectNotFound + /// 200 但响应体不是 DiffResult 形状(不可信输入源兜底)。 + case invalidResponse + /// 其余状态(含 500 `failed to read diff`)。 + case unexpectedStatus(Int) +} + +// MARK: - DiffFetcher + +/// Fetch a repo's structured diff over the injected transport. Immutable +/// value; one instance per (endpoint) — the screen's VM closes over it. +struct DiffFetcher: Sendable { + let endpoint: HostEndpoint + private let http: any HTTPTransport + + /// Route constants(无魔法字符串)。`stagedFlag`:服务器精确匹配 + /// `req.query['staged'] === '1'`(src/server.ts:613)——必须发 `1`/`0`。 + /// (对照:web fetchDiff 发 `true`/`false`,永远匹配不上 `'1'` —— + /// staged 开关在 web 端静默失效,已在任务日志上报,不属本任务 Owns。) + private enum Route { + static let path = "/projects/diff" + static let pathKey = "path" + static let stagedKey = "staged" + static let stagedOn = "1" + static let stagedOff = "0" + static let methodGet = "GET" + } + + private enum Status { + static let ok = 200 + static let badRequest = 400 + static let notFound = 404 + } + + /// Strict RFC 3986 unreserved set —— 与 APIClient `Endpoints. + /// unreservedCharacters` 同值同理(裸 `+` 会被 Express qs 解码成空格, + /// `&`/`=` 会拆参);该常量在包内 internal 不可 import,T-iOS-38 回收时合并。 + private static let unreservedCharacters = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + + init(endpoint: HostEndpoint, http: any HTTPTransport) { + self.endpoint = endpoint + self.http = http + } + + /// `GET /projects/diff?path=&staged=` → 宽容解码后的 `DiffResult`。 + /// 空 path 客户端先拒(镜像服务器 400 规则),不产生网络 I/O。 + func fetch(path: String, staged: Bool) async throws -> DiffResult { + guard !path.isEmpty, let request = buildRequest(path: path, staged: staged) else { + throw DiffFetchError.invalidRequest + } + let (data, response) = try await http.send(request) + switch response.statusCode { + case Status.ok: + guard let result = try? JSONDecoder().decode(DiffResult.self, from: data) else { + throw DiffFetchError.invalidResponse + } + return result + case Status.badRequest: + throw DiffFetchError.pathInvalid + case Status.notFound: + throw DiffFetchError.projectNotFound + default: + throw DiffFetchError.unexpectedStatus(response.statusCode) + } + } + + /// Build the RO GET —— 与 APIClient `APIRoute.urlRequest(for:)` 同派生 + /// 哲学:baseURL 只取 scheme/host/port,path/query 整体替换,fragment 与 + /// 凭据丢弃。**不 stamp Origin**(iff-G,见文件头)。 + private func buildRequest(path: String, staged: Bool) -> URLRequest? { + guard let encodedPath = path.addingPercentEncoding( + withAllowedCharacters: Self.unreservedCharacters + ) else { return nil } + guard var components = URLComponents( + url: endpoint.baseURL, resolvingAgainstBaseURL: true + ) else { return nil } + components.path = Route.path + components.query = nil + components.fragment = nil + components.user = nil + components.password = nil + let stagedValue = staged ? Route.stagedOn : Route.stagedOff + components.percentEncodedQuery = + "\(Route.pathKey)=\(encodedPath)&\(Route.stagedKey)=\(stagedValue)" + guard let url = components.url else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = Route.methodGet + return request + } +} diff --git a/ios/App/WebTerm/Push/NotificationActionHandler.swift b/ios/App/WebTerm/Push/NotificationActionHandler.swift new file mode 100644 index 0000000..86c791b --- /dev/null +++ b/ios/App/WebTerm/Push/NotificationActionHandler.swift @@ -0,0 +1,310 @@ +import APIClient +import Foundation +import HostRegistry +import OSLog +import UIKit +import UserNotifications +import WireProtocol + +/// T-iOS-21 · 通知动作处理:锁屏 Allow/Deny(系统**后台拉起主 App** 并送达 +/// `UNUserNotificationCenterDelegate.didReceive`——本工程无 notification +/// extension target,Service Extension 也收不到 action tap)与默认点按路由。 +/// +/// 安全纪律: +/// - push payload 是**不可信外部输入**——sessionId 经 `DeepLinkRouter.route(from:)` +/// (冻结的 v4 白名单,不再造第二个解析器),capability token 校验 v4 形状 +/// (服务器 `randomUUID()` 生成,src/server.ts:445)后**原样透传**(服务器做 +/// 字节级比对),任一非法 → `.invalidPayload` 丢弃计数,绝不部分应用; +/// - token 用后即弃:只存在于解析值 → `hookDecision` 调用参数,绝不落盘、 +/// 绝不进任何兜底文案/日志; +/// - POST 全程包在 begin/endBackgroundTask 里,didReceive 的 async 返回 +/// (= completionHandler)在 POST settle 之后; +/// - 失败必须可见:403(token 过期/已用)与传输失败都补一条本地通知,绝不静默吞。 + +// MARK: - Seams + +/// Seam over `UIApplication.beginBackgroundTask`/`endBackgroundTask`. +@MainActor +protocol BackgroundTaskRunning: AnyObject { + /// Returns an opaque token for `end(_:)`(生产映射 UIBackgroundTaskIdentifier)。 + func begin(name: String) -> Int + func end(_ token: Int) +} + +@MainActor +final class UIApplicationBackgroundTaskRunner: BackgroundTaskRunning { + private var active: [Int: UIBackgroundTaskIdentifier] = [:] + + func begin(name: String) -> Int { + var identifier = UIBackgroundTaskIdentifier.invalid + identifier = UIApplication.shared.beginBackgroundTask(withName: name) { + // 系统到期回调在主线程同步调用(UIKit 文档保证)。 + MainActor.assumeIsolated { self.expire(identifier) } + } + active[identifier.rawValue] = identifier + return identifier.rawValue + } + + func end(_ token: Int) { + guard let identifier = active.removeValue(forKey: token) else { return } + UIApplication.shared.endBackgroundTask(identifier) + } + + private func expire(_ identifier: UIBackgroundTaskIdentifier) { + guard identifier != .invalid, active.removeValue(forKey: identifier.rawValue) != nil else { + return + } + UIApplication.shared.endBackgroundTask(identifier) + } +} + +/// Seam for the local-notification fallback(决策失败可见性)。 +@MainActor +protocol LocalNoticePosting: AnyObject { + func post(title: String, body: String) async +} + +extension UNNotificationCenterAdapter: LocalNoticePosting { + func post(title: String, body: String) async { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + let request = UNNotificationRequest( + identifier: UUID().uuidString, content: content, trigger: nil + ) + do { + try await add(request) + } catch { + // 最后的兜底也失败:只剩日志(不能再用通知报告通知失败)。 + Logger(subsystem: PushLog.subsystem, category: PushLog.actionHandler) + .error("fallback local notice failed: \(error)") + } + } +} + +/// 用户可见兜底文案(payload 最小化:不含 token、不含会话内容)。 +enum PushDecisionCopy { + static let expiredTitle = "审批已失效" + static let expiredBody = "该次批准/拒绝已过期或已被处理,请打开 App 在会话中查看。" + static let failedTitle = "审批发送失败" + static let failedBody = "无法联系主机,请打开 App 在会话中处理。" +} + +/// `parse` 的白名单产物(Sendable——didReceive 在 nonisolated 上下文解析后 +/// 才跨进 MainActor,UN 对象绝不跨 isolation)。 +enum ParsedNotificationAction: Sendable, Equatable { + /// Allow/Deny 动作按钮 + 合法 `{sessionId, token}`。 + case decision(HookDecision, sessionId: UUID, token: String) + /// 默认点按(含 done 类通知)+ 合法 sessionId。 + case openSession(sessionId: UUID) + /// 目标动作的 payload 校验失败 → 丢弃计数(镜像"非法帧静默丢弃")。 + case invalidPayload + /// dismiss / 未知动作 id → no-op。 + case dismissed +} + +// MARK: - Handler + +@MainActor +final class NotificationActionHandler: NSObject { + /// Coordinator 提供的路由效果(点按 → 打开会话)。 + struct Actions { + let openSession: @MainActor (HostRegistry.Host, UUID) async -> Void + } + + private enum TaskName { + static let decision = "webterm.hook-decision" + } + + private let hostStore: any HostStore + private let http: any HTTPTransport + private let backgroundTasks: any BackgroundTaskRunning + private let notices: any LocalNoticePosting + private let actions: Actions + private let logger = Logger(subsystem: PushLog.subsystem, category: PushLog.actionHandler) + + private(set) var invalidPayloadCount = 0 + + init( + hostStore: any HostStore, + http: any HTTPTransport, + backgroundTasks: any BackgroundTaskRunning, + notices: any LocalNoticePosting, + actions: Actions + ) { + self.hostStore = hostStore + self.http = http + self.backgroundTasks = backgroundTasks + self.notices = notices + self.actions = actions + } + + // MARK: - Parse(纯函数,payload 级可测核心) + + nonisolated static func parse( + actionIdentifier: String, userInfo: [AnyHashable: Any] + ) -> ParsedNotificationAction { + switch actionIdentifier { + case GateNotificationCategory.allowActionId: + return decisionAction(.allow, userInfo: userInfo) + case GateNotificationCategory.denyActionId: + return decisionAction(.deny, userInfo: userInfo) + case UNNotificationDefaultActionIdentifier: + guard case let .gateSession(sessionId) = DeepLinkRouter.route(from: userInfo) else { + return .invalidPayload + } + return .openSession(sessionId: sessionId) + default: + return .dismissed + } + } + + private nonisolated static func decisionAction( + _ decision: HookDecision, userInfo: [AnyHashable: Any] + ) -> ParsedNotificationAction { + guard case let .gateSession(sessionId) = DeepLinkRouter.route(from: userInfo), + let token = userInfo[PayloadKey.token] as? String, + Validation.isValidSessionId(token) // 服务器 token = randomUUID() → 同一 v4 白名单 + else { return .invalidPayload } + return .decision(decision, sessionId: sessionId, token: token) + } + + private enum PayloadKey { + /// 根级 capability token(T-iOS-20 payload 定稿,仅 gate 类携带)。 + static let token = "token" + } + + // MARK: - Handle + + func handle(_ action: ParsedNotificationAction) async { + switch action { + case .dismissed: + return + case .invalidPayload: + invalidPayloadCount += 1 + // 不可信输入:只记计数,不回显 payload(日志注入/隐私卫生)。 + logger.notice("dropped invalid push payload (total: \(self.invalidPayloadCount))") + case let .openSession(sessionId): + await routeToSession(sessionId) + case let .decision(decision, sessionId, token): + await postDecision(decision, sessionId: sessionId, token: token) + } + } + + // MARK: - Decision(Allow/Deny → POST /hook/decision) + + private func postDecision(_ decision: HookDecision, sessionId: UUID, token: String) async { + let taskToken = backgroundTasks.begin(name: TaskName.decision) + defer { backgroundTasks.end(taskToken) } + do { + guard let host = try await resolveHost(sessionId: sessionId) else { + logger.error("hook decision: no paired host lists session — cannot deliver") + await notices.post( + title: PushDecisionCopy.failedTitle, body: PushDecisionCopy.failedBody + ) + return + } + try await APIClient(endpoint: host.endpoint, http: http) + .hookDecision(sessionId: sessionId, decision: decision, token: token) + } catch APIClientError.decisionRejected { + // 403:token 过期/已用/不匹配(SEC-C1/M1)→ 引导进 App 处理。 + await notices.post( + title: PushDecisionCopy.expiredTitle, body: PushDecisionCopy.expiredBody + ) + } catch { + logger.error("hook decision POST failed: \(error)") + await notices.post( + title: PushDecisionCopy.failedTitle, body: PushDecisionCopy.failedBody + ) + } + } + + // MARK: - Default tap(→ DeepLinkRouter 已验证的 sessionId → 会话) + + private func routeToSession(_ sessionId: UUID) async { + do { + guard let host = try await resolveHost(sessionId: sessionId) else { + // App 已被点按拉起:落在会话列表即可,无需报错弹窗。 + logger.notice("push tap: session not found on any paired host") + return + } + await actions.openSession(host, sessionId) + } catch { + logger.error("push tap: host store read failed: \(error)") + } + } + + // MARK: - Host resolution(payload 不含 host 身份——本 handler 拥有解析策略) + + /// 单主机 → 直取(离线也能把 POST 打出去,错了会显式失败);多主机 → + /// 逐主机 RO 查 `/live-sessions`(无 Origin)找到持有该会话的主机。 + private func resolveHost(sessionId: UUID) async throws -> HostRegistry.Host? { + let hosts = try await hostStore.loadAll() + if hosts.count <= 1 { return hosts.first } + for host in hosts { + let sessions = (try? await APIClient(endpoint: host.endpoint, http: http) + .liveSessions()) ?? [] + if sessions.contains(where: { $0.id == sessionId }) { return host } + } + return nil + } +} + +// MARK: - UNUserNotificationCenterDelegate(薄胶水;UNNotificationResponse 无法单测构造) + +extension NotificationActionHandler: UNUserNotificationCenterDelegate { + /// async 变体 = 系统的 completionHandler 在本方法 return 时才触发—— + /// 任务要求"completionHandler only after the POST settles"由此保证。 + /// UN 对象只在 nonisolated 入口同步读取,跨进 MainActor 的只有 Sendable + /// 的 `ParsedNotificationAction`。 + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let parsed = Self.parse( + actionIdentifier: response.actionIdentifier, + userInfo: response.notification.request.content.userInfo + ) + await handle(parsed) + } +} + +// MARK: - AppCoordinator wiring(与 DeepLinkRouter.swift 的 makeDeepLinkHandler 同款先例) + +extension AppCoordinator { + /// PushAppDelegate.didFinishLaunching 的组装点(生产依赖图单点: + /// hostStore/http 都来自 AppEnvironment,无 ad-hoc URLSession/keychain)。 + func makePushWiring() -> (registrar: PushRegistrar, handler: NotificationActionHandler) { + let center = UNNotificationCenterAdapter() + let registrar = PushRegistrar( + hostStore: environment.hostStore, + http: environment.http, + center: center, + remote: UIApplicationRemoteRegistrar() + ) + let handler = NotificationActionHandler( + hostStore: environment.hostStore, + http: environment.http, + backgroundTasks: UIApplicationBackgroundTaskRunner(), + notices: center, + actions: NotificationActionHandler.Actions( + openSession: { [weak self] host, sessionId in + await self?.openPushedSession(host: host, sessionId: sessionId) + } + ) + ) + return (registrar, handler) + } + + /// 通知点按 → 直达会话。先 `bootstrap()`(幂等,`route == .loading` 才 + /// 生效)确保冷启动点按不早于根路由建立;再镜像 deep-link 的 + /// close→open(openDeepLinkedSession 是 T-iOS-22 文件的 private—— + /// 三行复刻以尊重文件所有权)。 + func openPushedSession(host: HostRegistry.Host, sessionId: UUID) async { + await bootstrap() + if terminalController != nil { + closeTerminal() + } + open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId)) + } +} diff --git a/ios/App/WebTerm/Push/PushRegistrar.swift b/ios/App/WebTerm/Push/PushRegistrar.swift new file mode 100644 index 0000000..6d45e70 --- /dev/null +++ b/ios/App/WebTerm/Push/PushRegistrar.swift @@ -0,0 +1,331 @@ +import APIClient +import Foundation +import HostRegistry +import OSLog +import UIKit +import UserNotifications +import WireProtocol + +/// T-iOS-21 · APNs 注册侧:授权 → registerForRemoteNotifications → device +/// token 对每个已配对主机 `POST /push/apns-token`(builder 归 T-iOS-38)。 +/// +/// 授权决策(任务要求"document the choice"):请求**真授权 [.alert, .sound]** +/// 而非 provisional —— provisional 只静默投递到通知中心(无锁屏横幅、无声音、 +/// 无可交互动作),而本任务的核心是锁屏 Allow/Deny 两次手势闭环,必须 alert +/// 级展示(服务器 gate 推送也带 `sound: 'default'`,src/push/apns.ts:342)。 +/// +/// 重试语义:注册失败只记日志绝不 crash;device token 每次 +/// `registerForRemoteNotifications` 都会经 delegate 重新送达(launch/scene +/// 激活各触发一次 `activate()`),`registeredHostIds` 记账保证只补注册失败/ +/// 新增的主机(服务器端 upsert 本身幂等,5/min/IP 限频下不浪费配额)。 + +// MARK: - Seams(UNUserNotificationCenter / UIApplication 不可在单测实例化流程) + +/// Seam over the `UNUserNotificationCenter` surface this feature touches. +@MainActor +protocol NotificationCenterClient: AnyObject { + func authorizationStatus() async -> UNAuthorizationStatus + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool + func setNotificationCategories(_ categories: Set) + func add(_ request: UNNotificationRequest) async throws +} + +@MainActor +final class UNNotificationCenterAdapter: NotificationCenterClient { + private let center = UNUserNotificationCenter.current() + + func authorizationStatus() async -> UNAuthorizationStatus { + // The async `notificationSettings()` returns non-Sendable + // UNNotificationSettings across an isolation hop (Swift 6 error); + // extract only the Sendable status inside the completion instead. + // @Sendable literal: the closure must NOT inherit this class's + // MainActor isolation — UNUserNotificationCenter invokes it on a + // background queue, and an isolated closure traps the Swift 6 runtime + // executor check (dispatch_assert_queue_fail; W7 verify boot-crash). + await withCheckedContinuation { continuation in + center.getNotificationSettings { @Sendable settings in + continuation.resume(returning: settings.authorizationStatus) + } + } + } + + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { + // Completion-handler bridge for the same Swift 6 reason as above (the + // SDK's async variant sends the non-Sendable center across isolation). + try await withCheckedThrowingContinuation { continuation in + center.requestAuthorization(options: options) { @Sendable granted, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: granted) + } + } + } + } + + func setNotificationCategories(_ categories: Set) { + center.setNotificationCategories(categories) + } + + func add(_ request: UNNotificationRequest) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + center.add(request) { @Sendable error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } +} + +/// Seam over `UIApplication.registerForRemoteNotifications`. +@MainActor +protocol RemoteNotificationRegistering: AnyObject { + func registerForRemoteNotifications() +} + +@MainActor +final class UIApplicationRemoteRegistrar: RemoteNotificationRegistering { + func registerForRemoteNotifications() { + UIApplication.shared.registerForRemoteNotifications() + } +} + +// MARK: - WEBTERM_GATE category(安全注的单一定义点) + +/// 锁屏 Allow/Deny 的 category 注册形状。**安全注(plan T-iOS-21)**: +/// - Allow 必带 `.authenticationRequired` —— 锁屏批准 = 授权主机执行命令, +/// 必须 Face ID/通行码确认; +/// - Deny 保持免认证(fail-safe:旁观者拿到锁屏手机只能拒绝); +/// - 两动作都**不带** `.foreground` —— 决策在后台 POST,不拉起 UI。 +enum GateNotificationCategory { + /// 与服务器 `GATE_CATEGORY` 一致(src/push/apns.ts:52,T-iOS-20 定稿)。 + static let identifier = "WEBTERM_GATE" + static let allowActionId = "WEBTERM_ALLOW" + static let denyActionId = "WEBTERM_DENY" + static let allowTitle = "允许" + static let denyTitle = "拒绝" + + static func category() -> UNNotificationCategory { + let allow = UNNotificationAction( + identifier: allowActionId, title: allowTitle, + options: [.authenticationRequired] + ) + let deny = UNNotificationAction( + identifier: denyActionId, title: denyTitle, + options: [.destructive] // 红色渲染,纯视觉;不含 .foreground/.authenticationRequired + ) + return UNNotificationCategory( + identifier: identifier, actions: [allow, deny], + intentIdentifiers: [], options: [] + ) + } +} + +// MARK: - PushRegistrar + +@MainActor +final class PushRegistrar { + /// 真授权(非 provisional)——理由见文件头 doc。 + static let authorizationOptions: UNAuthorizationOptions = [.alert, .sound] + + private let hostStore: any HostStore + private let http: any HTTPTransport + private let center: any NotificationCenterClient + private let remote: any RemoteNotificationRegistering + private let logger = Logger(subsystem: PushLog.subsystem, category: PushLog.registrar) + + /// 当前 device token 的小写 hex;仅驻内存(iOS 每次注册都重新送达, + /// 无需落盘——落盘的只有服务器侧的注册表)。 + private(set) var currentTokenHex: String? + /// 已用 `currentTokenHex` 注册成功的主机(重试只补缺)。 + private var registeredHostIds: Set = [] + + init( + hostStore: any HostStore, + http: any HTTPTransport, + center: any NotificationCenterClient, + remote: any RemoteNotificationRegistering + ) { + self.hostStore = hostStore + self.http = http + self.center = center + self.remote = remote + } + + /// 幂等激活:每次 launch / scene 转 active 调一次。category 注册无条件; + /// 授权与远程注册只在**已有配对主机**时进行(无主机 = 无推送来源,不打扰; + /// 配对后的下一次前台激活/启动会补上)。 + func activate() async { + center.setNotificationCategories([GateNotificationCategory.category()]) + let hosts: [HostRegistry.Host] + do { + hosts = try await hostStore.loadAll() + } catch { + logger.error("push activate: host store read failed: \(error)") + return + } + guard !hosts.isEmpty else { + logger.debug("push activate: no paired hosts — skip authorization") + return + } + guard await ensureAuthorization() else { return } + remote.registerForRemoteNotifications() + } + + /// `didRegisterForRemoteNotificationsWithDeviceToken` 入口。 + func handleDeviceToken(_ deviceToken: Data) async { + let hex = Self.hexToken(deviceToken) + if hex != currentTokenHex { + currentTokenHex = hex + registeredHostIds = [] + } + await registerPendingHosts() + } + + /// `didFailToRegisterForRemoteNotificationsWithError` 入口:只记日志 + /// (模拟器/无 aps-environment entitlement 的构建走到这里,绝不 crash)。 + func handleRegistrationFailure(_ error: any Error) { + logger.error("remote notification registration failed: \(error)") + } + + /// 主机移除时注销该主机上的 device token(**additive hook**:当前 App + /// 层尚无移除主机的 UI 路径——`HostStore.remove(id:)` 无消费者;未来的 + /// 移除路径应调用本方法。失败仅记日志:服务器侧对失效 token 也会经 + /// APNs 410 自行清理)。 + func handleHostRemoved(_ host: HostRegistry.Host) async { + registeredHostIds.remove(host.id) + guard let token = currentTokenHex else { return } + do { + try await APIClient(endpoint: host.endpoint, http: http) + .unregisterApnsToken(token) + } catch { + logger.error("APNs token unregister failed for host \(host.id): \(error)") + } + } + + // MARK: - Internals + + /// notDetermined → 弹真授权请求;denied → 短路;其余(authorized/ + /// provisional/ephemeral)→ 直接放行。 + private func ensureAuthorization() async -> Bool { + switch await center.authorizationStatus() { + case .notDetermined: + do { + guard try await center.requestAuthorization( + options: Self.authorizationOptions + ) else { + logger.notice("push authorization denied by user") + return false + } + return true + } catch { + logger.error("push authorization request failed: \(error)") + return false + } + case .denied: + logger.notice("push authorization previously denied — skip registration") + return false + default: + return true + } + } + + private func registerPendingHosts() async { + guard let token = currentTokenHex else { return } + let hosts: [HostRegistry.Host] + do { + hosts = try await hostStore.loadAll() + } catch { + logger.error("push token registration: host store read failed: \(error)") + return + } + for host in hosts where !registeredHostIds.contains(host.id) { + do { + try await APIClient(endpoint: host.endpoint, http: http) + .registerApnsToken(token) + registeredHostIds.insert(host.id) + } catch { + // 失败续命:不 crash、不中断其余主机;下一次 token 送达 + //(下次启动/前台激活)自动重试本主机。 + logger.error("APNs token registration failed for host \(host.id): \(error)") + } + } + } + + /// APNs device token → 小写 hex(服务器 wire 形状 64–160 hex,T-iOS-38)。 + static func hexToken(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - PushAppDelegate(app 生命周期胶水,单测不覆盖——全部逻辑在上方可测类型里) + +/// `@UIApplicationDelegateAdaptor` 入口:remote-notification 回调只能经 +/// UIApplicationDelegate 送达,SwiftUI App 本体拿不到。 +/// +/// 接线路径(wiring 决策,任务允许的最小增量):SwiftUI 生命周期先跑 +/// `WebTermApp.init`(在那里创建 coordinator 并静态交接到 `bootstrap`),后跑 +/// `didFinishLaunching`(在此消费、组装 registrar/handler 并把 handler 设为 +/// UNUserNotificationCenter delegate——Apple 要求该 delegate 必须在启动完成前 +/// 就位,否则冷启动的通知动作会丢)。激活(授权+注册)不在这里做,而是随 +/// scenePhase 转 active(每次启动都会发生一次)由 `WebTermApp` 触发——后台 +/// 拉起(锁屏 Allow/Deny)时场景不会激活,正好省掉 30 秒窗口里的注册流量。 +@MainActor +final class PushAppDelegate: NSObject, UIApplicationDelegate { + /// WebTermApp.init → didFinishLaunching 的一次性交接槽。 + static var bootstrap: AppCoordinator? + + /// 单测宿主(XCTest 已加载)与 XCUITest 拉起(环境带会话标识)都跳过 + /// 推送接线:授权弹窗会污染确定性测试;测试用注入替身直接驱动逻辑。 + static var isRunningUnderTests: Bool { + NSClassFromString("XCTestCase") != nil + || ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] != nil + } + + private(set) var registrar: PushRegistrar? + private(set) var actionHandler: NotificationActionHandler? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + guard !Self.isRunningUnderTests, let coordinator = Self.bootstrap else { + Self.bootstrap = nil + return true + } + Self.bootstrap = nil + let wiring = coordinator.makePushWiring() + registrar = wiring.registrar + actionHandler = wiring.handler + UNUserNotificationCenter.current().delegate = wiring.handler + return true + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Task { await registrar?.handleDeviceToken(deviceToken) } + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: any Error + ) { + registrar?.handleRegistrationFailure(error) + } + + /// scenePhase 转 active(含每次启动)→ 幂等激活。 + func activatePush() { + Task { await registrar?.activate() } + } +} + +enum PushLog { + static let subsystem = "com.yaojia.webterm" + static let registrar = "push-registrar" + static let actionHandler = "push-action" +} diff --git a/ios/App/WebTerm/Screens/DiffScreen.swift b/ios/App/WebTerm/Screens/DiffScreen.swift new file mode 100644 index 0000000..f05bcbd --- /dev/null +++ b/ios/App/WebTerm/Screens/DiffScreen.swift @@ -0,0 +1,269 @@ +import SwiftUI +import WireProtocol + +/// T-iOS-27 · Read-only diff viewer(镜像 web public/diff.ts 的 render-only +/// 半边;解析只在服务器 src/http/diff.ts)。 +/// +/// 入口:T-iOS-26 的 ProjectDetail 以 `(endpoint, path)` 构造并 push/present +/// 本屏(本任务先行导出可呈现单元)。TerminalScreen 侧的 per-session cwd 入口 +/// 未接 —— TerminalScreen(T-iOS-11 Owns)没有暴露 toolbar hook,接线移交 +/// T-iOS-26/29。 +/// +/// 安全(SEC-H4 同款纪律):diff 内容是**不可信服务器字节** —— 所有服务器 +/// 文本一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测), +/// 等宽单行呈现;行列表是惰性 List,巨 diff(服务器上限 2MB)不合成单个 +/// Text 块。 +struct DiffScreen: View { + @State private var viewModel: DiffViewModel + + private enum Metrics { + static let pickerHorizontalPadding: CGFloat = 16 + static let pickerVerticalPadding: CGFloat = 8 + static let lineVerticalInset: CGFloat = 1 + static let lineHorizontalInset: CGFloat = 12 + static let fileHeaderTopInset: CGFloat = 14 + static let statTagSpacing: CGFloat = 8 + static let lineBackgroundOpacity = 0.12 + } + + /// 生产入口(T-iOS-26 消费):`(endpoint, path)` + 注入的传输层。 + init(endpoint: HostEndpoint, path: String, http: any HTTPTransport) { + _viewModel = State(initialValue: .forProject( + endpoint: endpoint, path: path, http: http + )) + } + + /// 测试/预览缝:直接注入 VM。 + init(viewModel: DiffViewModel) { + _viewModel = State(initialValue: viewModel) + } + + var body: some View { + VStack(spacing: 0) { + scopePicker + .padding(.horizontal, Metrics.pickerHorizontalPadding) + .padding(.vertical, Metrics.pickerVerticalPadding) + content + } + .navigationTitle(DiffCopy.title) + .navigationBarTitleDisplayMode(.inline) + .task { await viewModel.load() } // fresh screen → one initial fetch + } + + // MARK: - staged/unstaged 切换(re-fetch 由 VM 去重) + + private var scopePicker: some View { + Picker(DiffCopy.scopePickerLabel, selection: Binding( + get: { viewModel.staged }, + set: { newValue in Task { await viewModel.setStaged(newValue) } } + )) { + Text(DiffCopy.working).tag(false) + Text(DiffCopy.staged).tag(true) + } + .pickerStyle(.segmented) + } + + // MARK: - Phase switch + + @ViewBuilder private var content: some View { + switch viewModel.phase { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .empty(let truncated): + emptyState(truncated: truncated) + case .failed(let failure): + failedState(failure) + case .loaded(let presentation): + diffList(presentation) + } + } + + /// 该范围下无改动(服务器回空 files)。截断到空也要保留 banner —— + /// “没有改动”与“改动太大被截没了”是两回事。 + private func emptyState(truncated: Bool) -> some View { + VStack(spacing: 0) { + if truncated { truncatedBanner } + ContentUnavailableView( + DiffCopy.emptyTitle, + systemImage: "checkmark.circle", + description: Text(DiffCopy.emptyDetail) + ) + } + } + + /// 显式、可重试的错误态(path 非法 400/404 → 友好话术,任务 Steps)。 + private func failedState(_ failure: DiffViewModel.Failure) -> some View { + let copy = Self.failureCopy(failure) + return ContentUnavailableView { + Label(copy.title, systemImage: "exclamationmark.triangle") + } description: { + Text(copy.detail) + } actions: { + Button(DiffCopy.retry) { + Task { await viewModel.load() } + } + .buttonStyle(.borderedProminent) + } + } + + static func failureCopy(_ failure: DiffViewModel.Failure) -> (title: String, detail: String) { + switch failure { + case .pathInvalid: + return (DiffCopy.failedPathInvalid, DiffCopy.failedPathInvalidDetail) + case .notFound: + return (DiffCopy.failedNotFound, DiffCopy.failedNotFoundDetail) + case .unavailable: + return (DiffCopy.failedUnavailable, DiffCopy.failedUnavailableDetail) + } + } + + // MARK: - 行列表(惰性;行模型已由 VM 平铺) + + private func diffList(_ presentation: DiffPresentation) -> some View { + List { + if presentation.truncated { + truncatedBanner + .listRowSeparator(.hidden) + } + ForEach(presentation.rows) { row in + rowView(row) + } + } + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 0) // 行高随内容,diff 行要紧凑 + } + + private var truncatedBanner: some View { + Label(DiffCopy.truncatedBanner, systemImage: "scissors") + .font(.footnote) + .foregroundStyle(.orange) + } + + @ViewBuilder private func rowView(_ row: DiffRow) -> some View { + switch row.kind { + case .fileHeader(let header): + fileHeaderRow(header) + case .binaryNotice: + Text(DiffCopy.binaryFile) + .font(.caption.italic()) + .foregroundStyle(.secondary) + .listRowSeparator(.hidden) + case .hunkHeader(let header): + diffTextRow( + header, color: .blue, + background: Color.blue.opacity(Metrics.lineBackgroundOpacity) + ) + case .line(let kind, let text): + diffTextRow( + text, color: Self.lineColor(kind), background: Self.lineBackground(kind) + ) + } + } + + private func fileHeaderRow(_ header: DiffFileHeader) -> some View { + HStack(spacing: Metrics.statTagSpacing) { + // 路径是服务器字节:verbatim + 单行中段截断。 + Text(verbatim: header.pathLabel) + .font(.footnote.weight(.semibold).monospaced()) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Text(verbatim: "+\(header.added)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.green) + Text(verbatim: "-\(header.removed)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.red) + Text(DiffStatusStyle.label(for: header.status)) + .font(.caption2) + .foregroundStyle(DiffStatusStyle.color(for: header.status)) + } + .padding(.top, Metrics.fileHeaderTopInset) + .accessibilityElement(children: .combine) + } + + /// One monospaced diff line. UNTRUSTED server bytes: `Text(verbatim:)`, + /// single-line tail truncation (read-only skim view; no wrapping blob). + private func diffTextRow(_ text: String, color: Color, background: Color) -> some View { + Text(verbatim: text) + .font(.caption.monospaced()) + .foregroundStyle(color) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowBackground(background) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets( + top: Metrics.lineVerticalInset, + leading: Metrics.lineHorizontalInset, + bottom: Metrics.lineVerticalInset, + trailing: Metrics.lineHorizontalInset + )) + } + + // MARK: - kind → 颜色(全函数;未知 kind 已在解码层降级为 .context) + + static func lineColor(_ kind: DiffLineKind) -> Color { + switch kind { + case .added: return .green + case .removed: return .red + case .context: return .primary + case .hunk: return .blue + case .meta: return .secondary + } + } + + static func lineBackground(_ kind: DiffLineKind) -> Color { + switch kind { + case .added: return .green.opacity(Metrics.lineBackgroundOpacity) + case .removed: return .red.opacity(Metrics.lineBackgroundOpacity) + case .hunk: return .blue.opacity(Metrics.lineBackgroundOpacity) + case .context, .meta: return .clear + } + } +} + +// MARK: - status → 标签/颜色(全函数映射) + +enum DiffStatusStyle { + static func label(for status: DiffFileStatus) -> String { + switch status { + case .modified: return "修改" + case .added: return "新增" + case .deleted: return "删除" + case .renamed: return "重命名" + case .binary: return "二进制" + case .untracked: return "未跟踪" + } + } + + static func color(for status: DiffFileStatus) -> Color { + switch status { + case .added, .untracked: return .green + case .deleted: return .red + case .renamed: return .blue + case .modified, .binary: return .secondary + } + } +} + +// MARK: - 用户可见文案(中文具名常量,plan 工程标准) + +enum DiffCopy { + static let title = "代码差异" + static let scopePickerLabel = "差异范围" + static let working = "工作区" + static let staged = "已暂存" + static let truncatedBanner = "差异过大,已截断显示(主机侧 DIFF_MAX_BYTES / DIFF_MAX_FILES 限制)。" + static let emptyTitle = "无改动" + static let emptyDetail = "当前范围下没有可显示的改动。" + static let binaryFile = "二进制文件" + static let failedPathInvalid = "路径无效" + static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400),请从项目列表重新进入。" + static let failedNotFound = "项目未找到" + static let failedNotFoundDetail = "该路径不是主机上的 git 仓库,或已被移除(404)。" + static let failedUnavailable = "差异加载失败" + static let failedUnavailableDetail = "无法从主机获取 diff,请检查连接后重试。" + static let retry = "重试" +} diff --git a/ios/App/WebTerm/Screens/ProjectDetailScreen.swift b/ios/App/WebTerm/Screens/ProjectDetailScreen.swift new file mode 100644 index 0000000..4efc847 --- /dev/null +++ b/ios/App/WebTerm/Screens/ProjectDetailScreen.swift @@ -0,0 +1,286 @@ +import APIClient +import SwiftUI +import WireProtocol + +/// T-iOS-26 · 项目详情屏:sessions/worktrees/CLAUDE.md 渲染 + diff 入口 +/// (T-iOS-27 的 `DiffScreen(endpoint:path:http:)`)+ "在此仓库开新会话"。 +/// +/// 安全:名字/路径/分支/CLAUDE.md 内容全是**不可信服务器字节** —— +/// 一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测)。 +struct ProjectDetailScreen: View { + @State private var viewModel: ProjectDetailViewModel + private let endpoint: HostEndpoint + private let http: any HTTPTransport + private let onOpenClaude: (String) -> Void + @State private var isDiffPresented = false + + private enum Metrics { + static let headerSpacing: CGFloat = 4 + static let chipSpacing: CGFloat = 6 + static let claudeMdLineLimit = 40 + } + + init( + viewModel: ProjectDetailViewModel, + endpoint: HostEndpoint, + http: any HTTPTransport, + onOpenClaude: @escaping (String) -> Void + ) { + _viewModel = State(initialValue: viewModel) + self.endpoint = endpoint + self.http = http + self.onOpenClaude = onOpenClaude + } + + var body: some View { + content + .navigationTitle(ProjectDetailCopy.title) + .navigationBarTitleDisplayMode(.inline) + .task { await viewModel.load() } + .navigationDestination(isPresented: $isDiffPresented) { + DiffScreen(endpoint: endpoint, path: viewModel.path, http: http) + } + } + + // MARK: - Phase switch + + @ViewBuilder private var content: some View { + switch viewModel.phase { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let failure): + failedState(failure) + case .loaded(let detail): + detailList(detail) + } + } + + /// 显式、可重试的错误态(400/404/500 `{error}` → 分类文案,任务 Steps)。 + private func failedState(_ failure: ProjectDetailViewModel.Failure) -> some View { + let copy = Self.failureCopy(failure) + return ContentUnavailableView { + Label(copy.title, systemImage: "exclamationmark.triangle") + } description: { + Text(copy.detail) + } actions: { + Button(ProjectDetailCopy.retry) { + Task { await viewModel.load() } + } + .buttonStyle(.borderedProminent) + } + } + + static func failureCopy( + _ failure: ProjectDetailViewModel.Failure + ) -> (title: String, detail: String) { + switch failure { + case .pathInvalid: + return (ProjectDetailCopy.failedPathInvalid, + ProjectDetailCopy.failedPathInvalidDetail) + case .notFound: + return (ProjectDetailCopy.failedNotFound, + ProjectDetailCopy.failedNotFoundDetail) + case .unavailable: + return (ProjectDetailCopy.failedUnavailable, + ProjectDetailCopy.failedUnavailableDetail) + } + } + + // MARK: - Loaded + + private func detailList(_ detail: ProjectDetail) -> some View { + List { + headerSection(detail) + actionsSection(detail) + sessionsSection(detail.sessions) + worktreesSection(detail.worktrees) + claudeMdSection(detail) + } + .listStyle(.insetGrouped) + } + + private func headerSection(_ detail: ProjectDetail) -> some View { + Section { + VStack(alignment: .leading, spacing: Metrics.headerSpacing) { + Text(verbatim: detail.name) + .font(.headline) + .lineLimit(1) + Text(verbatim: detail.path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + HStack(spacing: Metrics.chipSpacing) { + if let branch = detail.branch { + Label { + Text(verbatim: branch).lineLimit(1) + } icon: { + Image(systemName: "arrow.triangle.branch") + } + .font(.caption) + .foregroundStyle(.secondary) + } + if detail.dirty == true { + Text(ProjectsCopy.dirtyBadge) + .font(.caption2) + .foregroundStyle(.orange) + } + } + } + } + } + + private func actionsSection(_ detail: ProjectDetail) -> some View { + Section { + Button { + onOpenClaude(detail.path) + } label: { + Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + if detail.isGit { + Button { + isDiffPresented = true + } label: { + Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus") + } + } + } + } + + @ViewBuilder private func sessionsSection(_ sessions: [ProjectSessionRef]) -> some View { + Section(ProjectDetailCopy.sessionsHeader) { + if sessions.isEmpty { + Text(ProjectDetailCopy.noSessions) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(sessions, id: \.id) { session in + sessionRow(session) + } + } + } + } + + private func sessionRow(_ session: ProjectSessionRef) -> some View { + HStack(spacing: Metrics.chipSpacing) { + Text(verbatim: Self.statusGlyph(session.status)) + // title 是派生的 cwd 尾段(服务器字节)→ verbatim;缺失退回短 id。 + Text(verbatim: session.title ?? String( + session.id.uuidString.lowercased().prefix(8) + )) + .lineLimit(1) + Spacer(minLength: 0) + if session.exited { + Text(ProjectDetailCopy.sessionExited) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(ProjectDetailCopy.clientCount(session.clientCount)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + /// 镜像 web claudeIcon(public/tabs.ts:74-80)。 + static func statusGlyph(_ status: ClaudeStatus) -> String { + switch status { + case .working: return "⚙" + case .waiting: return "⏳" + case .idle: return "✓" + case .stuck: return "⚠" + case .unknown: return "" + } + } + + @ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View { + if !worktrees.isEmpty { + Section(ProjectDetailCopy.worktreesHeader) { + ForEach(worktrees, id: \.path) { worktree in + worktreeRow(worktree) + } + } + } + } + + private func worktreeRow(_ worktree: WorktreeInfo) -> some View { + VStack(alignment: .leading, spacing: Metrics.headerSpacing) { + HStack(spacing: Metrics.chipSpacing) { + Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead) + .font(.callout) + .lineLimit(1) + if worktree.isMain { + badge(ProjectDetailCopy.worktreeMain) + } + if worktree.isCurrent { + badge(ProjectDetailCopy.worktreeCurrent) + } + if worktree.locked == true { + badge(ProjectDetailCopy.worktreeLocked) + } + } + Text(verbatim: worktree.path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + private func badge(_ text: String) -> some View { + Text(text) + .font(.caption2) + .foregroundStyle(.blue) + } + + @ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View { + if detail.hasClaudeMd { + Section(ProjectDetailCopy.claudeMdHeader) { + if let content = detail.claudeMd { + // 服务器已截断供展示;仍是不可信字节 → verbatim + 行数上限。 + Text(verbatim: content) + .font(.caption.monospaced()) + .lineLimit(Metrics.claudeMdLineLimit) + } else { + Text(ProjectDetailCopy.claudeMdPresent) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + } +} + +// MARK: - 用户可见文案(中文具名常量,plan §4) + +enum ProjectDetailCopy { + static let title = "项目详情" + static let openClaude = "在此仓库开新会话" + static let viewDiff = "查看代码差异" + static let sessionsHeader = "运行中的会话" + static let noSessions = "暂无运行中的会话。" + static let sessionExited = "已退出" + static let worktreesHeader = "Worktrees" + static let worktreeMain = "主" + static let worktreeCurrent = "当前" + static let worktreeLocked = "已锁定" + static let detachedHead = "(分离 HEAD)" + static let claudeMdHeader = "CLAUDE.md" + static let claudeMdPresent = "本仓库包含 CLAUDE.md。" + static let retry = "重试" + static let failedPathInvalid = "路径无效" + static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400),请从项目列表重新进入。" + static let failedNotFound = "项目未找到" + static let failedNotFoundDetail = "该路径不在主机的项目根目录下,或已被移除(404)。" + static let failedUnavailable = "详情加载失败" + static let failedUnavailableDetail = "无法从主机获取项目详情,请检查连接后重试。" + + static func clientCount(_ count: Int) -> String { + "\(count) 个客户端" + } +} diff --git a/ios/App/WebTerm/Screens/ProjectsScreen.swift b/ios/App/WebTerm/Screens/ProjectsScreen.swift new file mode 100644 index 0000000..73ff08e --- /dev/null +++ b/ios/App/WebTerm/Screens/ProjectsScreen.swift @@ -0,0 +1,184 @@ +import APIClient +import SwiftUI + +/// T-iOS-26 · Projects 列表屏(镜像 web v0.6 projects 面板):namespace 分组 +/// 折叠分区 + 收藏星标 + dirty 徽标 + Active now 置顶 + 搜索。纯呈现 —— +/// 分组/收藏/折叠/导航信号全部是 `ProjectsViewModel` 逻辑(单测覆盖)。 +/// +/// 安全:项目名/路径/分支是**不可信服务器字节** —— 一律 `Text(verbatim:)` +/// (绝不 LocalizedStringKey/Markdown),单行截断。 +struct ProjectsScreen: View { + @Bindable var viewModel: ProjectsViewModel + /// "在此仓库开新会话" 导航钩子(AppCoordinator.openProject 消费)。 + var onOpen: (ProjectOpenRequest) -> Void = { _ in } + + private enum Metrics { + static let rowSpacing: CGFloat = 2 + static let chipSpacing: CGFloat = 6 + } + + var body: some View { + list + .navigationTitle(ProjectsCopy.title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $viewModel.searchText, prompt: ProjectsCopy.searchPrompt) + .refreshable { await viewModel.refresh() } + .task { await viewModel.load() } + .onChange(of: viewModel.openRequest) { _, request in + guard let request else { return } + onOpen(request) + } + .navigationDestination(for: ProjectRoute.self) { route in + ProjectDetailScreen( + viewModel: viewModel.makeDetailViewModel(path: route.path), + endpoint: viewModel.host.endpoint, + http: viewModel.http, + onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) } + ) + } + } + + // MARK: - List + + private var list: some View { + List { + errorRows + if let message = viewModel.emptyStateMessage { + Text(message) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .listRowSeparator(.hidden) + } + ForEach(viewModel.groups) { group in + groupSection(group) + } + } + .listStyle(.insetGrouped) + .overlay { + if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil { + ProgressView() + } + } + } + + /// 显式错误行(刷新失败留旧列表、prefs 失败降级本地 —— 都要可见)。 + @ViewBuilder private var errorRows: some View { + ForEach( + [ + viewModel.fetchErrorMessage, + viewModel.prefsErrorMessage, + viewModel.prefsSyncErrorMessage, + viewModel.openErrorMessage, + ].compactMap(\.self), + id: \.self + ) { message in + Label(message, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + .listRowSeparator(.hidden) + } + } + + // MARK: - Group section(flat 无 chrome;namespace/other 可折叠) + + @ViewBuilder private func groupSection(_ group: ProjectGroup) -> some View { + let isCollapsed = viewModel.isCollapsed(group) + Section { + if !isCollapsed { + ForEach(group.projects, id: \.path) { project in + projectRow(project, group: group) + } + } + } header: { + if group.kind != .flat { + groupHeader(group, isCollapsed: isCollapsed) + } + } + } + + private func groupHeader(_ group: ProjectGroup, isCollapsed: Bool) -> some View { + HStack(spacing: Metrics.chipSpacing) { + if group.isCollapsible { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.caption2) + } + // namespace label 来自服务器仓库名 → verbatim。 + Text(verbatim: group.label) + .lineLimit(1) + Text(verbatim: "\(group.projects.count)") + .foregroundStyle(.secondary) + // 折叠的分区绝不悄悄埋掉活跃会话(镜像 web 组头徽标)。 + if group.kind != .active && group.activeCount > 0 { + Text(ProjectsCopy.activeCountBadge(group.activeCount)) + .font(.caption2) + .foregroundStyle(.green) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + .onTapGesture { + guard group.isCollapsible else { return } + Task { await viewModel.toggleCollapsed(key: group.key) } + } + .accessibilityAddTraits(group.isCollapsible ? .isButton : []) + } + + // MARK: - Project row + + private func projectRow(_ project: ProjectInfo, group: ProjectGroup) -> some View { + NavigationLink(value: ProjectRoute(path: project.path)) { + HStack(spacing: Metrics.chipSpacing) { + favouriteButton(project) + VStack(alignment: .leading, spacing: Metrics.rowSpacing) { + Text(verbatim: ProjectGrouping.displayLabel( + name: project.name, groupKey: group.key + )) + .font(.body.weight(.medium)) + .lineLimit(1) + projectChips(project) + } + Spacer(minLength: 0) + if ProjectGrouping.hasRunningSession(project) { + Image(systemName: "circle.fill") + .font(.caption2) + .foregroundStyle(.green) + .accessibilityLabel(ProjectsCopy.activeCountBadge(1)) + } + } + } + } + + private func favouriteButton(_ project: ProjectInfo) -> some View { + Button { + Task { await viewModel.toggleFavourite(path: project.path) } + } label: { + Image(systemName: viewModel.isFavourite(project.path) ? "star.fill" : "star") + .foregroundStyle(.yellow) + } + .buttonStyle(.borderless) // List 行内独立可点 + } + + @ViewBuilder private func projectChips(_ project: ProjectInfo) -> some View { + HStack(spacing: Metrics.chipSpacing) { + if let branch = project.branch { + Label { + Text(verbatim: branch).lineLimit(1) + } icon: { + Image(systemName: "arrow.triangle.branch") + } + .font(.caption) + .foregroundStyle(.secondary) + } + if project.dirty == true { + Text(ProjectsCopy.dirtyBadge) + .font(.caption2) + .foregroundStyle(.orange) + } + } + } +} + +/// 详情屏的 push 路由值(value-based navigation)。 +struct ProjectRoute: Hashable { + let path: String +} diff --git a/ios/App/WebTerm/Screens/SessionListScreen.swift b/ios/App/WebTerm/Screens/SessionListScreen.swift index e7d22c5..5af70ab 100644 --- a/ios/App/WebTerm/Screens/SessionListScreen.swift +++ b/ios/App/WebTerm/Screens/SessionListScreen.swift @@ -16,6 +16,11 @@ struct SessionListScreen: View { var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in } /// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15). var onAddHost: () -> Void = {} + /// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one + /// render-concurrency gate across ALL rows (scrolling must never spawn + /// unbounded offscreen terminals). `@State` keeps it stable across body + /// rebuilds; `live()` is cheap (the URLSession is a static shared). + @State private var thumbnails = SessionThumbnailPipeline.live() var body: some View { content @@ -60,7 +65,7 @@ struct SessionListScreen: View { Button { viewModel.openSession(id: row.id) } label: { - SessionRowView(row: row) + SessionRowView(row: row, thumbnail: thumbnailSlot(for: row)) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { @@ -74,6 +79,23 @@ struct SessionListScreen: View { .refreshable { await viewModel.refresh() } } + /// T-iOS-28 · build one row's thumbnail slot. No paired host (defensive — + /// rows imply a host) → no slot; the request key carries `lastOutputAt` + /// so unchanged sessions render exactly once (pipeline cache). + private func thumbnailSlot( + for row: SessionListViewModel.SessionRow + ) -> SessionThumbnailView? { + guard let endpoint = viewModel.activeHost?.endpoint else { return nil } + return SessionThumbnailView( + request: SessionThumbnailRequest( + endpoint: endpoint, + sessionId: row.id, + lastOutputAt: row.info.lastOutputAt + ), + pipeline: thumbnails + ) + } + private func errorRow(_ message: String) -> some View { Label(message, systemImage: "exclamationmark.triangle") .font(.footnote) @@ -142,15 +164,26 @@ struct SessionListScreen: View { private struct SessionRowView: View { let row: SessionListViewModel.SessionRow + /// T-iOS-28 (additive) · trailing live-preview thumbnail; nil = no slot + /// (no host / preview-less contexts) and the row lays out as before. + var thumbnail: SessionThumbnailView? var body: some View { HStack(alignment: .top, spacing: Metrics.rowSpacing) { indicator .frame(width: Metrics.indicatorWidth) VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) { - Text(title) - .font(.body) - .lineLimit(1) + HStack(spacing: Metrics.titleSpacing) { + // T-iOS-23: OSC titles are attacker-controlled — already + // sanitized in the VM, rendered verbatim (no Markdown / + // LocalizedStringKey interpretation), one line only. + Text(verbatim: title) + .font(.body) + .lineLimit(1) + if row.isUnread { + unreadDot + } + } Text(meta) .font(.caption) .foregroundStyle(.secondary) @@ -158,6 +191,10 @@ private struct SessionRowView: View { TelemetryChips(model: telemetry) } } + if let thumbnail { + Spacer(minLength: Metrics.titleSpacing) + thumbnail + } } .opacity(row.info.exited ? Metrics.exitedOpacity : 1) .accessibilityElement(children: .combine) @@ -179,8 +216,19 @@ private struct SessionRowView: View { } } - /// `cwd` is server-supplied display text (untrusted → plain Text only). + /// Unread dot (T-iOS-23): output newer than the local last-seen watermark. + private var unreadDot: some View { + Circle() + .fill(.blue) + .frame(width: Metrics.unreadDotSize, height: Metrics.unreadDotSize) + .accessibilityLabel(ScreenCopy.unreadLabel) + } + + /// Sanitized OSC title first (T-iOS-23, mirrors web autoTitle precedence, + /// public/tabs.ts:570), else the cwd-derived name. Both are server-supplied + /// display text (untrusted → verbatim Text only). private var title: String { + if let osc = row.title, !osc.isEmpty { return osc } guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory } return URL(fileURLWithPath: cwd).lastPathComponent } @@ -213,6 +261,8 @@ private struct SessionRowView: View { static let dotSize: CGFloat = 10 static let dotTopPadding: CGFloat = 5 static let exitedOpacity = 0.55 + static let titleSpacing: CGFloat = 6 + static let unreadDotSize: CGFloat = 8 } } @@ -231,6 +281,7 @@ private enum ScreenCopy { static let unknownDirectory = "未知目录" static let exitedTag = "已退出" static let pendingBadgeLabel = "等待审批" + static let unreadLabel = "有新输出" static func clientCount(_ count: Int) -> String { "\(count) 台设备在看" diff --git a/ios/App/WebTerm/Screens/TerminalScreen.swift b/ios/App/WebTerm/Screens/TerminalScreen.swift index 7d9fdfd..b947d25 100644 --- a/ios/App/WebTerm/Screens/TerminalScreen.swift +++ b/ios/App/WebTerm/Screens/TerminalScreen.swift @@ -20,26 +20,51 @@ import UIKit /// T-iOS-15 — this screen only renders and routes. struct TerminalScreen: View { let viewModel: TerminalViewModel + /// T-iOS-29 · "在当前目录开新会话":toolbar 常驻入口 + exit 横幅上的 + /// "开新会话"(同一动作 —— close→open fresh spawn,cwd 解析在 + /// `AppCoordinator.openNewSessionInCurrentCwd`)。nil = 两处入口都隐藏 + /// (预览/无 coordinator 的测试环境)。 + var onNewSessionInCwd: (@MainActor () -> Void)? = nil private enum Metrics { static let bannerHorizontalPadding: CGFloat = 12 static let bannerTopPadding: CGFloat = 8 } + private enum Copy { + static let newSessionInCwd = "在当前目录开新会话" + } + var body: some View { TerminalHostView(viewModel: viewModel) .ignoresSafeArea(.container, edges: .bottom) .overlay(alignment: .top) { if let model = viewModel.bannerModel { - ReconnectBanner(model: model) + ReconnectBanner(model: model, onNewSession: onNewSessionInCwd) .padding(.horizontal, Metrics.bannerHorizontalPadding) .padding(.top, Metrics.bannerTopPadding) .transition(.move(edge: .top).combined(with: .opacity)) } } .animation(.default, value: viewModel.bannerModel) + .toolbar { newSessionToolbarItem } .onAppear { viewModel.start() } } + + /// Mirrors web `tabs.ts newTab()` (M6): the + affordance opens a fresh + /// session in the active session's cwd, if known. + @ToolbarContentBuilder private var newSessionToolbarItem: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + if let onNewSessionInCwd { + Button { + onNewSessionInCwd() + } label: { + Label(Copy.newSessionInCwd, systemImage: "plus.rectangle.on.folder") + } + .accessibilityIdentifier("terminal.newInCwdButton") + } + } + } } // MARK: - SwiftTerm bridge @@ -112,9 +137,15 @@ private struct TerminalHostView: UIViewRepresentable { UIApplication.shared.open(url) } - // Title/cwd surface in the session list via the server (T-iOS-13/23), - // not from the local emulator — deliberate no-ops. - func setTerminalTitle(source: TerminalView, title: String) {} + /// OSC 0/2 title (T-iOS-23): hostile input — the VM sanitizes + /// (TitleSanitizer) before any UI/registry use, then the wiring + /// surfaces it on the session-list row. + func setTerminalTitle(source: TerminalView, title: String) { + viewModel.setTerminalTitle(title) + } + + // cwd surfaces in the session list via the server (T-iOS-13), not + // from the local emulator — deliberate no-op. func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} func scrolled(source: TerminalView, position: Double) {} func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} diff --git a/ios/App/WebTerm/Screens/TimelineSheet.swift b/ios/App/WebTerm/Screens/TimelineSheet.swift new file mode 100644 index 0000000..46f3b89 --- /dev/null +++ b/ios/App/WebTerm/Screens/TimelineSheet.swift @@ -0,0 +1,166 @@ +import SwiftUI +import WireProtocol + +/// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the +/// away-digest「展开」affordance (TerminalContainerView wiring). +/// +/// Mirrors the web timeline panel (public/timeline.ts): rows are +/// "HH:MM · icon · label", newest-first, capped at +/// `TimelineViewModel.maxEvents`. `label` is SERVER text (untrusted display +/// input) — rendered via `Text(verbatim:)` only, `lineLimit(1)` (same row +/// discipline as AwayDigestView; web sets it via textContent, SEC-H6). +struct TimelineSheet: View { + let viewModel: TimelineViewModel + + private enum Metrics { + static let rowSpacing: CGFloat = 10 + static let iconColumnWidth: CGFloat = 24 + } + + var body: some View { + NavigationStack { + content + .navigationTitle(TimelineCopy.title) + .navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.medium, .large]) + .task { await viewModel.load() } // fresh VM per presentation → one fetch + } + + // MARK: - Phase switch + + @ViewBuilder private var content: some View { + switch viewModel.phase { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .empty: + emptyState + case .failed: + failedState + case .loaded(let events): + eventList(events) + } + } + + /// Server replied `[]` — covers both "no activity yet" and timeline + /// capture disabled host-side (src/server.ts:589-591). An empty timeline + /// is a normal state, NEVER an error (task spec). + private var emptyState: some View { + ContentUnavailableView( + TimelineCopy.emptyTitle, + systemImage: "clock.badge.questionmark", + description: Text(TimelineCopy.emptyDetail) + ) + } + + /// Explicit, retryable error state — the fetch can fail transiently + /// (LAN hop, host asleep); retry re-runs the same load path. + private var failedState: some View { + ContentUnavailableView { + Label(TimelineCopy.loadFailed, systemImage: "wifi.exclamationmark") + } description: { + Text(TimelineCopy.loadFailedDetail) + } actions: { + Button(TimelineCopy.retry) { + Task { await viewModel.load() } + } + .buttonStyle(.borderedProminent) + } + } + + // MARK: - Rows (newest-first, already ordered by the VM) + + private func eventList(_ events: [TimelineEvent]) -> some View { + List { + // Offset identity (not `at`): the server can ingest several + // events in the same millisecond, and rows are static. + ForEach(Array(events.enumerated()), id: \.offset) { _, event in + row(event) + } + } + .listStyle(.plain) + } + + private func row(_ event: TimelineEvent) -> some View { + HStack(spacing: Metrics.rowSpacing) { + Text(TimelineRowFormat.timeLabel(atMs: event.at)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + Text(verbatim: TimelineClassStyle.glyph(for: event.class)) + .font(.callout) + .foregroundStyle(TimelineClassStyle.color(for: event.class)) + .frame(width: Metrics.iconColumnWidth) + // Server-derived phrase — untrusted: verbatim (never + // LocalizedStringKey/Markdown) + hard single-line truncation. + Text(verbatim: event.label) + .font(.subheadline) + .lineLimit(1) + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + } +} + +// MARK: - class → icon / color mapping + +/// Glyphs mirror web `timelineIcon` verbatim (public/timeline.ts:88-96). +/// Colors are semantic — the web CSS defines NO tl-icon-* colors, so iOS +/// aligns with SessionListScreen's status color convention (waiting=orange, +/// stuck=red) and extends it. Total functions: the server is untrusted, so an +/// unknown class degrades to a neutral glyph/color instead of trapping (even +/// though `APIClient.events` already drops unknown classes — defense in depth). +enum TimelineClassStyle { + static let fallbackGlyph = "•" + + static func glyph(for cls: String) -> String { + switch cls { + case "tool": return "🔧" + case "waiting": return "⏳" + case "done": return "✓" + case "stuck": return "⚠" + case "user": return "💬" + default: return fallbackGlyph + } + } + + static func color(for cls: String) -> Color { + switch cls { + case "tool": return .blue + case "waiting": return .orange + case "done": return .green + case "stuck": return .red + case "user": return .purple + default: return .secondary + } + } +} + +// MARK: - Row time formatting + +/// Mirrors web `formatHHMM` (public/timeline.ts:101-106): 24-hour wall-clock +/// "HH:mm". Fixed POSIX locale so a 12-hour user locale can't leak AM/PM into +/// the fixed format; timezone injectable for deterministic tests. +enum TimelineRowFormat { + private static let millisecondsPerSecond = 1_000.0 + + static func timeLabel(atMs: Int, timeZone: TimeZone = .current) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "HH:mm" + let date = Date(timeIntervalSince1970: Double(atMs) / millisecondsPerSecond) + return formatter.string(from: date) + } +} + +// MARK: - 用户可见文案(中文具名常量,plan 工程标准) + +enum TimelineCopy { + static let title = "活动时间线" + static let emptyTitle = "暂无活动" + static let emptyDetail = "会话还没有可展示的事件;主机关闭时间线(TIMELINE_ENABLED=0)时也会显示为空。" + static let loadFailed = "时间线加载失败" + static let loadFailedDetail = "无法从主机获取活动时间线,请检查连接后重试。" + static let retry = "重试" +} diff --git a/ios/App/WebTerm/ViewModels/DiffViewModel.swift b/ios/App/WebTerm/ViewModels/DiffViewModel.swift new file mode 100644 index 0000000..6c0333a --- /dev/null +++ b/ios/App/WebTerm/ViewModels/DiffViewModel.swift @@ -0,0 +1,164 @@ +import Foundation +import Observation +import WireProtocol + +/// T-iOS-27 · State for the read-only diff viewer (`DiffScreen`). +/// +/// The fetch closure is injected —— 生产由 `forProject` 用 `DiffFetcher` 包一 +/// 层(App 装配缝,T-iOS-26 的 ProjectDetail 入口按 (endpoint, path) 构造); +/// 测试注入 fake。服务器数据的不可信处理(宽容解码、错误分类)已在 +/// `DiffFetcher` 完成;本 VM 只区分用户可见的四种结局: +/// - 非空 files → `.loaded`:文件头/hunk 头/行**平铺**为惰性列表行模型 +/// (巨 diff —— 服务器上限 DIFF_MAX_BYTES 2MB —— 绝不合成单个 Text 块); +/// - `[]` → `.empty`(truncated 位透传,截断到空也要提示); +/// - 400/非法请求 → `.failed(.pathInvalid)`、404 → `.failed(.notFound)`、 +/// 其余 → `.failed(.unavailable)` —— 全部可经 `load()` 重试; +/// - staged/unstaged 切换(`setStaged`)→ 以新范围重新 fetch,同值 no-op。 +@MainActor +@Observable +final class DiffViewModel { + /// 用户可见的失败三分类(文案映射在 DiffScreen 的 DiffCopy)。 + enum Failure: Equatable { + case pathInvalid + case notFound + case unavailable + } + + /// Rendering phase — an explicit enum so the screen can never show an + /// error and diff rows at the same time (same discipline as Timeline). + enum Phase: Equatable { + case loading + /// 服务器回了空 files(该范围无改动)。truncated 位仍需提示。 + case empty(truncated: Bool) + case loaded(DiffPresentation) + case failed(Failure) + } + + private(set) var phase: Phase = .loading + /// 当前范围:false = 工作区(unstaged),true = 已暂存(--staged)。 + private(set) var staged = false + + @ObservationIgnored + private let fetch: @Sendable (_ staged: Bool) async throws -> DiffResult + + init(fetch: @escaping @Sendable (_ staged: Bool) async throws -> DiffResult) { + self.fetch = fetch + } + + /// 生产装配缝:`DiffScreen(endpoint:path:http:)` 经此构造(T-iOS-26 的 + /// ProjectDetail 入口只需转手这三样,无需触碰 DiffFetcher)。 + static func forProject( + endpoint: HostEndpoint, path: String, http: any HTTPTransport + ) -> DiffViewModel { + let fetcher = DiffFetcher(endpoint: endpoint, http: http) + return DiffViewModel(fetch: { staged in + try await fetcher.fetch(path: path, staged: staged) + }) + } + + /// Fetch and present. Also the「重试」path: callable again from `.failed`. + func load() async { + phase = .loading + do { + let result = try await fetch(staged) + phase = Self.presentation(for: result) + } catch let error as DiffFetchError { + phase = .failed(Self.failure(for: error)) + } catch { + phase = .failed(.unavailable) // 传输层等其余错误:可重试兜底 + } + } + + /// staged/unstaged 切换 → 重新 fetch;同值绝不重复请求(任务 Steps)。 + func setStaged(_ newValue: Bool) async { + guard newValue != staged else { return } + staged = newValue + await load() + } + + // MARK: - 纯呈现归约(静态,可单测) + + static func presentation(for result: DiffResult) -> Phase { + guard !result.files.isEmpty else { + return .empty(truncated: result.truncated) + } + return .loaded(DiffPresentation( + rows: makeRows(files: result.files), truncated: result.truncated + )) + } + + /// 平铺:文件头 → (binary 占位 | hunk 头 → 行…)…,逐文件顺序保持服务器 + /// 返回顺序;binary 短路 hunks(镜像 web renderDiffFile 的 early return, + /// public/diff.ts:163-166)。id 为稳定递增序号(行内容可重复,不能当身份)。 + static func makeRows(files: [DiffFile]) -> [DiffRow] { + var rows: [DiffRow] = [] + for file in files { + rows.append(DiffRow(id: rows.count, kind: .fileHeader(header(for: file)))) + if file.binary { + rows.append(DiffRow(id: rows.count, kind: .binaryNotice)) + continue + } + for hunk in file.hunks { + rows.append(DiffRow(id: rows.count, kind: .hunkHeader(hunk.header))) + for line in hunk.lines { + rows.append(DiffRow( + id: rows.count, kind: .line(kind: line.kind, text: line.text) + )) + } + } + } + return rows + } + + private static func failure(for error: DiffFetchError) -> Failure { + switch error { + case .invalidRequest, .pathInvalid: + return .pathInvalid + case .projectNotFound: + return .notFound + case .invalidResponse, .unexpectedStatus: + return .unavailable + } + } + + /// Rename 显示 “old → new”(镜像 web,public/diff.ts:146-150),其余显示 + /// newPath。路径是服务器字节 —— 屏幕侧一律 `Text(verbatim:)`。 + private static func header(for file: DiffFile) -> DiffFileHeader { + let pathLabel = file.status == .renamed && file.oldPath != file.newPath + ? "\(file.oldPath) → \(file.newPath)" + : file.newPath + return DiffFileHeader( + pathLabel: pathLabel, added: file.added, + removed: file.removed, status: file.status + ) + } +} + +// MARK: - 行模型(惰性列表的最小呈现单元) + +/// Display-ready flattened diff(不可变快照)。 +struct DiffPresentation: Equatable { + let rows: [DiffRow] + let truncated: Bool +} + +/// One lazy-list row. `id` = 平铺序号(稳定、唯一——文本内容可重复)。 +struct DiffRow: Equatable, Identifiable { + enum Kind: Equatable { + case fileHeader(DiffFileHeader) + case binaryNotice + case hunkHeader(String) + case line(kind: DiffLineKind, text: String) + } + + let id: Int + let kind: Kind +} + +/// File-header row payload(路径标签已含 rename 箭头)。 +struct DiffFileHeader: Equatable { + let pathLabel: String + let added: Int + let removed: Int + let status: DiffFileStatus +} diff --git a/ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift b/ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift new file mode 100644 index 0000000..61c8646 --- /dev/null +++ b/ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift @@ -0,0 +1,76 @@ +import APIClient +import Foundation +import Observation +import WireProtocol + +/// T-iOS-26 · 项目详情状态(`GET /projects/detail?path=` → phase 状态机, +/// 与 DiffViewModel/TimelineViewModel 同一纪律)。 +/// +/// fetch 闭包注入 —— 生产由 `forHost` 包 `APIClient.projectDetail(path:)` +/// (builder 百分号编码、400/404/500 `{error}` → 类型化错误均在 T-iOS-38 +/// 完成并已测);测试注入 fake。本 VM 只归约用户可见的三种结局: +/// - 成功 → `.loaded(ProjectDetail)`(sessions/worktrees/hasClaudeMd 透传); +/// - 400 → `.failed(.pathInvalid)`、404 → `.failed(.notFound)`、 +/// 500/解码/传输 → `.failed(.unavailable)` —— 全部可经 `load()` 重试。 +@MainActor +@Observable +final class ProjectDetailViewModel { + /// 用户可见的失败三分类(文案映射在 ProjectDetailScreen)。 + enum Failure: Equatable { + case pathInvalid + case notFound + case unavailable + } + + enum Phase: Equatable { + case loading + case loaded(ProjectDetail) + case failed(Failure) + } + + private(set) var phase: Phase = .loading + /// 详情/diff 的目标项目路径(来自列表行 —— 服务器数据;只透传给 + /// builder,绝不本地拼 URL)。 + let path: String + + @ObservationIgnored + private let fetch: @Sendable () async throws -> ProjectDetail + + init(path: String, fetch: @escaping @Sendable () async throws -> ProjectDetail) { + self.path = path + self.fetch = fetch + } + + /// 生产装配缝(ProjectsViewModel.makeDetailViewModel 经此构造)。 + static func forHost( + endpoint: HostEndpoint, http: any HTTPTransport, path: String + ) -> ProjectDetailViewModel { + let client = APIClient(endpoint: endpoint, http: http) + return ProjectDetailViewModel(path: path, fetch: { + try await client.projectDetail(path: path) + }) + } + + /// Fetch 并呈现。也是「重试」路径:`.failed` 后可再次调用。 + func load() async { + phase = .loading + do { + phase = .loaded(try await fetch()) + } catch let error as APIClientError { + phase = .failed(Self.failure(for: error)) + } catch { + phase = .failed(.unavailable) // 传输层等其余错误:可重试兜底 + } + } + + private static func failure(for error: APIClientError) -> Failure { + switch error { + case .projectPathInvalid, .invalidRequest: + return .pathInvalid + case .projectNotFound: + return .notFound + default: + return .unavailable + } + } +} diff --git a/ios/App/WebTerm/ViewModels/ProjectGrouping.swift b/ios/App/WebTerm/ViewModels/ProjectGrouping.swift new file mode 100644 index 0000000..c07fc54 --- /dev/null +++ b/ios/App/WebTerm/ViewModels/ProjectGrouping.swift @@ -0,0 +1,192 @@ +import APIClient +import Foundation + +/// T-iOS-26 · Projects 列表的纯分组逻辑 —— 逐条镜像 web v0.6 的 +/// public/projects.ts(filterProjects / sortProjects / groupProjects / +/// displayLabel),使手机与网页看到同一套分区。 +/// +/// 关键契约:**组 key 与 web 逐字节一致**(namespace 首见大小写、哨兵 +/// `" active"` / `" other"`),因为折叠状态以组 key 存进跨端共享的 +/// `/prefs.collapsed` —— key 漂移 = 两端互相丢折叠状态。 +/// label 则是本端 UI 文案(哨兵组中文;namespace 组 = key 本身)。 +/// +/// Active 置顶(任务要求 assert reality):`ProjectSessionRef.exited` 字段 +/// 实测存在(src/types.ts:262-269),running = 任一会话 `!exited` —— 与 web +/// `hasRunningSession` 同一判据;running 项目**复制**进置顶组,原组保留。 +enum ProjectGrouping { + /// 哨兵组 key(带空格前缀,不可能与真实 `First.Second` namespace 撞车, + /// 镜像 public/projects.ts:83-84)。 + static let activeGroupKey = " active" + static let otherGroupKey = " other" + /// namespace 至少要这么多成员才配得上独立分区(MIN_GROUP_SIZE)。 + static let minGroupSize = 2 + + // MARK: - filter(镜像 filterProjects:name/path 子串,大小写不敏感) + + static func filter(_ projects: [ProjectInfo], query: String) -> [ProjectInfo] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return projects } + let lower = trimmed.lowercased() + return projects.filter { + $0.name.lowercased().contains(lower) || $0.path.lowercased().contains(lower) + } + } + + // MARK: - sort(镜像 sortProjects:收藏优先 → lastActiveMs 降序;显式稳定) + + /// JS 的 `Array.sort` 是稳定的,web 靠它在平局时保持服务器顺序;Swift 的 + /// `sorted` 未承诺稳定 → 用输入下标做最终决胜键,行为逐字节对齐。 + static func sort(_ projects: [ProjectInfo], favourites: Set) -> [ProjectInfo] { + projects.enumerated().sorted { a, b in + let aFav = favourites.contains(a.element.path) + let bFav = favourites.contains(b.element.path) + if aFav != bFav { return aFav } + let aActive = a.element.lastActiveMs ?? 0 + let bActive = b.element.lastActiveMs ?? 0 + if aActive != bActive { return aActive > bActive } + return a.offset < b.offset + }.map(\.element) + } + + // MARK: - group(镜像 groupProjects) + + static func group(_ projects: [ProjectInfo], favourites: Set) -> [ProjectGroup] { + let (namespaceGroups, other) = bucketByNamespace(projects, favourites: favourites) + + // 分组一无所获 → 单一 flat 组(无 chrome 的平铺网格回退)。 + guard !namespaceGroups.isEmpty else { + return [makeGroup( + key: otherGroupKey, label: ProjectsCopy.allGroupLabel, + kind: .flat, items: projects, favourites: favourites + )] + } + + var groups: [ProjectGroup] = [] + let active = projects.filter(hasRunningSession) + if !active.isEmpty { + groups.append(makeGroup( + key: activeGroupKey, label: ProjectsCopy.activeGroupLabel, + kind: .active, items: active, favourites: favourites + )) + } + groups.append(contentsOf: orderedByRecency(namespaceGroups)) + if !other.isEmpty { + groups.append(makeGroup( + key: otherGroupKey, label: ProjectsCopy.otherGroupLabel, + kind: .other, items: other, favourites: favourites + )) + } + return groups + } + + /// 组内卡片名:namespace 组剥掉 `.` 前缀(大小写不敏感),免得每张 + /// 卡都在喊 `Billo.Platform.`;哨兵组保留全名(镜像 displayLabel)。 + static func displayLabel(name: String, groupKey: String) -> String { + if groupKey == activeGroupKey || groupKey == otherGroupKey { return name } + let prefix = "\(groupKey)." + guard name.lowercased().hasPrefix(prefix.lowercased()) else { return name } + return String(name.dropFirst(prefix.count)) + } + + static func hasRunningSession(_ project: ProjectInfo) -> Bool { + project.sessions.contains { !$0.exited } + } + + // MARK: - Internals + + /// namespace = 名字的前两个点分段(`'a.b.c'` → `'a.b'`;不足两段 → nil), + /// 空段保留(镜像 JS `split('.')` 语义)。 + private static func namespaceKey(_ name: String) -> String? { + let segments = name.split(separator: ".", omittingEmptySubsequences: false) + guard segments.count >= 2 else { return nil } + return segments.prefix(2).joined(separator: ".") + } + + /// 桶 key 小写去重、显示名取首见大小写、首见顺序稳定(镜像 JS Map 的 + /// 插入序遍历)。返回 (成组的 namespace, 塌进 Other 的项目)。 + private static func bucketByNamespace( + _ projects: [ProjectInfo], favourites: Set + ) -> (groups: [ProjectGroup], other: [ProjectInfo]) { + var bucketOrder: [String] = [] + var buckets: [String: (display: String, items: [ProjectInfo])] = [:] + var other: [ProjectInfo] = [] + for project in projects { + guard let namespace = namespaceKey(project.name) else { + other.append(project) + continue + } + let lowerKey = namespace.lowercased() + if var bucket = buckets[lowerKey] { + bucket.items.append(project) + buckets[lowerKey] = bucket + } else { + buckets[lowerKey] = (namespace, [project]) + bucketOrder.append(lowerKey) + } + } + + var groups: [ProjectGroup] = [] + for lowerKey in bucketOrder { + guard let bucket = buckets[lowerKey] else { continue } + if bucket.items.count < minGroupSize { + other.append(contentsOf: bucket.items) + } else { + groups.append(makeGroup( + key: bucket.display, label: bucket.display, + kind: .namespace, items: bucket.items, favourites: favourites + )) + } + } + return (groups, other) + } + + /// namespace 分区按组内最新活跃时间降序,平局按 label 升序(镜像 + /// groupProjects 的 sort)。 + private static func orderedByRecency(_ groups: [ProjectGroup]) -> [ProjectGroup] { + groups.sorted { a, b in + let aMax = maxLastActive(a.projects) + let bMax = maxLastActive(b.projects) + if aMax != bMax { return aMax > bMax } + return a.label.localizedCompare(b.label) == .orderedAscending + } + } + + private static func maxLastActive(_ items: [ProjectInfo]) -> Int { + items.reduce(0) { max($0, $1.lastActiveMs ?? 0) } + } + + private static func makeGroup( + key: String, label: String, kind: ProjectGroupKind, + items: [ProjectInfo], favourites: Set + ) -> ProjectGroup { + ProjectGroup( + key: key, label: label, kind: kind, + projects: sort(items, favourites: favourites), + activeCount: items.filter(hasRunningSession).count + ) + } +} + +/// 分区种类(镜像 web `ProjectGroupKind`)。 +enum ProjectGroupKind: Equatable, Sendable { + case active + case namespace + case other + case flat +} + +/// 一个可折叠分区的不可变快照(镜像 web `ProjectGroup`)。 +struct ProjectGroup: Equatable, Identifiable, Sendable { + /// 折叠状态的持久化 key —— 必须与 web 逐字节一致(/prefs 跨端共享)。 + let key: String + let label: String + let kind: ProjectGroupKind + /// 已排序(收藏优先 → 活跃降序)。 + let projects: [ProjectInfo] + /// 有运行中会话的项目数(折叠的分区绝不悄悄埋掉活跃会话)。 + let activeCount: Int + + var id: String { key } + /// Active now 永远展开(它就是重点);flat 无 chrome。 + var isCollapsible: Bool { kind == .namespace || kind == .other } +} diff --git a/ios/App/WebTerm/ViewModels/ProjectsViewModel.swift b/ios/App/WebTerm/ViewModels/ProjectsViewModel.swift new file mode 100644 index 0000000..9c6e957 --- /dev/null +++ b/ios/App/WebTerm/ViewModels/ProjectsViewModel.swift @@ -0,0 +1,236 @@ +import APIClient +import Foundation +import HostRegistry +import Observation +import WireProtocol + +/// T-iOS-26 · Projects 列表状态(镜像 web v0.6 projects 面板的数据面): +/// `GET /projects` 的分组网格 + `GET/PUT /prefs` 的跨端收藏/折叠往返 + +/// "在此仓库开新会话" 的导航信号。 +/// +/// Documented decisions: +/// - **prefs 是 clobber 敏感面**:`PUT /prefs` 服务器侧整体替换 blob +/// (src/server.ts:286-288),所以一律经 `UiPrefs` 的 unknown-key-preserving +/// API 改写(web/未来服务器写入的未知顶层键原样带回);prefs GET 失败时 +/// toggle 只改本地、**绝不 PUT**(空底盘上写 = 清掉服务器收藏)。 +/// - **PUT 成功采纳服务器 echo 为新真相**(服务器会 sanitize,本地状态与 +/// 服务器逐字节对齐);失败保留本地改动 + 显式文案,下次 toggle 自然重试。 +/// - **prefs 只在首次 load 拉一次**(镜像 web mountProjects.init:刷新节奏 +/// 只重取项目,绝不用旧服务器值clobber本地未同步的编辑)。 +/// - 列表数据是不可信服务器输入:解码宽容(APIClient 已做),路径在铸造 +/// OpenRequest 前再过 `Validation.isAbsoluteCwd`(deep-link 同款纪律)。 +@MainActor +@Observable +final class ProjectsViewModel { + // MARK: - Observable state + + private(set) var projects: [ProjectInfo] = [] + private(set) var hasLoadedOnce = false + /// 最近一次项目刷新失败(旧列表保留 —— 一次丢包不清屏)。 + private(set) var fetchErrorMessage: String? + /// prefs 加载失败:收藏/折叠只在本地生效、不回写(防 clobber)。 + private(set) var prefsErrorMessage: String? + /// 最近一次 PUT /prefs 失败(本地改动已保留)。 + private(set) var prefsSyncErrorMessage: String? + /// "在此仓库开新会话" 被拒(非法路径)。 + private(set) var openErrorMessage: String? + /// 收藏的项目路径(保序:新收藏追加尾部)。 + private(set) var favourites: [String] = [] + /// 组 key → 已折叠(只存 true;展开是默认态 —— 与 web/服务器一致)。 + private(set) var collapsedGroups: [String: Bool] = [:] + /// 导航信号(T-iOS-26):每次请求新 id,重复点按也能触发 onChange。 + private(set) var openRequest: ProjectOpenRequest? + /// 搜索框绑定(.searchable)。 + var searchText = "" + + // MARK: - Dependencies & internal state + + let host: HostRegistry.Host + @ObservationIgnored let http: any HTTPTransport + /// 最近一次已知的完整 prefs blob(含未知键)。nil = 从未成功加载 → + /// 绝不 PUT。 + @ObservationIgnored private var prefsBase: UiPrefs? + + private var client: APIClient { + APIClient(endpoint: host.endpoint, http: http) + } + + init(host: HostRegistry.Host, http: any HTTPTransport) { + self.host = host + self.http = http + } + + // MARK: - Derived view state + + /// 分组快照:搜索过滤 → web 同款分组(收藏优先排序在组内完成)。 + var groups: [ProjectGroup] { + ProjectGrouping.group( + ProjectGrouping.filter(projects, query: searchText), + favourites: Set(favourites) + ) + } + + var isSearching: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// 搜索中强制全展开(结果绝不藏在折叠 caret 后,镜像 web renderGrid)。 + func isCollapsed(_ group: ProjectGroup) -> Bool { + group.isCollapsible && !isSearching && collapsedGroups[group.key] == true + } + + func isFavourite(_ path: String) -> Bool { + favourites.contains(path) + } + + /// 空态文案:无项目 vs 无搜索结果(镜像 web renderGrid 的两种 msg)。 + var emptyStateMessage: String? { + guard hasLoadedOnce, fetchErrorMessage == nil else { return nil } + if projects.isEmpty { return ProjectsCopy.emptyNoProjects } + if ProjectGrouping.filter(projects, query: searchText).isEmpty { + return ProjectsCopy.emptyNoMatch + } + return nil + } + + // MARK: - Load / refresh + + /// 首次进入:prefs(一次)+ 项目。prefs 之后留在内存(镜像 web init)。 + func load() async { + await loadPrefsIfNeeded() + await refresh() + } + + /// 只重取项目(下拉刷新与重试共用)。失败保留旧列表 + 显式文案。 + func refresh() async { + do { + projects = try await client.projects() + fetchErrorMessage = nil + hasLoadedOnce = true + } catch { + fetchErrorMessage = ProjectsCopy.fetchFailed(Self.errorDetail(error)) + } + } + + private func loadPrefsIfNeeded() async { + guard prefsBase == nil else { return } + do { + let prefs = try await client.prefs() + adopt(prefs) + prefsErrorMessage = nil + } catch { + prefsErrorMessage = ProjectsCopy.prefsLoadFailed + } + } + + // MARK: - Favourites / collapse(跨端 prefs 往返) + + func toggleFavourite(path: String) async { + favourites = favourites.contains(path) + ? favourites.filter { $0 != path } + : favourites + [path] + await persistPrefs() + } + + func toggleCollapsed(key: String) async { + if collapsedGroups[key] == true { + collapsedGroups = collapsedGroups.filter { $0.key != key } + } else { + collapsedGroups = collapsedGroups.merging([key: true]) { _, new in new } + } + await persistPrefs() + } + + /// 以最近已知的完整 blob 为底盘改写两个已知键(未知键原样带回),PUT + /// 后采纳服务器 echo。无底盘(prefs 从未加载成功)→ 本地生效、不回写。 + private func persistPrefs() async { + guard let base = prefsBase else { return } + let next = base.withFavourites(favourites).withCollapsed(collapsedGroups) + prefsBase = next // 乐观:连续 toggle 在同一底盘上叠加 + do { + let echoed = try await client.putPrefs(next) + adopt(echoed) + prefsSyncErrorMessage = nil + } catch { + prefsSyncErrorMessage = ProjectsCopy.prefsSyncFailed(Self.errorDetail(error)) + } + } + + private func adopt(_ prefs: UiPrefs) { + prefsBase = prefs + favourites = prefs.favourites + collapsedGroups = prefs.collapsed + } + + // MARK: - 在此仓库开新会话(T-iOS-26 的核心动作) + + /// `attach(null, cwd)` + attach 后注入 `claude\r`(帧序由 engine 的 + /// attach-first 队列保证)。路径是服务器数据 → 不可信,铸造前先验证。 + func requestOpenClaude(cwd: String) { + guard Validation.isAbsoluteCwd(cwd) else { + openErrorMessage = ProjectsCopy.openClaudeInvalidPath + return + } + openErrorMessage = nil + openRequest = ProjectOpenRequest( + id: UUID(), host: host, cwd: cwd, + bootstrapInput: ProjectLaunch.claudeBootstrapInput + ) + } + + // MARK: - Detail assembly(详情/差异屏的装配缝) + + func makeDetailViewModel(path: String) -> ProjectDetailViewModel { + .forHost(endpoint: host.endpoint, http: http, path: path) + } + + // MARK: - Helpers + + private static func errorDetail(_ error: any Error) -> String { + (error as? APIClientError)?.message ?? error.localizedDescription + } +} + +/// "在此仓库开新会话" 的导航信号 —— `SessionListViewModel.OpenRequest` 的 +/// cwd+bootstrap 平行变体(该类型属 T-iOS-13 文件,不越界扩展;由 +/// AppCoordinator.openProject 消费)。 +struct ProjectOpenRequest: Equatable, Sendable, Identifiable { + let id: UUID + let host: HostRegistry.Host + /// 新会话的工作目录(已验证为绝对路径)。 + let cwd: String + /// attach 后注入的首条输入(nil = 只开 shell)。 + let bootstrapInput: String? +} + +/// 项目内启动命令(镜像 public/tabs.ts:679 `openProject` 的默认 cmd)。 +enum ProjectLaunch { + /// Enter 是 `\r`(0x0D)不是 `\n` —— 合成输入的经典坑(CLAUDE.md Gotchas)。 + static let claudeBootstrapInput = "claude\r" +} + +/// 用户可见文案(中文具名常量,plan §4)。 +enum ProjectsCopy { + static let title = "项目" + static let searchPrompt = "筛选项目…" + static let activeGroupLabel = "活跃中" + static let otherGroupLabel = "其他" + static let allGroupLabel = "全部项目" + static let dirtyBadge = "未提交" + static let emptyNoProjects = "未发现项目。请检查主机的 PROJECT_ROOTS 配置。" + static let emptyNoMatch = "没有匹配的项目。" + static let prefsLoadFailed = "云端收藏/折叠状态加载失败,本次修改仅在本机生效。" + static let openClaudeInvalidPath = "项目路径无效,无法开新会话。" + + static func fetchFailed(_ detail: String) -> String { + "刷新项目列表失败:\(detail)" + } + + static func prefsSyncFailed(_ detail: String) -> String { + "收藏/折叠状态同步失败:\(detail)" + } + + static func activeCountBadge(_ count: Int) -> String { + "● \(count) 活跃" + } +} diff --git a/ios/App/WebTerm/ViewModels/SessionListViewModel.swift b/ios/App/WebTerm/ViewModels/SessionListViewModel.swift index b83e80b..ed0621c 100644 --- a/ios/App/WebTerm/ViewModels/SessionListViewModel.swift +++ b/ios/App/WebTerm/ViewModels/SessionListViewModel.swift @@ -2,6 +2,7 @@ import APIClient import Foundation import HostRegistry import Observation +import SessionCore import WireProtocol /// T-iOS-13 · Session list state (merged chooser + dashboard, plan §7): @@ -56,6 +57,12 @@ final class SessionListViewModel { let info: LiveSessionInfo let isPendingApproval: Bool let telemetry: TelemetryChips.Model? + /// Sanitized OSC title (T-iOS-23) — nil = no title, row falls back to + /// the cwd-derived name. NEVER raw delegate input (TitleSanitizer). + let title: String? + /// `lastOutputAt` strictly newer than the local last-seen watermark + /// (UnreadLedger; mirrors the web tab dot, public/tabs.ts hasActivity). + let isUnread: Bool var id: UUID { info.id } var indicator: StatusIndicator { @@ -105,10 +112,19 @@ final class SessionListViewModel { @ObservationIgnored private let clock: any Clock /// Injected time source for telemetry staleness (ms since epoch). @ObservationIgnored private let nowMs: @Sendable () -> Int + @ObservationIgnored private let unreadStore: any UnreadWatermarkStore @ObservationIgnored private var pollTask: Task? @ObservationIgnored private var latestFetched: [LiveSessionInfo] = [] @ObservationIgnored private var hiddenKilledIds: Set = [] @ObservationIgnored private var pendingSessionIds: Set = [] + /// T-iOS-23 · last-seen watermarks (loaded once, persisted per `markSeen`). + /// NOT pruned to the active host's fetch — other hosts' watermarks must + /// survive a host switch; `UnreadLedger.maxEntries` bounds growth instead. + @ObservationIgnored private var unreadLedger: UnreadLedger + /// T-iOS-23 · sanitized OSC titles by sessionId. In-memory only — replay + /// re-emits the OSC sequence on next attach, persistence would only keep + /// stale titles. Other hosts' entries never match a visible row. + @ObservationIgnored private var sessionTitles: [UUID: String] = [:] @ObservationIgnored private var hasAttemptedHostLoad = false @ObservationIgnored private var hasLoadedOnce = false @@ -120,12 +136,15 @@ final class SessionListViewModel { hostStore: any HostStore, http: any HTTPTransport, clock: any Clock, + unreadStore: any UnreadWatermarkStore, nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs ) { self.hostStore = hostStore self.http = http self.clock = clock + self.unreadStore = unreadStore self.nowMs = nowMs + unreadLedger = UnreadLedger(watermarks: unreadStore.load()) } // MARK: - Visibility lifecycle (poll task owner) @@ -230,6 +249,32 @@ final class SessionListViewModel { rebuildRows() } + // MARK: - Unread watermarks (T-iOS-23; UnreadLedger + persisted store) + + /// The user just looked at (or is about to look at) this session: stamp + /// the last-seen watermark NOW and persist it. Called on row tap + /// (`openSession`) and by the coordinator when a terminal closes — output + /// that streamed while the user was watching must not relight the dot. + func markSeen(sessionId: UUID) { + unreadLedger = unreadLedger.record(seen: sessionId, at: nowMs()) + unreadStore.save(unreadLedger.watermarks) + rebuildRows() + } + + // MARK: - OSC title registry (T-iOS-23; fed by TerminalViewModel wiring) + + /// Surface a terminal-reported OSC title on the session's list row. + /// `title` is UNTRUSTED (host/attacker-controlled OSC payload) — sanitized + /// again at THIS boundary regardless of upstream (sanitize is idempotent). + /// Empty after sanitisation clears the entry (web `title.trim() || null`). + func setSessionTitle(sessionId: UUID, title: String) { + let sanitized = TitleSanitizer.sanitize(title) + sessionTitles = sanitized.isEmpty + ? sessionTitles.filter { $0.key != sessionId } + : sessionTitles.merging([sessionId: sanitized]) { _, new in new } + rebuildRows() + } + // MARK: - Swipe-to-kill (optimistic + rollback) func kill(sessionId: UUID) async { @@ -258,8 +303,11 @@ final class SessionListViewModel { } /// Open a listed session. Unknown ids are refused at the boundary. + /// Tapping = seeing: the unread watermark is stamped immediately (the + /// terminal replays everything anyway; the dot must not outlive the tap). func openSession(id: UUID) { guard let activeHost, rows.contains(where: { $0.id == id }) else { return } + markSeen(sessionId: id) openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: id) } @@ -272,7 +320,11 @@ final class SessionListViewModel { SessionRow( info: info, isPendingApproval: pendingSessionIds.contains(info.id), - telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now) + telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now), + title: sessionTitles[info.id], + isUnread: unreadLedger.isUnread( + sessionId: info.id, lastOutputAt: info.lastOutputAt + ) ) } } diff --git a/ios/App/WebTerm/ViewModels/TerminalViewModel.swift b/ios/App/WebTerm/ViewModels/TerminalViewModel.swift index ae8381a..e3e97ad 100644 --- a/ios/App/WebTerm/ViewModels/TerminalViewModel.swift +++ b/ios/App/WebTerm/ViewModels/TerminalViewModel.swift @@ -67,6 +67,10 @@ final class TerminalViewModel { /// Server-adopted session id (ALWAYS the server-issued one — persisting it /// per host is the T-iOS-15 wiring's job via `LastSessionStore`). private(set) var sessionId: UUID? + /// Sanitized OSC title (T-iOS-23). Raw `setTerminalTitle` delegate input + /// is HOST/ATTACKER-CONTROLLED and passes `TitleSanitizer` at THIS + /// boundary; nil = no title (empty after sanitisation). + private(set) var terminalTitle: String? /// Read-only = no input reaches the PTY (exit / terminal failure). Resize /// is NOT gated here — the engine owns terminal-state dropping. @@ -121,6 +125,17 @@ final class TerminalViewModel { /// Test tap, called after each event is applied (state already coherent). @ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)? + // MARK: - OSC title surface (T-iOS-23; wired by TerminalSessionController) + + /// List-side registry hook: fires with the ADOPTED sessionId and the + /// SANITIZED title ("" = title cleared — the registry drops the entry). + /// Titles arriving before adoption are held and forwarded once on + /// `.adopted` (defensive — `attached` always precedes output on the wire). + @ObservationIgnored var onTitleChanged: (@MainActor (UUID, String) -> Void)? + /// Latest sanitized title not yet delivered to `onTitleChanged` because + /// no sessionId was known at the time. nil = nothing held. + @ObservationIgnored private var heldTitleForward: String? + private struct CountWaiter { let target: Int let continuation: CheckedContinuation @@ -194,6 +209,19 @@ final class TerminalViewModel { enqueueSend(.input(data: data)) } + /// SwiftTerm reported an OSC 0/2 title (`setTerminalTitle` delegate). + /// Sanitize FIRST — the raw string is untrusted terminal output — then + /// surface locally and forward to the list registry (T-iOS-23). + func setTerminalTitle(_ raw: String) { + let sanitized = TitleSanitizer.sanitize(raw) + terminalTitle = sanitized.isEmpty ? nil : sanitized + guard let sessionId else { + heldTitleForward = sanitized + return + } + onTitleChanged?(sessionId, sanitized) + } + /// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded — /// the engine validates bounds and owns terminal-state dropping. Valid /// dims are remembered in `lastSentDims` so the T-iOS-15 wiring can feed @@ -214,6 +242,10 @@ final class TerminalViewModel { applyConnection(state) case .adopted(let id): sessionId = id // ALWAYS adopt the server-issued id + if let held = heldTitleForward { + heldTitleForward = nil + onTitleChanged?(id, held) + } case .output(let data): deliverOutput(data) case .exited(let code, let reason): diff --git a/ios/App/WebTerm/ViewModels/TimelineViewModel.swift b/ios/App/WebTerm/ViewModels/TimelineViewModel.swift new file mode 100644 index 0000000..4358233 --- /dev/null +++ b/ios/App/WebTerm/ViewModels/TimelineViewModel.swift @@ -0,0 +1,81 @@ +import Foundation +import Observation +import WireProtocol + +/// T-iOS-24 · State for the full-timeline drill-down sheet (`TimelineSheet`). +/// +/// One VM per presentation (the container view builds a fresh one on each +/// digest「展开」tap), so every open re-fetches `GET /live-sessions/:id/events`. +/// The fetch closure is injected — production wraps `APIClient.events` via +/// `TerminalSessionController.timelineEventsSource`; tests inject fakes. +/// +/// Server data is UNTRUSTED at this boundary (plan §4): the tolerant decode +/// (malformed / unknown-class entries dropped, non-array → `[]`) already +/// happened inside `APIClient.events` → `TimelineEvent.decodeList`. This VM +/// only distinguishes the three USER-visible outcomes: +/// - `[]` → `.empty` — the server replies `[]` both for "no activity yet" and +/// for timeline capture disabled (src/server.ts:589-591); NEVER an error; +/// - thrown fetch → `.failed` — explicit, retryable (`load()` again); +/// - events → `.loaded`, ordered exactly like the web panel. +@MainActor +@Observable +final class TimelineViewModel: Identifiable { + /// Rendering phase — an explicit enum so the sheet can never show an + /// error and rows at the same time. + enum Phase: Equatable { + case loading + /// Display-ready rows: capped + newest-first (web parity, see + /// `presentation(for:)`). + case loaded([TimelineEvent]) + /// Server returned `[]` (no activity OR timeline disabled host-side). + case empty + /// Fetch failed — retryable via `load()`. + case failed + } + + /// Web parity: `DEFAULT_MAX_EVENTS` (public/timeline.ts:20). + static let maxEvents = 50 + + /// `.sheet(item:)` identity — a fresh VM per presentation. + nonisolated let id = UUID() + + private(set) var phase: Phase = .loading + + @ObservationIgnored private let fetch: @Sendable () async throws -> [TimelineEvent] + + init(fetch: @escaping @Sendable () async throws -> [TimelineEvent]) { + self.fetch = fetch + } + + /// Assembly seam for the digest「展开」entry point: no adopted sessionId + /// yet → no sheet (defensive — a digest only arrives after `attached`, so + /// the id is normally known). Otherwise the id is passed to `source` + /// verbatim on every load. + static func forSession( + _ sessionId: UUID?, + source: @escaping @Sendable (UUID) async throws -> [TimelineEvent] + ) -> TimelineViewModel? { + guard let sessionId else { return nil } + return TimelineViewModel(fetch: { try await source(sessionId) }) + } + + /// Fetch and present. Also the「重试」path: callable again from `.failed`. + func load() async { + phase = .loading + do { + let events = try await fetch() + phase = Self.presentation(for: events) + } catch { + phase = .failed // user-facing copy lives in TimelineSheet + } + } + + /// Pure presentation reducer, mirroring the web panel's render() pipeline + /// line for line (public/timeline.ts:174-189): the server returns + /// oldest-first → `slice(0, maxEvents)` first, THEN reverse, so the sheet + /// shows the same capped slice newest-first as the web timeline panel. + static func presentation(for events: [TimelineEvent]) -> Phase { + guard !events.isEmpty else { return .empty } + return .loaded(Array(events.prefix(maxEvents).reversed())) + } +} diff --git a/ios/App/WebTerm/WebTermApp.swift b/ios/App/WebTerm/WebTermApp.swift index 64ced54..1a25153 100644 --- a/ios/App/WebTerm/WebTermApp.swift +++ b/ios/App/WebTerm/WebTermApp.swift @@ -3,13 +3,33 @@ import SwiftUI /// T-iOS-15 · App entry: assemble the production dependency graph once and /// hand it to the coordinator (Pairing → SessionList → Terminal). All wiring /// lives under `Wiring/`; this file stays a thin `@main`. +/// +/// T-iOS-21(增量接线,任务允许的最小改动): +/// - `@UIApplicationDelegateAdaptor` 挂 `PushAppDelegate`(remote-notification +/// 回调只能走 UIApplicationDelegate);SwiftUI 生命周期保证本 `init` 先于 +/// `didFinishLaunching` 运行,coordinator 经一次性静态槽交接过去,在启动 +/// 完成前就把通知 delegate 设好(冷启动动作不丢)。 +/// - scenePhase 转 `.active`(含每次可见启动)→ 幂等 `activate()`(授权 + +/// token 注册补账);后台拉起(锁屏 Allow/Deny)不激活场景,正好不注册。 @main struct WebTermApp: App { - @State private var coordinator = AppCoordinator(environment: .production()) + @UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate + @Environment(\.scenePhase) private var scenePhase + @State private var coordinator: AppCoordinator + + init() { + let coordinator = AppCoordinator(environment: .production()) + _coordinator = State(initialValue: coordinator) + PushAppDelegate.bootstrap = coordinator // 消费点:didFinishLaunching + } var body: some Scene { WindowGroup { RootView(coordinator: coordinator) + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + pushDelegate.activatePush() + } } } } diff --git a/ios/App/WebTerm/Wiring/AppCoordinator.swift b/ios/App/WebTerm/Wiring/AppCoordinator.swift index 6dd3356..6b65ee1 100644 --- a/ios/App/WebTerm/Wiring/AppCoordinator.swift +++ b/ios/App/WebTerm/Wiring/AppCoordinator.swift @@ -2,6 +2,7 @@ import Foundation import HostRegistry import Observation import SwiftUI +import WireProtocol /// T-iOS-15 · Navigation + lifecycle owner: Pairing → SessionList → Terminal /// with the production dependency graph (plan §7 T-iOS-15 step 1). @@ -25,16 +26,25 @@ final class AppCoordinator { /// Add-host pairing VM (sheet from the list header). private(set) var addHostPairingViewModel: PairingViewModel? var isAddHostPresented = false + /// T-iOS-26 · Projects sheet(入口在 RootView 的 toolbar —— 不碰 + /// `SessionListScreen`,该文件 W7 内归 T-iOS-23)。每次呈现新建 VM, + /// prefs 每次进入都重新拉取。 + private(set) var projectsViewModel: ProjectsViewModel? + var isProjectsPresented = false let sessionList: SessionListViewModel @ObservationIgnored let environment: AppEnvironment + /// T-iOS-22 · Deep-link handler; all routing/wiring logic lives in + /// DeepLinkRouter.swift (incl. the `makeDeepLinkHandler` extension). + @ObservationIgnored private(set) lazy var deepLink: DeepLinkHandler = makeDeepLinkHandler() init(environment: AppEnvironment) { self.environment = environment sessionList = SessionListViewModel( hostStore: environment.hostStore, http: environment.http, - clock: ContinuousClock() + clock: ContinuousClock(), + unreadStore: environment.unreadStore ) } @@ -54,6 +64,7 @@ final class AppCoordinator { if route == .pairing { rootPairingViewModel = makePairingViewModel() } + await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22) } /// First-run pairing done → move to the list (the paired host is already @@ -83,30 +94,110 @@ final class AppCoordinator { Task { await sessionList.reloadHosts() } } + // MARK: - Projects (T-iOS-26) + + /// Toolbar 入口(RootView):以当前活跃主机呈现 Projects sheet。 + func presentProjects() { + guard let host = sessionList.activeHost else { return } + projectsViewModel = ProjectsViewModel(host: host, http: environment.http) + isProjectsPresented = true + } + + /// Sheet 消失(打开会话 OR 手动关闭):丢弃 VM。 + func projectsDismissed() { + projectsViewModel = nil + } + + /// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+ + /// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。 + func openProject(_ request: ProjectOpenRequest) { + guard terminalController == nil else { return } // one foreground session + isProjectsPresented = false + projectsViewModel = nil + startTerminal( + host: request.host, sessionId: nil, + spawnCwd: request.cwd, bootstrapInput: request.bootstrapInput + ) + } + // MARK: - Terminal open/close /// `SessionListScreen.onOpen` (one navigation signal per tap) and the /// "继续上次" banner both land here. `sessionId == nil` = new session. func open(_ request: SessionListViewModel.OpenRequest) { guard terminalController == nil else { return } // one foreground session + startTerminal(host: request.host, sessionId: request.sessionId) + } + + /// 唯一的 controller 构造点(普通打开与 T-iOS-26 项目内 spawn 共用)。 + private func startTerminal( + host: HostRegistry.Host, + sessionId: UUID?, + spawnCwd: String? = nil, + bootstrapInput: String? = nil + ) { let controller = TerminalSessionController( - host: request.host, - sessionId: request.sessionId, + host: host, + sessionId: sessionId, environment: environment, onPendingChanged: { [weak self] sessionId, pending in self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending) - } + }, + onTitleChanged: { [weak self] sessionId, title in + // T-iOS-23 · OSC title → list row (already sanitized in the + // VM; the list VM sanitizes once more at its own boundary). + self?.sessionList.setSessionTitle(sessionId: sessionId, title: title) + }, + spawnCwd: spawnCwd, + bootstrapInput: bootstrapInput ) terminalController = controller controller.start() } - /// Back navigation popped the terminal: explicit detach. + /// Back navigation popped the terminal: explicit detach. Also the first + /// half of every session SWITCH (single live WS invariant, plan §1): + /// list back-nav and `openDeepLinkedSession` both close here before the + /// next `open` — one engine at a time, always close→open with replay. func closeTerminal() { + // T-iOS-23 · leaving = seen: stamp the unread watermark for the + // adopted session so output watched in the terminal never relights + // the list dot. + if let sessionId = terminalController?.terminalViewModel.sessionId { + sessionList.markSeen(sessionId: sessionId) + } terminalController?.teardown() terminalController = nil } + // MARK: - "在当前目录开新会话" (T-iOS-29) + + /// 当前终端会话的 cwd。解析次序:/live-sessions 行数据(server-adopted + /// id 匹配行,列表 VM 的最近快照)→ controller 的 `spawnCwd`(T-iOS-26 + /// 项目 fresh-spawn 尚未进列表轮询)→ nil(未知)。服务器数据不可信: + /// 非绝对路径按未知处理(ProjectsViewModel 同款纪律;engine 侧还会再验)。 + var currentTerminalCwd: String? { + guard let controller = terminalController else { return nil } + let fromRows = controller.terminalViewModel.sessionId.flatMap { id in + sessionList.rows.first(where: { $0.id == id })?.info.cwd + } + let candidate = fromRows ?? controller.spawnCwd + return candidate.flatMap { Validation.isAbsoluteCwd($0) ? $0 : nil } + } + + /// TerminalScreen 工具栏与 exit 横幅共用动作:在当前会话的 cwd fresh + /// spawn(`attach(null, cwd)`,镜像 web tabs.ts `newTab()` M6)。单活 + /// WS 不变式:先 `closeTerminal()`(旧会话记 last-seen 水位、engine + /// detach),再开新 controller。cwd 未知 → 普通新会话;无打开的终端 + /// → no-op。不注入 bootstrap —— "开新 shell"不是"起 claude"。 + func openNewSessionInCurrentCwd() { + guard let controller = terminalController else { return } + let host = controller.host + let cwd = currentTerminalCwd + closeTerminal() + startTerminal(host: host, sessionId: nil, spawnCwd: cwd) + } + // MARK: - "继续上次" (cold start step 5) var continueLastSessionId: UUID? { diff --git a/ios/App/WebTerm/Wiring/AppEnvironment.swift b/ios/App/WebTerm/Wiring/AppEnvironment.swift index f53fe38..db6fe0a 100644 --- a/ios/App/WebTerm/Wiring/AppEnvironment.swift +++ b/ios/App/WebTerm/Wiring/AppEnvironment.swift @@ -31,6 +31,10 @@ struct AppEnvironment: Sendable { /// over the real transports (two-step: RO GET, then WS attach + guarded /// kill; only runs after the user's explicit confirm, T-iOS-12). let probe: PairingViewModel.Probe + /// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults). + /// `var` + default so the memberwise init stays source-compatible for + /// pre-P1 call sites while tests can inject an in-memory fake. + var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore() static func production() -> AppEnvironment { let http = URLSessionHTTPTransport() diff --git a/ios/App/WebTerm/Wiring/RootView.swift b/ios/App/WebTerm/Wiring/RootView.swift index f470bcf..1c6c48a 100644 --- a/ios/App/WebTerm/Wiring/RootView.swift +++ b/ios/App/WebTerm/Wiring/RootView.swift @@ -28,12 +28,35 @@ struct RootView: View { .onChange(of: scenePhase) { _, phase in coordinator.handleScenePhase(phase) } + .onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22 + .alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) { + Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() } + } message: { + Text(coordinator.deepLink.hintMessage ?? "") + } .sheet( isPresented: $coordinator.isAddHostPresented, onDismiss: { coordinator.addHostDismissed() } ) { addHostSheet } + .sheet( + isPresented: $coordinator.isProjectsPresented, + onDismiss: { coordinator.projectsDismissed() } + ) { + projectsSheet + } + } + + /// Deep-link hint alert (unknown host / store failure, T-iOS-22). + private var deepLinkHintBinding: Binding { + Binding( + get: { coordinator.deepLink.hintMessage != nil }, + set: { presented in + guard !presented else { return } + coordinator.deepLink.clearHint() + } + ) } // MARK: - Route switch @@ -66,6 +89,22 @@ struct RootView: View { onAddHost: { coordinator.presentAddHost() } ) .safeAreaInset(edge: .bottom) { continueLastBanner } + // T-iOS-26 · Projects 入口挂在 RootView 层(SessionListScreen 是 + // T-iOS-23 的 W7 独占文件 —— 列表侧入口整合移交给它)。leading 位, + // 避开列表自己的 topBarTrailing hostMenu。 + .toolbar { projectsToolbarItem } + } + + @ToolbarContentBuilder private var projectsToolbarItem: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { + coordinator.presentProjects() + } label: { + Label(RootCopy.projects, systemImage: "folder") + } + .disabled(coordinator.sessionList.activeHost == nil) + .accessibilityIdentifier("sessions.projectsButton") + } } // MARK: - "继续上次" highlight (cold start step 5) @@ -99,7 +138,17 @@ struct RootView: View { @ViewBuilder private var terminalDestination: some View { if let controller = coordinator.terminalController { - TerminalContainerView(controller: controller) + TerminalContainerView( + controller: controller, + onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() } + ) + // T-iOS-29 · identity PER CONTROLLER: an in-place session switch + // (new-in-cwd, deep link) swaps the controller while the + // destination stays presented — without a new identity SwiftUI + // keeps the old SwiftTerm UIView and the new ViewModel's output + // sink never attaches (makeUIView never re-runs). Also resets the + // container's per-session @State (plan-gate dismissal, timeline). + .id(controller.id) } } @@ -114,6 +163,20 @@ struct RootView: View { } } } + + // MARK: - Projects sheet (T-iOS-26) + + /// 自带 NavigationStack:列表 → 详情 → 差异都在 sheet 内 push; + /// "在此仓库开新会话" 关掉 sheet 并在根导航推入终端。 + @ViewBuilder private var projectsSheet: some View { + if let viewModel = coordinator.projectsViewModel { + NavigationStack { + ProjectsScreen(viewModel: viewModel) { request in + coordinator.openProject(request) + } + } + } + } } private enum RootMetrics { @@ -123,4 +186,5 @@ private enum RootMetrics { private enum RootCopy { static let continueLast = "继续上次会话" + static let projects = "项目" } diff --git a/ios/App/WebTerm/Wiring/TerminalContainerView.swift b/ios/App/WebTerm/Wiring/TerminalContainerView.swift index 1fe3222..cb81d69 100644 --- a/ios/App/WebTerm/Wiring/TerminalContainerView.swift +++ b/ios/App/WebTerm/Wiring/TerminalContainerView.swift @@ -19,15 +19,26 @@ import SwiftUI /// removes the gate server-side → `planGate` goes nil → sheet dismisses. struct TerminalContainerView: View { let controller: TerminalSessionController + /// T-iOS-29 · pass-through to TerminalScreen's toolbar/exit-banner + /// "在当前目录开新会话" action (RootView supplies the coordinator hop). + var onNewSessionInCwd: (@MainActor () -> Void)? = nil /// Epoch of a plan gate the user swiped away — suppresses re-present for /// THAT gate only; a new epoch re-presents automatically. @State private var dismissedPlanGateEpoch: Int? + /// T-iOS-24 (additive) · Non-nil while the full-timeline sheet is up; a + /// FRESH VM per presentation (each open re-fetches /events). + @State private var timelineViewModel: TimelineViewModel? + /// T-iOS-25 (additive) · Quick-reply palette store — per-container over + /// `.standard` defaults (non-secret UI prefs, plan §5.3 split). + @State private var quickReplyStore = QuickReplyStore() private enum Metrics { static let overlaySpacing: CGFloat = 8 static let overlayHorizontalPadding: CGFloat = 12 /// Clears TerminalScreen's own top-aligned ReconnectBanner zone. static let overlayTopPadding: CGFloat = 52 + /// Breathing room between the chip row and the keyboard/key-bar edge. + static let quickReplyBottomPadding: CGFloat = 8 } private enum Copy { @@ -35,11 +46,33 @@ struct TerminalContainerView: View { } var body: some View { - TerminalScreen(viewModel: controller.terminalViewModel) - .id(controller.generation) + TerminalScreen( + viewModel: controller.terminalViewModel, + onNewSessionInCwd: onNewSessionInCwd + ) + .id(controller.generation) .navigationBarTitleDisplayMode(.inline) .overlay(alignment: .top) { topOverlays } + .overlay(alignment: .bottom) { quickReplyOverlay } .sheet(isPresented: planGateBinding) { planGateSheet } + .sheet(item: $timelineViewModel) { TimelineSheet(viewModel: $0) } + } + + // MARK: - Quick-reply chips (T-iOS-25, additive) + + /// Chips float at the bottom edge (above the keyboard's safe area) ONLY + /// while a gate is waiting — visibility/read-only logic lives in + /// `QuickReplyBar.isVisible`, driven by the SAME fan-out branches the two + /// VMs already consume (no extra branch needed). + @ViewBuilder private var quickReplyOverlay: some View { + QuickReplyBar( + terminalViewModel: controller.terminalViewModel, + gateViewModel: controller.gateViewModel, + store: quickReplyStore + ) + .padding(.horizontal, Metrics.overlayHorizontalPadding) + .padding(.bottom, Metrics.quickReplyBottomPadding) + .animation(.default, value: controller.gateViewModel.currentGate) } // MARK: - Digest + tool gate (top stack) @@ -51,7 +84,7 @@ struct TerminalContainerView: View { AwayDigestView( digest: digest, isExpanded: gateViewModel.isDigestExpanded, - onExpand: { gateViewModel.expandDigest() }, + onExpand: { expandDigestAndPresentTimeline() }, onDismiss: { gateViewModel.dismissDigest() } ) } @@ -73,6 +106,21 @@ struct TerminalContainerView: View { .animation(.default, value: gateViewModel.digest) } + // MARK: - Timeline drill-down (T-iOS-24, additive) + + /// The digest「展开」affordance does BOTH: inline expansion (which cancels + /// the auto-fade, T-iOS-14 semantics — the digest must survive under the + /// sheet) and the full-timeline sheet. No adopted sessionId yet → + /// `forSession` returns nil → inline expand only (defensive; a digest + /// only ever arrives after `attached`). + private func expandDigestAndPresentTimeline() { + controller.gateViewModel.expandDigest() + timelineViewModel = TimelineViewModel.forSession( + controller.terminalViewModel.sessionId, + source: controller.timelineEventsSource + ) + } + // MARK: - Plan gate sheet private var hasDismissedPendingPlanGate: Bool { diff --git a/ios/App/WebTerm/Wiring/TerminalSessionController.swift b/ios/App/WebTerm/Wiring/TerminalSessionController.swift index 95daf22..946083b 100644 --- a/ios/App/WebTerm/Wiring/TerminalSessionController.swift +++ b/ios/App/WebTerm/Wiring/TerminalSessionController.swift @@ -37,9 +37,26 @@ final class TerminalSessionController: Identifiable { private(set) var terminalViewModel: TerminalViewModel private(set) var gateViewModel: GateViewModel - @ObservationIgnored private let host: HostRegistry.Host + /// T-iOS-29 · read-only exposure for the coordinator's new-in-cwd flow + /// (the host to reopen against — a controller is host-bound for life). + @ObservationIgnored let host: HostRegistry.Host @ObservationIgnored private let environment: AppEnvironment @ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void + /// T-iOS-23 · OSC-title outlet (adopted sessionId + SANITIZED title) — + /// re-attached to every rebuilt TerminalViewModel so a background→ + /// foreground rebuild never silently drops the list-title feed. + @ObservationIgnored private let onTitleChanged: @MainActor (UUID, String) -> Void + /// T-iOS-26 · fresh-spawn 变体:新会话的工作目录(`attach(null, cwd)`)。 + /// 仅在目标 sessionId 为 nil 时生效 —— 服务器对既有会话忽略 cwd。 + /// (T-iOS-29 起对 coordinator 只读可见:fresh spawn 未进列表轮询时, + /// new-in-cwd 以它为 cwd 回退。) + @ObservationIgnored let spawnCwd: String? + /// T-iOS-26 · attach 后注入的首条输入(如 `claude\r`)。engine 的 + /// attach-first 队列语义保证它绝不先于 attach 出线;采纳既有会话 id 后 + /// 的重开(suspend→resume)不再注入。 + @ObservationIgnored private let bootstrapInput: String? + /// 最近一次 open(+bootstrap) 提交任务 —— 测试屏障(ProjectOpenWiringTests)。 + @ObservationIgnored private(set) var openTask: Task? @ObservationIgnored private var engine: SessionEngine @ObservationIgnored private var fanOut: EventFanOut @ObservationIgnored private var bridge: SessionActivityBridge @@ -62,11 +79,17 @@ final class TerminalSessionController: Identifiable { host: HostRegistry.Host, sessionId: UUID?, environment: AppEnvironment, - onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void + onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void, + onTitleChanged: @escaping @MainActor (UUID, String) -> Void = { _, _ in }, + spawnCwd: String? = nil, + bootstrapInput: String? = nil ) { self.host = host self.environment = environment self.onPendingChanged = onPendingChanged + self.onTitleChanged = onTitleChanged + self.spawnCwd = spawnCwd + self.bootstrapInput = bootstrapInput self.targetSessionId = sessionId let stack = Self.makeStack( host: host, environment: environment, onPendingChanged: onPendingChanged @@ -76,6 +99,7 @@ final class TerminalSessionController: Identifiable { terminalViewModel = stack.terminalViewModel gateViewModel = stack.gateViewModel bridge = stack.bridge + terminalViewModel.onTitleChanged = onTitleChanged } // MARK: - Lifecycle entry points @@ -86,9 +110,7 @@ final class TerminalSessionController: Identifiable { guard !hasStarted, !isTornDown else { return } hasStarted = true startConsumers() - let engine = engine - let sessionId = targetSessionId - Task { await engine.open(sessionId: sessionId, cwd: nil) } + openEngineAtTarget() } /// scenePhase → `.background`: clean detach (PTY keeps running). @@ -112,9 +134,23 @@ final class TerminalSessionController: Identifiable { isSuspended = false rebuildStack() startConsumers() + openEngineAtTarget() + } + + /// (Re)open 当前目标:既有会话 → 裸 attach;fresh spawn(目标 id 为 + /// nil)→ 带 cwd attach + attach 后注入 bootstrap(T-iOS-26)。suspend + /// 前已采纳的 id 走前一分支,bootstrap 不会重放。 + private func openEngineAtTarget() { let engine = engine let sessionId = targetSessionId - Task { await engine.open(sessionId: sessionId, cwd: nil) } + let cwd = sessionId == nil ? spawnCwd : nil + let bootstrap = sessionId == nil ? bootstrapInput : nil + openTask = Task { + await engine.open(sessionId: sessionId, cwd: cwd) + if let bootstrap { + await engine.send(.input(data: bootstrap)) + } + } } /// Alive-engine `.active` hop: feed the engine the last VALID dims the @@ -136,6 +172,15 @@ final class TerminalSessionController: Identifiable { Task { await engine.close() } } + // MARK: - Timeline drill-down (T-iOS-24, additive) + + /// Per-host timeline fetcher for the container's `TimelineSheet` — the + /// same `APIClient.events` wrapper the engine's away-digest uses; the + /// single derivation point stays `AppEnvironment.makeEventsSource`. + var timelineEventsSource: @Sendable (UUID) async throws -> [TimelineEvent] { + environment.makeEventsSource(endpoint: host.endpoint) + } + // MARK: - Stack assembly private struct Stack { @@ -186,6 +231,7 @@ final class TerminalSessionController: Identifiable { terminalViewModel = stack.terminalViewModel gateViewModel = stack.gateViewModel bridge = stack.bridge + terminalViewModel.onTitleChanged = onTitleChanged // rebuilt VM re-wired generation += 1 // new SwiftUI identity → fresh SwiftTerm view } diff --git a/ios/App/WebTerm/Wiring/UnreadWatermarkStore.swift b/ios/App/WebTerm/Wiring/UnreadWatermarkStore.swift new file mode 100644 index 0000000..10c8654 --- /dev/null +++ b/ios/App/WebTerm/Wiring/UnreadWatermarkStore.swift @@ -0,0 +1,44 @@ +import Foundation + +/// T-iOS-23 · Persistence seam for `SessionCore.UnreadLedger` watermarks. +/// The ledger itself is persistence-agnostic (plan §7) — the App layer wires +/// UserDefaults here. NON-SECRET UI state only (plan §5.3 split: Keychain = +/// secrets, UserDefaults = prefs), same tier as `LastSessionStore`. +protocol UnreadWatermarkStore: Sendable { + func load() -> [UUID: Int] + func save(_ watermarks: [UUID: Int]) +} + +/// UserDefaults-backed implementation with an injectable suite for tests +/// (mirrors `UserDefaultsLastSessionStore`). `@unchecked Sendable`: +/// `UserDefaults` is documented thread-safe and this wrapper adds no mutable +/// state of its own. +struct UserDefaultsUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable { + private static let key = "unreadWatermarks" + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func load() -> [UUID: Int] { + guard let raw = defaults.dictionary(forKey: Self.key) as? [String: Int] else { + return [:] + } + // Stored data crosses a storage boundary — validate every key; garbage + // is dropped, never trusted (two case-variant spellings of one UUID + // collapse via max, so this can never crash on duplicates). + let pairs = raw.compactMap { key, value in + UUID(uuidString: key).map { ($0, value) } + } + return Dictionary(pairs, uniquingKeysWith: max) + } + + func save(_ watermarks: [UUID: Int]) { + let plist = Dictionary( + uniqueKeysWithValues: watermarks.map { ($0.key.uuidString, $0.value) } + ) + defaults.set(plist, forKey: Self.key) + } +} diff --git a/ios/App/WebTermTests/DeepLinkRouterTests.swift b/ios/App/WebTermTests/DeepLinkRouterTests.swift new file mode 100644 index 0000000..351d0b7 --- /dev/null +++ b/ios/App/WebTermTests/DeepLinkRouterTests.swift @@ -0,0 +1,338 @@ +import Foundation +import HostRegistry +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-22 · Deep-link routing: `webterminal://open?host=&join=` +/// 解析(全字段白名单,任一非法 → .ignore,绝不部分应用)、WEBTERM_GATE push +/// payload 的同源校验入口,以及 DeepLinkHandler 的冷启动 stash / 未知 host +/// 落配对页 / 忽略计数。 +@MainActor +@Suite("DeepLinkRouter") +struct DeepLinkRouterTests { + // MARK: - Fixtures + + /// v4 UUID(version nibble=4、variant=8)——通过 Validation.isValidSessionId。 + private static let validHostId = "11111111-2222-4333-8444-555555555555" + private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a" + /// version nibble=1:Foundation 的 UUID(uuidString:) 接受,但服务器的 + /// SESSION_ID_RE(v4 专属)拒绝——用它钉住"复用 Validation、不再造正则"。 + private static let v1StyleId = "11111111-2222-1333-8444-555555555555" + /// variant nibble=c(合法 v4 要求 8/9/a/b)。 + private static let badVariantId = "11111111-2222-4333-c444-555555555555" + + private static func url(_ string: String) throws -> URL { + try #require(URL(string: string)) + } + + private static func openURL( + host: String = validHostId, join: String = validSessionId + ) throws -> URL { + try url("webterminal://open?host=\(host)&join=\(join)") + } + + private static func makeHost(id: UUID) throws -> HostRegistry.Host { + let base = try #require(URL(string: "http://192.168.1.5:3000")) + let endpoint = try #require(HostEndpoint(baseURL: base)) + return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint) + } + + // MARK: - URL 解析:合法路径 + + @Test("合法 open 链接 → .openSession(hostId, sessionId)") + func validLinkRoutes() throws { + let route = DeepLinkRouter.route(url: try Self.openURL()) + + let hostId = try #require(UUID(uuidString: Self.validHostId)) + let sessionId = try #require(UUID(uuidString: Self.validSessionId)) + #expect(route == .openSession(hostId: hostId, sessionId: sessionId)) + } + + @Test("大写 UUID → 接受(Validation 大小写不敏感)") + func uppercaseUUIDsAccepted() throws { + let route = DeepLinkRouter.route( + url: try Self.openURL( + host: Self.validHostId.uppercased(), + join: Self.validSessionId.uppercased() + ) + ) + + let hostId = try #require(UUID(uuidString: Self.validHostId)) + let sessionId = try #require(UUID(uuidString: Self.validSessionId)) + #expect(route == .openSession(hostId: hostId, sessionId: sessionId)) + } + + @Test("未知多余 query 键 → 忽略键本身,链接仍路由") + func unknownExtraKeysIgnored() throws { + let route = DeepLinkRouter.route( + url: try Self.url( + "webterminal://open?host=\(Self.validHostId)&join=\(Self.validSessionId)&utm=x&foo=bar" + ) + ) + + let hostId = try #require(UUID(uuidString: Self.validHostId)) + let sessionId = try #require(UUID(uuidString: Self.validSessionId)) + #expect(route == .openSession(hostId: hostId, sessionId: sessionId)) + } + + // MARK: - URL 解析:白名单拒绝路径(任一非法 → .ignore) + + @Test("scheme 非 webterminal → .ignore") + func wrongSchemeIgnored() throws { + let route = DeepLinkRouter.route( + url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)") + ) + #expect(route == .ignore) + } + + @Test("action 非 open → .ignore") + func wrongActionIgnored() throws { + let route = DeepLinkRouter.route( + url: try Self.url("webterminal://kill?host=\(Self.validHostId)&join=\(Self.validSessionId)") + ) + #expect(route == .ignore) + } + + @Test("带非空 path → .ignore(白名单外的形状)") + func extraPathIgnored() throws { + let route = DeepLinkRouter.route( + url: try Self.url( + "webterminal://open/extra?host=\(Self.validHostId)&join=\(Self.validSessionId)" + ) + ) + #expect(route == .ignore) + } + + @Test("缺 host 键 → .ignore") + func missingHostKeyIgnored() throws { + let route = DeepLinkRouter.route( + url: try Self.url("webterminal://open?join=\(Self.validSessionId)") + ) + #expect(route == .ignore) + } + + @Test("缺 join 键 → .ignore") + func missingJoinKeyIgnored() throws { + let route = DeepLinkRouter.route( + url: try Self.url("webterminal://open?host=\(Self.validHostId)") + ) + #expect(route == .ignore) + } + + @Test("重复 host 键 → .ignore(歧义输入绝不部分应用)") + func duplicateHostKeyIgnored() throws { + let route = DeepLinkRouter.route( + url: try Self.url( + "webterminal://open?host=\(Self.validHostId)&host=\(Self.validHostId)&join=\(Self.validSessionId)" + ) + ) + #expect(route == .ignore) + } + + @Test("UUID 非 v4(version nibble=1)→ .ignore:钉住复用 Validation") + func nonV4UUIDIgnored() throws { + #expect(DeepLinkRouter.route(url: try Self.openURL(host: Self.v1StyleId)) == .ignore) + #expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.v1StyleId)) == .ignore) + } + + @Test("variant nibble 非 8/9/a/b → .ignore") + func badVariantUUIDIgnored() throws { + #expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.badVariantId)) == .ignore) + } + + @Test("空值 / 垃圾值 / 空 query → .ignore,不 crash") + func fuzzedValuesIgnored() throws { + #expect(DeepLinkRouter.route(url: try Self.url("webterminal://open?host=&join=")) == .ignore) + #expect(DeepLinkRouter.route(url: try Self.url("webterminal://open")) == .ignore) + #expect(DeepLinkRouter.route(url: try Self.openURL(host: "not-a-uuid")) == .ignore) + #expect( + DeepLinkRouter.route(url: try Self.openURL(join: "'; DROP TABLE sessions;--")) == .ignore + ) + } + + // MARK: - Push payload(WEBTERM_GATE,T-iOS-21 复用同一路由) + + @Test("payload 含合法 sessionId(多余键忽略)→ .gateSession") + func gatePayloadRoutes() throws { + let payload: [AnyHashable: Any] = [ + "sessionId": Self.validSessionId, + "cls": "gate", + "token": "opaque", + "aps": ["category": "WEBTERM_GATE"], + ] + + let sessionId = try #require(UUID(uuidString: Self.validSessionId)) + #expect(DeepLinkRouter.route(from: payload) == .gateSession(sessionId: sessionId)) + } + + @Test("payload 缺 sessionId / 非字符串 / 非 v4 → .ignore") + func gatePayloadRejectsInvalid() { + #expect(DeepLinkRouter.route(from: [:]) == .ignore) + #expect(DeepLinkRouter.route(from: ["sessionId": 42]) == .ignore) + #expect(DeepLinkRouter.route(from: ["sessionId": Self.v1StyleId]) == .ignore) + } +} + +/// DeepLinkHandler:热路径直达、冷启动 stash、未知 host 落配对、忽略计数。 +@MainActor +@Suite("DeepLinkHandler") +struct DeepLinkHandlerTests { + private enum StubError: Error { case storeFailure } + + /// 记录 handler 触发的动作(测试替身;生产侧由 AppCoordinator 提供闭包)。 + @MainActor + private final class ActionRecorder { + private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = [] + private(set) var pairingShownCount = 0 + + var actions: DeepLinkHandler.Actions { + DeepLinkHandler.Actions( + openSession: { [weak self] host, sessionId in + self?.opened.append((host, sessionId)) + }, + showPairing: { [weak self] in + self?.pairingShownCount += 1 + } + ) + } + } + + private static func makeHost(id: UUID = UUID()) throws -> HostRegistry.Host { + let base = try #require(URL(string: "http://192.168.1.5:3000")) + let endpoint = try #require(HostEndpoint(baseURL: base)) + return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint) + } + + private static func openURL(hostId: UUID, sessionId: UUID) throws -> URL { + try #require(URL( + string: "webterminal://open?host=\(hostId.uuidString)&join=\(sessionId.uuidString)" + )) + } + + /// UUID() 是 v4,但 uuidString 是大写——直接可作合法链接值。 + private static func sessionId() -> UUID { UUID() } + + // MARK: - 热路径(app 已就绪) + + @Test("热路径:已知 host → openSession(host, sessionId),无 hint") + func warmKnownHostOpens() async throws { + let host = try Self.makeHost() + let sessionId = Self.sessionId() + let recorder = ActionRecorder() + let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions) + await handler.markReady() + + await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId)) + + #expect(recorder.opened.count == 1) + #expect(recorder.opened.first?.host == host) + #expect(recorder.opened.first?.sessionId == sessionId) + #expect(recorder.pairingShownCount == 0) + #expect(handler.hintMessage == nil) + } + + @Test("未知 host id(UUID 合法但不在 store)→ showPairing + 提示文案") + func unknownHostRoutesToPairing() async throws { + let stored = try Self.makeHost() + let recorder = ActionRecorder() + let handler = DeepLinkHandler(loadHosts: { [stored] }, actions: recorder.actions) + await handler.markReady() + + await handler.handle( + url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId()) + ) + + #expect(recorder.opened.isEmpty) + #expect(recorder.pairingShownCount == 1) + #expect(handler.hintMessage == DeepLinkCopy.unknownHostHint) + } + + @Test("host store 读失败 → 显式错误文案,不 showPairing、不 crash") + func storeFailureSurfacesExplicitCopy() async throws { + let recorder = ActionRecorder() + let handler = DeepLinkHandler( + loadHosts: { throw StubError.storeFailure }, + actions: recorder.actions + ) + await handler.markReady() + + await handler.handle( + url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId()) + ) + + #expect(recorder.opened.isEmpty) + #expect(recorder.pairingShownCount == 0) + #expect(handler.hintMessage == DeepLinkCopy.hostLoadFailed) + } + + // MARK: - 非法链接:计数 + 不入 stash + + @Test("非法链接 → ignoredCount+1,无任何动作,也不入 stash") + func invalidLinkCountedNeverStashed() async throws { + let recorder = ActionRecorder() + let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions) + + await handler.handle(url: try #require(URL(string: "https://evil.example/?host=x"))) + #expect(handler.ignoredCount == 1) + + await handler.markReady() // ready 后 stash 若被污染会在此触发动作 + + #expect(recorder.opened.isEmpty) + #expect(recorder.pairingShownCount == 0) + #expect(handler.hintMessage == nil) + } + + // MARK: - 冷启动 stash + + @Test("冷启动:ready 前 handle → 挂起;markReady → 恰好应用一次") + func coldLaunchStashAppliesOnReady() async throws { + let host = try Self.makeHost() + let sessionId = Self.sessionId() + let recorder = ActionRecorder() + let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions) + + await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId)) + #expect(recorder.opened.isEmpty) // 未就绪:不得提前应用 + + await handler.markReady() + #expect(recorder.opened.count == 1) + #expect(recorder.opened.first?.sessionId == sessionId) + + await handler.markReady() // 幂等:stash 已清空,不得重放 + #expect(recorder.opened.count == 1) + } + + @Test("ready 前连续两条链接 → 只应用最新一条(单槽 stash)") + func stashKeepsOnlyLatestLink() async throws { + let host = try Self.makeHost() + let first = Self.sessionId() + let second = Self.sessionId() + let recorder = ActionRecorder() + let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions) + + await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: first)) + await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: second)) + await handler.markReady() + + #expect(recorder.opened.count == 1) + #expect(recorder.opened.first?.sessionId == second) + } + + // MARK: - hint 生命周期 + + @Test("clearHint → hintMessage 归位 nil") + func clearHintResets() async throws { + let recorder = ActionRecorder() + let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions) + await handler.markReady() + await handler.handle( + url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId()) + ) + #expect(handler.hintMessage != nil) + + handler.clearHint() + + #expect(handler.hintMessage == nil) + } +} diff --git a/ios/App/WebTermTests/DiffFetcherTests.swift b/ios/App/WebTermTests/DiffFetcherTests.swift new file mode 100644 index 0000000..e0abf0a --- /dev/null +++ b/ios/App/WebTermTests/DiffFetcherTests.swift @@ -0,0 +1,250 @@ +import Foundation +import TestSupport +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-27 · Diff 查看器 —— `DiffFetcher`(App 层,GET /projects/diff)。 +/// +/// 覆盖面(Steps 测试先行): +/// - 请求形状:GET + path/staged 严格百分号编码 + **无 Origin**(RO 端点, +/// §3.4 iff-G 铁律;服务器路由无 requireAllowedOrigin,src/server.ts:601-618); +/// - `staged` 序列化为 `1`/`0` —— 服务器精确匹配 `=== '1'`(src/server.ts:613); +/// - `DiffResult{files,staged,truncated}` 宽容解码(镜像 public/diff.ts +/// normalizeDiffResult:顶层三键缺一 → 整体无效;畸形 file/hunk/line 逐条 +/// 丢弃;未知 kind → .context、未知 status → .modified——服务器是不可信输入源); +/// - 400 → .pathInvalid、404 → .projectNotFound(SEC-H7 三叉校验失败)、 +/// 其余状态 → .unexpectedStatus; +/// - 空 path 客户端先拒(镜像服务器 400 规则),零网络。 +struct DiffFetcherTests { + // MARK: - Fixtures + + private static func makeEndpoint() throws -> HostEndpoint { + let url = try #require(URL(string: "http://127.0.0.1:3000")) + return try #require(HostEndpoint(baseURL: url)) + } + + private static func makeFetcher() throws -> (DiffFetcher, FakeHTTPTransport) { + let fake = FakeHTTPTransport() + let fetcher = DiffFetcher(endpoint: try makeEndpoint(), http: fake) + return (fetcher, fake) + } + + private static func url(_ s: String) throws -> URL { + try #require(URL(string: s)) + } + + /// 一份完整合法响应体(modified 文件 + 1 hunk + 4 行,覆盖 4 种 kind)。 + private static let fullBody = Data(""" + { + "files": [ + { + "oldPath": "src/a.ts", "newPath": "src/a.ts", "status": "modified", + "added": 2, "removed": 1, "binary": false, + "hunks": [ + { "header": "@@ -1,3 +1,4 @@", + "lines": [ + { "kind": "context", "text": "unchanged" }, + { "kind": "removed", "text": "old line" }, + { "kind": "added", "text": "new line" }, + { "kind": "meta", "text": "No newline at end of file" } + ] } + ] + } + ], + "staged": false, + "truncated": false + } + """.utf8) + + // MARK: - 请求形状 + + @Test("GET /projects/diff?path=&staged=0,无 Origin(RO 端点,iff-G 铁律)") + func requestShapeIsReadOnlyGetWithoutOrigin() async throws { + let (fetcher, fake) = try Self.makeFetcher() + let expected = try Self.url( + "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0" + ) + await fake.queueSuccess(url: expected, body: Self.fullBody) + + _ = try await fetcher.fetch(path: "/tmp/repo", staged: false) + + let recorded = await fake.recordedRequests + let request = try #require(recorded.first) + #expect(recorded.count == 1) + #expect(request.httpMethod == "GET") + #expect(request.url == expected) + // RO 一律不带 Origin:服务器若把该端点改为 G,这里先红(§3.4 铁律)。 + #expect(request.value(forHTTPHeaderField: "Origin") == nil) + } + + @Test("staged=true → staged=1(服务器 === '1' 精确匹配,src/server.ts:613)") + func stagedSerializesAsOne() async throws { + let (fetcher, fake) = try Self.makeFetcher() + let expected = try Self.url( + "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1" + ) + await fake.queueSuccess(url: expected, body: Self.fullBody) + + _ = try await fetcher.fetch(path: "/tmp/repo", staged: true) + + let recorded = await fake.recordedRequests + #expect(recorded.first?.url == expected) + } + + @Test("path 严格百分号编码:空格/&/+/= 全部转义(unreserved-only 集)") + func pathIsStrictlyPercentEncoded() async throws { + let (fetcher, fake) = try Self.makeFetcher() + // " " → %20、"&" → %26、"+" → %2B、"=" → %3D、"/" → %2F + let expected = try Self.url( + "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Fa%20b%26c%2Bd%3De&staged=0" + ) + await fake.queueSuccess(url: expected, body: Self.fullBody) + + _ = try await fetcher.fetch(path: "/tmp/a b&c+d=e", staged: false) + + let recorded = await fake.recordedRequests + #expect(recorded.first?.url == expected) + } + + @Test("空 path → .invalidRequest,零网络(镜像服务器 400 规则,先于 I/O 拒绝)") + func emptyPathRejectedBeforeNetwork() async throws { + let (fetcher, fake) = try Self.makeFetcher() + + await #expect(throws: DiffFetchError.invalidRequest) { + _ = try await fetcher.fetch(path: "", staged: false) + } + + let recorded = await fake.recordedRequests + #expect(recorded.isEmpty) + } + + // MARK: - 解码(服务器为不可信输入源) + + @Test("200 完整体 → DiffResult 各字段逐一到位") + func decodesFullBody() async throws { + let (fetcher, fake) = try Self.makeFetcher() + let expected = try Self.url( + "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0" + ) + await fake.queueSuccess(url: expected, body: Self.fullBody) + + let result = try await fetcher.fetch(path: "/tmp/repo", staged: false) + + #expect(result.staged == false) + #expect(result.truncated == false) + #expect(result.files.count == 1) + let file = try #require(result.files.first) + #expect(file.newPath == "src/a.ts") + #expect(file.status == .modified) + #expect(file.added == 2) + #expect(file.removed == 1) + #expect(file.binary == false) + let hunk = try #require(file.hunks.first) + #expect(hunk.header == "@@ -1,3 +1,4 @@") + #expect(hunk.lines.map(\.kind) == [.context, .removed, .added, .meta]) + #expect(hunk.lines.map(\.text) == [ + "unchanged", "old line", "new line", "No newline at end of file", + ]) + } + + @Test("宽容解码:畸形 file 条目丢弃、未知 kind → .context、未知 status → .modified") + func tolerantDecodingDropsMalformedEntries() async throws { + let (fetcher, fake) = try Self.makeFetcher() + let body = Data(""" + { + "files": [ + 42, + { "oldPath": 1, "newPath": "x" }, + { + "oldPath": "u.txt", "newPath": "u.txt", "status": "exotic-future", + "added": 0, "removed": 0, "binary": false, + "hunks": [ + { "lines": [] }, + { "header": "@@ -1 +1 @@", + "lines": [ + { "kind": "sparkle", "text": "mystery" }, + { "kind": "added" }, + { "kind": "added", "text": "kept" } + ] } + ] + } + ], + "staged": true, + "truncated": true + } + """.utf8) + let expected = try Self.url( + "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1" + ) + await fake.queueSuccess(url: expected, body: body) + + let result = try await fetcher.fetch(path: "/tmp/repo", staged: true) + + #expect(result.truncated == true) + #expect(result.files.count == 1) // 42 与缺字段对象被丢弃 + let file = try #require(result.files.first) + #expect(file.status == .modified) // 未知 status 降级 + #expect(file.hunks.count == 1) // 缺 header 的 hunk 被丢弃 + let lines = try #require(file.hunks.first).lines + #expect(lines.count == 2) // 缺 text 的行被丢弃 + #expect(lines.first?.kind == .context) // 未知 kind 降级(同 web df-context 兜底) + #expect(lines.last?.text == "kept") + } + + @Test("顶层畸形(缺键 / 非对象 / 非 JSON)→ .invalidResponse", arguments: [ + #"{"staged": false, "truncated": false}"#, + #"[1, 2, 3]"#, + "not json at all", + ]) + func malformedTopLevelIsInvalidResponse(raw: String) async throws { + let (fetcher, fake) = try Self.makeFetcher() + let expected = try Self.url( + "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0" + ) + await fake.queueSuccess(url: expected, body: Data(raw.utf8)) + + await #expect(throws: DiffFetchError.invalidResponse) { + _ = try await fetcher.fetch(path: "/tmp/repo", staged: false) + } + } + + // MARK: - 错误状态映射(route 层语义,src/server.ts:602-618) + + @Test("400 → .pathInvalid、404 → .projectNotFound、500 → .unexpectedStatus(500)") + func statusCodesMapToTypedErrors() async throws { + let cases: [(Int, DiffFetchError)] = [ + (400, .pathInvalid), + (404, .projectNotFound), + (500, .unexpectedStatus(500)), + (403, .unexpectedStatus(403)), + ] + for (status, expectedError) in cases { + let (fetcher, fake) = try Self.makeFetcher() + let expected = try Self.url( + "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0" + ) + await fake.queueSuccess( + url: expected, status: status, body: Data(#"{"error":"x"}"#.utf8) + ) + + await #expect(throws: expectedError) { + _ = try await fetcher.fetch(path: "/tmp/repo", staged: false) + } + } + } + + @Test("传输层错误原样上抛(不吞、不错误分类)") + func transportErrorsPropagate() async throws { + struct Boom: Error {} + let (fetcher, fake) = try Self.makeFetcher() + let expected = try Self.url( + "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0" + ) + await fake.queueFailure(url: expected, error: Boom()) + + await #expect(throws: Boom.self) { + _ = try await fetcher.fetch(path: "/tmp/repo", staged: false) + } + } +} diff --git a/ios/App/WebTermTests/DiffViewModelTests.swift b/ios/App/WebTermTests/DiffViewModelTests.swift new file mode 100644 index 0000000..5956b00 --- /dev/null +++ b/ios/App/WebTermTests/DiffViewModelTests.swift @@ -0,0 +1,242 @@ +import Foundation +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-27 · Diff 查看器 —— `DiffViewModel`(phase 状态机 + 行平铺)。 +/// +/// 覆盖面(Steps 测试先行): +/// - `DiffResult{files,staged,truncated}` → 呈现:文件头 → hunk 头 → 行, +/// 平铺为惰性列表的行模型(巨 diff 不合成单个 Text 块); +/// - truncated → banner 数据位(空态与非空态都要携带); +/// - staged/unstaged 切换 → 以新 staged 重新 fetch;同值不重复请求; +/// - path 非法(400/404)→ 显式友好错误 phase,其余错误 → 可重试的 unavailable; +/// - binary 文件短路(镜像 web renderDiffFile 的 early return)、renamed 路径标签。 +@MainActor +@Suite("DiffViewModel") +struct DiffViewModelTests { + // MARK: - Fixtures + + private nonisolated static func line(_ kind: DiffLineKind, _ text: String) -> DiffLine { + DiffLine(kind: kind, text: text) + } + + private nonisolated static func modifiedFile() -> DiffFile { + DiffFile( + oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified, + added: 1, removed: 1, binary: false, + hunks: [DiffHunk(header: "@@ -1,2 +1,2 @@", lines: [ + line(.removed, "old"), + line(.added, "new"), + ])] + ) + } + + private nonisolated static func result( + files: [DiffFile], staged: Bool = false, truncated: Bool = false + ) -> DiffResult { + DiffResult(files: files, staged: staged, truncated: truncated) + } + + /// 记录每次 fetch 收到的 staged 参数(@Sendable 闭包内可变状态 → actor)。 + private actor FetchRecorder { + private(set) var stagedArgs: [Bool] = [] + func record(_ staged: Bool) { stagedArgs = stagedArgs + [staged] } + } + + // MARK: - Phase 状态机 + + @Test("初始:phase = .loading、staged = false(默认工作区视图)") + func initialState() { + let vm = DiffViewModel(fetch: { _ in Self.result(files: []) }) + + #expect(vm.phase == .loading) + #expect(vm.staged == false) + } + + @Test("load 成功(非空)→ .loaded,行序:文件头 → hunk 头 → 行,id 稳定递增") + func loadFlattensRowsInOrder() async throws { + let vm = DiffViewModel(fetch: { _ in Self.result(files: [Self.modifiedFile()]) }) + + await vm.load() + + guard case .loaded(let presentation) = vm.phase else { + Issue.record("期望 .loaded,实际 \(vm.phase)") + return + } + #expect(presentation.truncated == false) + #expect(presentation.rows.map(\.id) == [0, 1, 2, 3]) // Identifiable:惰性列表身份 + #expect(presentation.rows.map(\.kind) == [ + .fileHeader(DiffFileHeader( + pathLabel: "src/a.ts", added: 1, removed: 1, status: .modified + )), + .hunkHeader("@@ -1,2 +1,2 @@"), + .line(kind: .removed, text: "old"), + .line(kind: .added, text: "new"), + ]) + } + + @Test("空 files → .empty(truncated 数据位透传)", arguments: [false, true]) + func emptyFilesBecomeEmptyPhase(truncated: Bool) async { + let vm = DiffViewModel(fetch: { _ in + Self.result(files: [], truncated: truncated) + }) + + await vm.load() + + #expect(vm.phase == .empty(truncated: truncated)) + } + + @Test("truncated + 非空 → .loaded 且 presentation.truncated = true(banner 提示位)") + func truncatedFlagSurvivesIntoPresentation() async throws { + let vm = DiffViewModel(fetch: { _ in + Self.result(files: [Self.modifiedFile()], truncated: true) + }) + + await vm.load() + + guard case .loaded(let presentation) = vm.phase else { + Issue.record("期望 .loaded,实际 \(vm.phase)") + return + } + #expect(presentation.truncated == true) + } + + // MARK: - 行平铺规则(镜像 web renderDiffFile) + + @Test("binary 文件:文件头 + 二进制占位,hunk 全部短路(web early return 同款)") + func binaryFileShortCircuitsHunks() { + let binary = DiffFile( + oldPath: "logo.png", newPath: "logo.png", status: .binary, + added: 0, removed: 0, binary: true, + hunks: [DiffHunk(header: "@@ junk @@", lines: [Self.line(.added, "x")])] + ) + + let rows = DiffViewModel.makeRows(files: [binary]) + + #expect(rows.map(\.kind) == [ + .fileHeader(DiffFileHeader( + pathLabel: "logo.png", added: 0, removed: 0, status: .binary + )), + .binaryNotice, + ]) + } + + @Test("renamed 且新旧路径不同 → 路径标签 “old → new”(web 同款箭头)") + func renamedFileShowsArrowLabel() { + let renamed = DiffFile( + oldPath: "old/name.ts", newPath: "new/name.ts", status: .renamed, + added: 0, removed: 0, binary: false, hunks: [] + ) + + let rows = DiffViewModel.makeRows(files: [renamed]) + + guard case .fileHeader(let header)? = rows.first?.kind else { + Issue.record("期望 fileHeader,实际 \(String(describing: rows.first))") + return + } + #expect(header.pathLabel == "old/name.ts → new/name.ts") + } + + @Test("untracked(0 hunk、非 binary)→ 仅文件头行") + func untrackedFileIsHeaderOnly() { + let untracked = DiffFile( + oldPath: "notes.md", newPath: "notes.md", status: .untracked, + added: 0, removed: 0, binary: false, hunks: [] + ) + + let rows = DiffViewModel.makeRows(files: [untracked]) + + #expect(rows.count == 1) + #expect(rows.first?.kind == .fileHeader(DiffFileHeader( + pathLabel: "notes.md", added: 0, removed: 0, status: .untracked + ))) + } + + // MARK: - 错误映射(400/404 → 友好错误,任务 Steps) + + @Test("DiffFetchError → Failure 映射:400/非法请求 → pathInvalid,404 → notFound,其余 → unavailable") + func fetchErrorsMapToFailures() async { + struct RandomError: Error {} + let cases: [(any Error, DiffViewModel.Failure)] = [ + (DiffFetchError.pathInvalid, .pathInvalid), + (DiffFetchError.invalidRequest, .pathInvalid), + (DiffFetchError.projectNotFound, .notFound), + (DiffFetchError.invalidResponse, .unavailable), + (DiffFetchError.unexpectedStatus(500), .unavailable), + (RandomError(), .unavailable), + ] + for (thrown, expected) in cases { + let vm = DiffViewModel(fetch: { _ in throw thrown }) + + await vm.load() + + #expect(vm.phase == .failed(expected)) + } + } + + @Test("重试:失败后再次 load 成功 → .loaded(错误态可恢复)") + func retryAfterFailureRecovers() async { + struct Boom: Error {} + let recorder = FetchRecorder() + let vm = DiffViewModel(fetch: { staged in + await recorder.record(staged) + if await recorder.stagedArgs.count == 1 { throw Boom() } + return Self.result(files: [Self.modifiedFile()]) + }) + + await vm.load() + #expect(vm.phase == .failed(.unavailable)) + + await vm.load() // DiffScreen「重试」按钮走的就是这条路径 + + guard case .loaded = vm.phase else { + Issue.record("期望 .loaded,实际 \(vm.phase)") + return + } + } + + // MARK: - staged/unstaged 切换(re-fetch 语义) + + @Test("setStaged(true) → 以 staged=true 重新 fetch;同值再设 → 不重复请求") + func setStagedRefetchesOnlyOnChange() async { + let recorder = FetchRecorder() + let vm = DiffViewModel(fetch: { staged in + await recorder.record(staged) + return Self.result(files: [], staged: staged) + }) + await vm.load() + + await vm.setStaged(true) + await vm.setStaged(true) // 同值:必须是 no-op + await vm.setStaged(false) + + #expect(vm.staged == false) + #expect(await recorder.stagedArgs == [false, true, false]) + } + + // MARK: - 用户可见文案(中文具名常量;空态 ≠ 错误态) + + @Test("文案常量非空且空态/错误态互不相同") + func copyConstantsAreDistinct() { + #expect(!DiffCopy.title.isEmpty) + #expect(!DiffCopy.truncatedBanner.isEmpty) + #expect(!DiffCopy.emptyTitle.isEmpty) + #expect(!DiffCopy.binaryFile.isEmpty) + #expect(!DiffCopy.retry.isEmpty) + #expect(DiffCopy.emptyTitle != DiffCopy.failedUnavailable) + #expect(DiffCopy.failedPathInvalid != DiffCopy.failedNotFound) + } + + @Test("状态标签全函数映射(含全部六种 FileStatus,中文)") + func statusLabelsAreTotalAndChinese() { + let all: [DiffFileStatus] = [ + .modified, .added, .deleted, .renamed, .binary, .untracked, + ] + for status in all { + #expect(!DiffStatusStyle.label(for: status).isEmpty) + } + #expect(DiffStatusStyle.label(for: .added) == "新增") + #expect(DiffStatusStyle.label(for: .deleted) == "删除") + } +} diff --git a/ios/App/WebTermTests/NewSessionInCwdTests.swift b/ios/App/WebTermTests/NewSessionInCwdTests.swift new file mode 100644 index 0000000..3f185a3 --- /dev/null +++ b/ios/App/WebTermTests/NewSessionInCwdTests.swift @@ -0,0 +1,290 @@ +import Foundation +import HostRegistry +import SessionCore +import TestSupport +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-29 · 杂项闭环:new-in-cwd + 退出会话清理。 +/// +/// 1. "在当前目录开新会话"(TerminalScreen 工具栏 / exit 横幅共用动作)→ +/// `AppCoordinator.openNewSessionInCurrentCwd()`:单活 WS 不变式下的 +/// close→open,新会话首帧 `attach(null, cwd)`(镜像 web tabs.ts +/// `newTab()` M6——取活跃会话 cwd)。cwd 解析次序:/live-sessions 行数据 +/// (server-adopted id 匹配行)→ controller 的 spawnCwd(T-iOS-26 项目 +/// fresh-spawn 尚未进列表轮询)→ 未知(普通新会话)。服务器数据不可信: +/// 非绝对路径按未知处理(ProjectsViewModel 同款纪律)。 +/// 2. exited 会话点开 → 回放渲染 + exit 横幅(TerminalViewModel P0 已有)+ +/// 横幅"开新会话"动作(src/session/manager.ts:145-153:exited 会话在 +/// reap 前保持可列出,attach 即回放 ring buffer + 补发 exit 帧)。 +/// +/// 真 `SessionEngine`/`AppCoordinator` over FakeTransport/FakeHTTPTransport, +/// 零真实等待(openTask + waitUntilProcessed 双屏障,ProjectOpenWiringTests +/// 同款)。 +@MainActor +@Suite("NewSessionInCwd (T-iOS-29)") +struct NewSessionInCwdTests { + private nonisolated static let base = "http://192.168.1.5:3000" + + // MARK: - Fixtures(SessionSwitcherTests 的 WiringFixture 同款) + + private struct Fixture { + let transport: FakeTransport + let http: FakeHTTPTransport + let host: HostRegistry.Host + let unreadStore: InMemoryUnreadWatermarkStore + let environment: AppEnvironment + } + + private func makeFixture(suiteName: String) throws -> Fixture { + let baseURL = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let transport = FakeTransport() + let http = FakeHTTPTransport() + let unreadStore = InMemoryUnreadWatermarkStore() + let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint) + let defaults = try #require(UserDefaults(suiteName: suiteName)) + return Fixture( + transport: transport, + http: http, + host: host, + unreadStore: unreadStore, + environment: AppEnvironment( + hostStore: InMemoryHostStore(hosts: [host]), + lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults), + http: http, + termTransport: transport, + probe: { _ in .failure(.timeout) }, + unreadStore: unreadStore + ) + ) + } + + private func listURL() throws -> URL { + try #require(URL(string: "\(Self.base)/live-sessions")) + } + + private func sessionJSON(id: UUID, cwd: String?, exited: Bool = false) -> String { + let cwdJSON = cwd.map { ",\"cwd\":\"\($0)\"" } ?? "" + return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":1700000000000" + + ",\"clientCount\":0,\"status\":\"idle\",\"exited\":\(exited)" + + "\(cwdJSON),\"cols\":80,\"rows\":24}" + } + + private func listBody(_ entries: [String]) -> Data { + Data("[\(entries.joined(separator: ","))]".utf8) + } + + /// 让列表 VM 先掌握行数据(cwd 的唯一服务器来源)。 + private func primeRows(_ coordinator: AppCoordinator, fixture: Fixture, rows: [String]) async throws { + await coordinator.sessionList.reloadHosts() + await fixture.http.queueSuccess(url: try listURL(), body: listBody(rows)) + await coordinator.sessionList.refresh() + } + + /// attach 握手完成(服务器 adopted):openTask 完成 ∧ 前 3 个事件 + /// (connecting/connected/adopted)已应用。 + private func adopt( + _ controller: TerminalSessionController, + transport: FakeTransport, + sessionId: UUID + ) async { + await controller.openTask?.value + await transport.emit( + frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"# + ) + await controller.terminalViewModel.waitUntilProcessed(eventCount: 3) + } + + /// 新 controller 的 open 已提交且连接事件已冲刷(帧必已落地)。 + private func awaitAttachSettled(_ controller: TerminalSessionController) async { + await controller.openTask?.value + await controller.terminalViewModel.waitUntilProcessed(eventCount: 2) + } + + // MARK: - 1. 在当前会话 cwd 开新会话 + + @Test("cwd 来自 /live-sessions 行 → 切换后新连接首帧 attach(null, cwd);旧会话记 last-seen") + func newInCwdUsesListRowCwd() async throws { + // Arrange:列表已知该会话 cwd,打开并 adopted。 + let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.rowCwd") + let coordinator = AppCoordinator(environment: fixture.environment) + let sessionId = UUID() + try await primeRows( + coordinator, fixture: fixture, + rows: [sessionJSON(id: sessionId, cwd: "/Users/dev/proj")] + ) + coordinator.open(SessionListViewModel.OpenRequest( + id: UUID(), host: fixture.host, sessionId: sessionId + )) + let first = try #require(coordinator.terminalController) + await adopt(first, transport: fixture.transport, sessionId: sessionId) + + // Act:工具栏/横幅动作。 + coordinator.openNewSessionInCurrentCwd() + + // Assert:close→open —— 新 controller、新连接,首帧带行 cwd 的 fresh spawn。 + let second = try #require(coordinator.terminalController) + #expect(second !== first) + await awaitAttachSettled(second) + let byConnection = await fixture.transport.sentFramesByConnection + #expect(byConnection.count == 2) + #expect(byConnection[1] == [ + MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj")) + ]) + // 旧会话经 closeTerminal 记 last-seen(切走即已读)。 + #expect(fixture.unreadStore.snapshot[sessionId] != nil) + second.teardown() + } + + @Test("行缺席(fresh spawn 未入轮询)→ 回退 controller.spawnCwd;bootstrap 绝不复注入") + func newInCwdFallsBackToSpawnCwdWithoutBootstrap() async throws { + // Arrange:项目内 fresh spawn(spawnCwd + claude bootstrap),列表无行。 + let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.spawnCwd") + let coordinator = AppCoordinator(environment: fixture.environment) + coordinator.openProject(ProjectOpenRequest( + id: UUID(), host: fixture.host, cwd: "/repos/api", + bootstrapInput: ProjectLaunch.claudeBootstrapInput + )) + let first = try #require(coordinator.terminalController) + await adopt(first, transport: fixture.transport, sessionId: UUID()) + + // Act + coordinator.openNewSessionInCurrentCwd() + + // Assert:同 cwd,但只有裸 attach —— claude\r 不属于"开新 shell"。 + let second = try #require(coordinator.terminalController) + await awaitAttachSettled(second) + let byConnection = await fixture.transport.sentFramesByConnection + #expect(byConnection.count == 2) + #expect(byConnection[1] == [ + MessageCodec.encode(.attach(sessionId: nil, cwd: "/repos/api")) + ]) + second.teardown() + } + + @Test("cwd 全未知(无行、无 spawnCwd)→ 普通新会话 attach(null, nil)") + func newInCwdWithUnknownCwdOpensPlainSession() async throws { + // Arrange:直接"+ 新会话"打开(无列表行),adopted id 不在任何行里。 + let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.unknown") + let coordinator = AppCoordinator(environment: fixture.environment) + coordinator.open(SessionListViewModel.OpenRequest( + id: UUID(), host: fixture.host, sessionId: nil + )) + let first = try #require(coordinator.terminalController) + await adopt(first, transport: fixture.transport, sessionId: UUID()) + + // Act + coordinator.openNewSessionInCurrentCwd() + + // Assert + let second = try #require(coordinator.terminalController) + await awaitAttachSettled(second) + let byConnection = await fixture.transport.sentFramesByConnection + #expect(byConnection.count == 2) + #expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))]) + second.teardown() + } + + @Test("服务器行 cwd 非绝对路径(不可信输入)→ 按未知处理,attach(null, nil)") + func hostileRelativeCwdIsTreatedAsUnknown() async throws { + // Arrange:敌意/损坏的服务器数据 —— cwd 不是绝对路径。 + let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.hostile") + let coordinator = AppCoordinator(environment: fixture.environment) + let sessionId = UUID() + try await primeRows( + coordinator, fixture: fixture, + rows: [sessionJSON(id: sessionId, cwd: "repos/../etc")] + ) + coordinator.open(SessionListViewModel.OpenRequest( + id: UUID(), host: fixture.host, sessionId: sessionId + )) + let first = try #require(coordinator.terminalController) + await adopt(first, transport: fixture.transport, sessionId: sessionId) + + // Act + coordinator.openNewSessionInCurrentCwd() + + // Assert:非法 cwd 绝不透传。 + let second = try #require(coordinator.terminalController) + await awaitAttachSettled(second) + let byConnection = await fixture.transport.sentFramesByConnection + #expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))]) + second.teardown() + } + + @Test("无打开的终端 → 动作 no-op(不 spawn、不连接)") + func actionWithoutOpenTerminalIsNoOp() async throws { + // Arrange + let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.noop") + let coordinator = AppCoordinator(environment: fixture.environment) + + // Act + coordinator.openNewSessionInCurrentCwd() + + // Assert + #expect(coordinator.terminalController == nil) + let attempts = await fixture.transport.connectAttempts + #expect(attempts.isEmpty) + } + + // MARK: - 2. exited 会话:回放 + exit 横幅 + 横幅"开新会话" + + @Test("exited 会话点开:回放渲染 + exit 横幅只读 → 动作以该行 cwd 开新会话") + func exitedSessionReplaysThenBannerActionReusesCwd() async throws { + // Arrange:列表里的 exited 行(manager.ts:145-153——reap 前仍可列出)。 + let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.exited") + let coordinator = AppCoordinator(environment: fixture.environment) + let sessionId = UUID() + try await primeRows( + coordinator, fixture: fixture, + rows: [sessionJSON(id: sessionId, cwd: "/Users/dev/proj", exited: true)] + ) + coordinator.open(SessionListViewModel.OpenRequest( + id: UUID(), host: fixture.host, sessionId: sessionId + )) + let first = try #require(coordinator.terminalController) + var fed: [String] = [] + first.terminalViewModel.attachTerminalSink { fed.append($0) } + await first.openTask?.value + + // Act:服务器语义 —— 回放 ring buffer,随后补发 exit。 + await fixture.transport.emit( + frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"# + ) + await fixture.transport.emit(frame: #"{"type":"output","data":"[replay] $ make done"}"#) + await fixture.transport.emit(frame: #"{"type":"exit","code":0}"#) + await first.terminalViewModel.waitUntilProcessed(eventCount: 5) + + // Assert:回放已渲染、exit 横幅只读。 + #expect(fed == ["[replay] $ make done"]) + #expect(first.terminalViewModel.bannerModel == .exited(code: 0, reason: nil)) + #expect(first.terminalViewModel.isReadOnly) + + // Act:横幅"开新会话"(同一 coordinator 动作)。 + coordinator.openNewSessionInCurrentCwd() + + // Assert:同 cwd 的 fresh spawn。 + let second = try #require(coordinator.terminalController) + await awaitAttachSettled(second) + let byConnection = await fixture.transport.sentFramesByConnection + #expect(byConnection[1] == [ + MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj")) + ]) + second.teardown() + } + + @Test("横幅'开新会话'只在 exited 态可用(failed/连接态不提供)") + func bannerNewSessionAffordanceOnlyWhenExited() { + #expect(ReconnectBanner.isNewSessionActionAvailable(for: .exited(code: 0, reason: nil))) + #expect(ReconnectBanner.isNewSessionActionAvailable( + for: .exited(code: -1, reason: "spawn failed") + )) + #expect(!ReconnectBanner.isNewSessionActionAvailable(for: .connecting)) + #expect(!ReconnectBanner.isNewSessionActionAvailable( + for: .reconnecting(attempt: 1, retryIn: .seconds(1)) + )) + #expect(!ReconnectBanner.isNewSessionActionAvailable(for: .failed(message: "x"))) + } +} diff --git a/ios/App/WebTermTests/NotificationActionHandlerTests.swift b/ios/App/WebTermTests/NotificationActionHandlerTests.swift new file mode 100644 index 0000000..8cd24cf --- /dev/null +++ b/ios/App/WebTermTests/NotificationActionHandlerTests.swift @@ -0,0 +1,329 @@ +import APIClient +import Foundation +import HostRegistry +import Testing +import TestSupport +import UserNotifications +import WireProtocol +@testable import WebTerm + +/// T-iOS-21 · NotificationActionHandler.handle:Allow/Deny → POST +/// /hook/decision(背景任务包住、403 → 本地通知兜底)、默认点按 → +/// DeepLinkRouter 同源路由到会话(parse 的 payload 级用例在 +/// NotificationActionParseTests.swift)。 +/// 说明:UNNotificationResponse 无法在单测构造 —— didReceive 薄胶水不在 +/// 单测覆盖内,全部逻辑经 parse(actionIdentifier:userInfo:) + handle(_:) 测。 +@MainActor +@Suite("NotificationActionHandler") +struct NotificationActionHandlerTests { + // MARK: - Fakes + + @MainActor + private final class FakeBackgroundTasks: BackgroundTaskRunning { + private(set) var begunNames: [String] = [] + private(set) var endedTokens: [Int] = [] + private var nextToken = 0 + + func begin(name: String) -> Int { + begunNames.append(name) + nextToken += 1 + return nextToken + } + + func end(_ token: Int) { endedTokens.append(token) } + } + + @MainActor + private final class FakeNoticePoster: LocalNoticePosting { + private(set) var posted: [(title: String, body: String)] = [] + func post(title: String, body: String) async { posted.append((title, body)) } + } + + @MainActor + private final class OpenRecorder { + private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = [] + var actions: NotificationActionHandler.Actions { + NotificationActionHandler.Actions(openSession: { [weak self] host, sessionId in + self?.opened.append((host, sessionId)) + }) + } + } + + // MARK: - Fixtures + + private static let sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a" + /// 服务器 capability token = crypto.randomUUID()(src/server.ts:445)→ v4 形状。 + private static let token = "7c1de1e0-9a2b-4c3d-8e4f-5a6b7c8d9e0f" + private static let hostABase = "http://192.168.1.5:3000" + private static let hostBBase = "http://192.168.1.6:3000" + + private static func makeHost(base: String, name: String = "mac") throws -> HostRegistry.Host { + let url = try #require(URL(string: base)) + let endpoint = try #require(HostEndpoint(baseURL: url)) + return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint) + } + + private struct Harness { + let handler: NotificationActionHandler + let http: FakeHTTPTransport + let backgroundTasks: FakeBackgroundTasks + let notices: FakeNoticePoster + let recorder: OpenRecorder + } + + private static func makeHarness(hosts: [HostRegistry.Host]) -> Harness { + let http = FakeHTTPTransport() + let backgroundTasks = FakeBackgroundTasks() + let notices = FakeNoticePoster() + let recorder = OpenRecorder() + let handler = NotificationActionHandler( + hostStore: InMemoryHostStore(hosts: hosts), + http: http, + backgroundTasks: backgroundTasks, + notices: notices, + actions: recorder.actions + ) + return Harness( + handler: handler, http: http, backgroundTasks: backgroundTasks, + notices: notices, recorder: recorder + ) + } + + private static func decisionURL(base: String) throws -> URL { + try #require(URL(string: "\(base)/hook/decision")) + } + + private static func liveSessionsURL(base: String) throws -> URL { + try #require(URL(string: "\(base)/live-sessions")) + } + + /// 多主机场景的两条 RO 探查响应一次排好(A 无、B 持有目标会话)。 + private static func queueTwoHostProbe(_ harness: Harness) async throws { + await harness.http.queueSuccess( + method: "GET", url: try liveSessionsURL(base: hostABase), + body: liveSessionsBody(containing: nil) + ) + await harness.http.queueSuccess( + method: "GET", url: try liveSessionsURL(base: hostBBase), + body: liveSessionsBody(containing: sessionId) + ) + } + + /// /live-sessions 响应体:可选包含目标会话(必填字段齐全的最小形状)。 + private static func liveSessionsBody(containing sessionId: String?) -> Data { + guard let sessionId else { return Data("[]".utf8) } + let json = """ + [{"id":"\(sessionId)","createdAt":1,"clientCount":0,"status":"waiting", + "exited":false,"cols":80,"rows":24}] + """ + return Data(json.utf8) + } + + // MARK: - handle(.decision):POST /hook/decision + + @Test("单主机 Allow → 直接 POST(免 RO 探查),体为 {sessionId,decision,token},带 Origin,背景任务成对") + func decisionSingleHostPosts() async throws { + let host = try Self.makeHost(base: Self.hostABase) + let harness = Self.makeHarness(hosts: [host]) + await harness.http.queueSuccess( + method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204 + ) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + + await harness.handler.handle( + .decision(.allow, sessionId: sessionId, token: Self.token) + ) + + let requests = await harness.http.recordedRequests + #expect(requests.count == 1) + let request = try #require(requests.first) + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Origin") == Self.hostABase) + let body = try JSONDecoder().decode( + [String: String].self, from: try #require(request.httpBody) + ) + #expect(body == [ + "sessionId": Self.sessionId, "decision": "allow", "token": Self.token, + ]) + #expect(harness.backgroundTasks.begunNames.count == 1) + #expect(harness.backgroundTasks.endedTokens.count == 1) + #expect(harness.notices.posted.isEmpty) + } + + @Test("Deny → decision 字段为 deny") + func decisionDenyPostsDeny() async throws { + let host = try Self.makeHost(base: Self.hostABase) + let harness = Self.makeHarness(hosts: [host]) + await harness.http.queueSuccess( + method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204 + ) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + + await harness.handler.handle( + .decision(.deny, sessionId: sessionId, token: Self.token) + ) + + let request = try #require(await harness.http.recordedRequests.first) + let body = try JSONDecoder().decode( + [String: String].self, from: try #require(request.httpBody) + ) + #expect(body["decision"] == "deny") + } + + @Test("403(token 过期/已用)→ 本地通知兜底提示进 App;文案不含 token;背景任务成对") + func decisionRejectedPostsExpiredNotice() async throws { + let host = try Self.makeHost(base: Self.hostABase) + let harness = Self.makeHarness(hosts: [host]) + await harness.http.queueSuccess( + method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 403 + ) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + + await harness.handler.handle( + .decision(.allow, sessionId: sessionId, token: Self.token) + ) + + #expect(harness.notices.posted.count == 1) + let notice = try #require(harness.notices.posted.first) + #expect(notice.title == PushDecisionCopy.expiredTitle) + #expect(notice.body == PushDecisionCopy.expiredBody) + // capability token 用后即弃:绝不出现在任何兜底文案里。 + #expect(!notice.body.contains(Self.token) && !notice.title.contains(Self.token)) + #expect(harness.backgroundTasks.endedTokens.count == 1) + } + + @Test("传输层失败 → 本地通知兜底(失败可见,绝不静默吞);背景任务成对") + func decisionTransportFailurePostsFailedNotice() async throws { + let host = try Self.makeHost(base: Self.hostABase) + let harness = Self.makeHarness(hosts: [host]) + await harness.http.queueFailure( + method: "POST", url: try Self.decisionURL(base: Self.hostABase), + error: URLError(.cannotConnectToHost) + ) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + + await harness.handler.handle( + .decision(.allow, sessionId: sessionId, token: Self.token) + ) + + #expect(harness.notices.posted.count == 1) + #expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle) + #expect(harness.backgroundTasks.begunNames.count == 1) + #expect(harness.backgroundTasks.endedTokens.count == 1) + } + + @Test("多主机 → 先 RO 查 /live-sessions(无 Origin)定位持有会话的主机,再对其 POST") + func decisionMultiHostResolvesOwner() async throws { + let hostA = try Self.makeHost(base: Self.hostABase) + let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini") + let harness = Self.makeHarness(hosts: [hostA, hostB]) + try await Self.queueTwoHostProbe(harness) + await harness.http.queueSuccess( + method: "POST", url: try Self.decisionURL(base: Self.hostBBase), status: 204 + ) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + + await harness.handler.handle( + .decision(.allow, sessionId: sessionId, token: Self.token) + ) + + let requests = await harness.http.recordedRequests + let gets = requests.filter { $0.httpMethod == "GET" } + #expect(gets.count == 2) + for get in gets { // RO 铁律:探查一律不带 Origin + #expect(get.value(forHTTPHeaderField: "Origin") == nil) + } + let posts = requests.filter { $0.httpMethod == "POST" } + #expect(posts.count == 1) + #expect(posts.first?.url == (try Self.decisionURL(base: Self.hostBBase))) + #expect(harness.notices.posted.isEmpty) + } + + @Test("多主机但会话无处可寻 → 不 POST,本地通知兜底") + func decisionUnresolvableHostFailsVisibly() async throws { + let hostA = try Self.makeHost(base: Self.hostABase) + let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini") + let harness = Self.makeHarness(hosts: [hostA, hostB]) + await harness.http.queueSuccess( + method: "GET", url: try Self.liveSessionsURL(base: Self.hostABase), + body: Self.liveSessionsBody(containing: nil) + ) + await harness.http.queueSuccess( + method: "GET", url: try Self.liveSessionsURL(base: Self.hostBBase), + body: Self.liveSessionsBody(containing: nil) + ) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + + await harness.handler.handle( + .decision(.allow, sessionId: sessionId, token: Self.token) + ) + + let posts = await harness.http.recordedRequests.filter { $0.httpMethod == "POST" } + #expect(posts.isEmpty) + #expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle) + } + + // MARK: - handle(.openSession):默认点按路由 + + @Test("单主机点按 → openSession(host, id),不发决策 POST,也不开背景任务") + func openSessionSingleHostRoutes() async throws { + let host = try Self.makeHost(base: Self.hostABase) + let harness = Self.makeHarness(hosts: [host]) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + + await harness.handler.handle(.openSession(sessionId: sessionId)) + + #expect(harness.recorder.opened.count == 1) + #expect(harness.recorder.opened.first?.host == host) + #expect(harness.recorder.opened.first?.sessionId == sessionId) + #expect(await harness.http.recordedRequests.isEmpty) + #expect(harness.backgroundTasks.begunNames.isEmpty) + } + + @Test("多主机点按 → RO 定位后打开持有主机") + func openSessionMultiHostResolves() async throws { + let hostA = try Self.makeHost(base: Self.hostABase) + let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini") + let harness = Self.makeHarness(hosts: [hostA, hostB]) + try await Self.queueTwoHostProbe(harness) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + + await harness.handler.handle(.openSession(sessionId: sessionId)) + + #expect(harness.recorder.opened.first?.host == hostB) + } + + @Test("无主机可配 → 不打开、不 crash(App 已被点按拉起,落在列表页即可)") + func openSessionUnresolvedIsSafeNoOp() async throws { + let harness = Self.makeHarness(hosts: []) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + + await harness.handler.handle(.openSession(sessionId: sessionId)) + + #expect(harness.recorder.opened.isEmpty) + } + + // MARK: - 其余分支 + + @Test(".invalidPayload → 计数 +1,无网络、无通知、无路由") + func invalidPayloadCountedOnly() async throws { + let harness = Self.makeHarness(hosts: []) + + await harness.handler.handle(.invalidPayload) + + #expect(harness.handler.invalidPayloadCount == 1) + #expect(await harness.http.recordedRequests.isEmpty) + #expect(harness.notices.posted.isEmpty) + #expect(harness.recorder.opened.isEmpty) + } + + @Test(".dismissed → 完全 no-op") + func dismissedIsNoOp() async throws { + let harness = Self.makeHarness(hosts: []) + + await harness.handler.handle(.dismissed) + + #expect(harness.handler.invalidPayloadCount == 0) + #expect(await harness.http.recordedRequests.isEmpty) + } +} diff --git a/ios/App/WebTermTests/NotificationActionParseTests.swift b/ios/App/WebTermTests/NotificationActionParseTests.swift new file mode 100644 index 0000000..9b8780f --- /dev/null +++ b/ios/App/WebTermTests/NotificationActionParseTests.swift @@ -0,0 +1,104 @@ +import APIClient +import Foundation +import Testing +import UserNotifications +import WireProtocol +@testable import WebTerm + +/// T-iOS-21 · `NotificationActionHandler.parse`:payload 级白名单解析 +///(push payload 是不可信外部输入——sessionId 复用 DeepLinkRouter 的冻结 +/// v4 校验,token 校验 v4 形状后原样透传,任一非法 → .invalidPayload)。 +@MainActor +@Suite("NotificationActionHandler.parse") +struct NotificationActionParseTests { + private static let sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a" + /// 服务器 capability token = crypto.randomUUID()(src/server.ts:445)→ v4 形状。 + private static let token = "7c1de1e0-9a2b-4c3d-8e4f-5a6b7c8d9e0f" + + private static func gateUserInfo( + sessionId: String = sessionId, token: Any? = token + ) -> [AnyHashable: Any] { + var info: [AnyHashable: Any] = [ + "aps": ["category": "WEBTERM_GATE"], + "sessionId": sessionId, + "cls": "needs-input", + ] + if let token { info["token"] = token } + return info + } + + @Test("Allow 动作 + 合法 payload → .decision(.allow)") + func parseAllowAction() throws { + let parsed = NotificationActionHandler.parse( + actionIdentifier: GateNotificationCategory.allowActionId, + userInfo: Self.gateUserInfo() + ) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + #expect(parsed == .decision(.allow, sessionId: sessionId, token: Self.token)) + } + + @Test("Deny 动作 + 合法 payload → .decision(.deny)") + func parseDenyAction() throws { + let parsed = NotificationActionHandler.parse( + actionIdentifier: GateNotificationCategory.denyActionId, + userInfo: Self.gateUserInfo() + ) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + #expect(parsed == .decision(.deny, sessionId: sessionId, token: Self.token)) + } + + @Test("决策动作缺 token / token 非字符串 / token 非 v4 形状 → .invalidPayload") + func parseDecisionRejectsBadToken() { + for bad in [nil, 42 as Any, "not-a-token", "11111111-2222-1333-8444-555555555555"] { + let parsed = NotificationActionHandler.parse( + actionIdentifier: GateNotificationCategory.allowActionId, + userInfo: Self.gateUserInfo(token: bad) + ) + #expect(parsed == .invalidPayload) + } + } + + @Test("决策动作 sessionId 非法(非 v4 / 缺失)→ .invalidPayload") + func parseDecisionRejectsBadSessionId() { + let badId = NotificationActionHandler.parse( + actionIdentifier: GateNotificationCategory.allowActionId, + userInfo: Self.gateUserInfo(sessionId: "garbage") + ) + #expect(badId == .invalidPayload) + let missing = NotificationActionHandler.parse( + actionIdentifier: GateNotificationCategory.denyActionId, + userInfo: ["token": Self.token] + ) + #expect(missing == .invalidPayload) + } + + @Test("默认点按 + 合法 sessionId → .openSession(复用 DeepLinkRouter 同一解析)") + func parseDefaultTapRoutes() throws { + let parsed = NotificationActionHandler.parse( + actionIdentifier: UNNotificationDefaultActionIdentifier, + userInfo: Self.gateUserInfo(token: nil) // done 类通知无 token,点按仍可路由 + ) + let sessionId = try #require(UUID(uuidString: Self.sessionId)) + #expect(parsed == .openSession(sessionId: sessionId)) + } + + @Test("默认点按 + 非法 payload → .invalidPayload") + func parseDefaultTapRejectsGarbage() { + let parsed = NotificationActionHandler.parse( + actionIdentifier: UNNotificationDefaultActionIdentifier, + userInfo: ["sessionId": "'; DROP TABLE sessions;--"] + ) + #expect(parsed == .invalidPayload) + } + + @Test("dismiss / 未知动作 id → .dismissed(no-op)") + func parseDismissAndUnknownActions() { + #expect(NotificationActionHandler.parse( + actionIdentifier: UNNotificationDismissActionIdentifier, + userInfo: Self.gateUserInfo() + ) == .dismissed) + #expect(NotificationActionHandler.parse( + actionIdentifier: "EVIL_ACTION", userInfo: Self.gateUserInfo() + ) == .dismissed) + } +} diff --git a/ios/App/WebTermTests/ProjectDetailViewModelTests.swift b/ios/App/WebTermTests/ProjectDetailViewModelTests.swift new file mode 100644 index 0000000..2e0cbf5 --- /dev/null +++ b/ios/App/WebTermTests/ProjectDetailViewModelTests.swift @@ -0,0 +1,118 @@ +import APIClient +import Foundation +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-26 · ProjectDetailViewModel(详情 phase 状态机 + 400/404/500 显式 +/// 错误路径,镜像 DiffViewModel 的四结局纪律)。 +/// +/// fetch 闭包注入(生产由 `forHost` 包 `APIClient.projectDetail(path:)`, +/// 其 builder/解码/状态码映射已在 APIClient 包内测过 —— 此处只测 VM 归约)。 +@MainActor +@Suite("ProjectDetailViewModel") +struct ProjectDetailViewModelTests { + private nonisolated static func detail( + sessions: [ProjectSessionRef] = [], + worktrees: [WorktreeInfo] = [], + hasClaudeMd: Bool = false + ) -> ProjectDetail { + ProjectDetail( + name: "web-terminal", path: "/repos/web-terminal", isGit: true, + branch: "main", dirty: true, worktrees: worktrees, + sessions: sessions, hasClaudeMd: hasClaudeMd, claudeMd: nil + ) + } + + @Test("初始 .loading;load 成功 → .loaded(sessions/worktrees/hasClaudeMd 透传)") + func loadSuccess() async throws { + let payload = Self.detail( + sessions: [ProjectSessionRef( + id: UUID(), title: "web-terminal", status: .working, + clientCount: 2, createdAt: 1, exited: false + )], + worktrees: [WorktreeInfo( + path: "/repos/wt", branch: "feat/x", head: "abc", + isMain: false, isCurrent: true, locked: nil, prunable: nil + )], + hasClaudeMd: true + ) + let vm = ProjectDetailViewModel(path: payload.path, fetch: { payload }) + #expect(vm.phase == .loading) + + await vm.load() + + #expect(vm.phase == .loaded(payload)) + } + + @Test( + "错误映射:400→pathInvalid、404→notFound、500/解码/传输→unavailable", + arguments: [ + (APIClientError.projectPathInvalid, ProjectDetailViewModel.Failure.pathInvalid), + (APIClientError.projectNotFound, .notFound), + (APIClientError.projectDetailUnavailable, .unavailable), + (APIClientError.invalidResponseBody, .unavailable), + ] + ) + func errorMapping( + error: APIClientError, expected: ProjectDetailViewModel.Failure + ) async { + let vm = ProjectDetailViewModel(path: "/p", fetch: { throw error }) + + await vm.load() + + #expect(vm.phase == .failed(expected)) + } + + @Test("传输层任意错误 → .unavailable(可重试兜底,绝不 crash)") + func transportErrorFallsBackToUnavailable() async { + let vm = ProjectDetailViewModel( + path: "/p", fetch: { throw URLError(.notConnectedToInternet) } + ) + + await vm.load() + + #expect(vm.phase == .failed(.unavailable)) + } + + @Test("重试路径:failed 后再次 load 成功 → loaded") + func retryAfterFailure() async { + let flag = FailOnceFlag() + let payload = Self.detail() + let vm = ProjectDetailViewModel(path: payload.path, fetch: { + if await flag.consumeShouldFail() { throw APIClientError.projectDetailUnavailable } + return payload + }) + + await vm.load() + #expect(vm.phase == .failed(.unavailable)) + + await vm.load() + #expect(vm.phase == .loaded(payload)) + } + + @Test("failureCopy:三种失败各有非空、互不相同的中文文案") + func failureCopyIsDistinct() { + let copies = [ + ProjectDetailScreen.failureCopy(.pathInvalid), + ProjectDetailScreen.failureCopy(.notFound), + ProjectDetailScreen.failureCopy(.unavailable), + ] + + for copy in copies { + #expect(!copy.title.isEmpty) + #expect(!copy.detail.isEmpty) + } + #expect(Set(copies.map(\.title)).count == copies.count) + } +} + +/// @Sendable fetch 闭包里的可变一次性失败开关(actor 隔离)。 +private actor FailOnceFlag { + private var shouldFail = true + + func consumeShouldFail() -> Bool { + defer { shouldFail = false } + return shouldFail + } +} diff --git a/ios/App/WebTermTests/ProjectGroupingTests.swift b/ios/App/WebTermTests/ProjectGroupingTests.swift new file mode 100644 index 0000000..b16ed83 --- /dev/null +++ b/ios/App/WebTermTests/ProjectGroupingTests.swift @@ -0,0 +1,211 @@ +import APIClient +import Foundation +import Testing +@testable import WebTerm + +/// T-iOS-26 · Projects 列表的纯分组逻辑 —— 逐条镜像 web v0.6 的 +/// public/projects.ts 规则(filterProjects / sortProjects / groupProjects / +/// displayLabel): +/// - namespace = 名字的前两个点分段(不足两段 → 无 namespace); +/// - 成员 < MIN_GROUP_SIZE(2) 的 namespace 塌进 Other; +/// - 有运行中会话的项目**复制**进置顶的 "Active now" 组(sessions[].exited +/// 字段实测存在 —— active 置顶按任务要求 assert reality 后实现); +/// - 一个 namespace 组都没有 → 单一 flat 组(无 chrome 的平铺网格回退); +/// - 组 key 与 web 逐字节一致(" active" / " other" / namespace 原始大小写), +/// 因为 collapsed 状态经 /prefs 跨端共享,key 不一致就互相丢状态。 +struct ProjectGroupingTests { + // MARK: - Fixtures + + private static func session(exited: Bool) -> ProjectSessionRef { + ProjectSessionRef( + id: UUID(), title: nil, status: .working, + clientCount: 1, createdAt: 0, exited: exited + ) + } + + private static func project( + _ name: String, + path: String? = nil, + lastActiveMs: Int? = nil, + running: Bool = false, + exitedSession: Bool = false + ) -> ProjectInfo { + var sessions: [ProjectSessionRef] = [] + if running { sessions.append(session(exited: false)) } + if exitedSession { sessions.append(session(exited: true)) } + return ProjectInfo( + name: name, path: path ?? "/repos/\(name)", isGit: true, + branch: "main", dirty: nil, lastActiveMs: lastActiveMs, + sessions: sessions + ) + } + + // MARK: - filter(大小写不敏感的 name/path 子串,镜像 filterProjects) + + @Test("filter:空查询原样返回全部(含前后空白仅剩空白的查询)") + func filterEmptyQueryReturnsAll() { + let projects = [Self.project("a"), Self.project("b")] + + #expect(ProjectGrouping.filter(projects, query: "") == projects) + #expect(ProjectGrouping.filter(projects, query: " ") == projects) + } + + @Test("filter:name 与 path 子串均命中,大小写不敏感") + func filterMatchesNameAndPathCaseInsensitively() { + let byName = Self.project("Billo.Platform.api", path: "/x/one") + let byPath = Self.project("zzz", path: "/Users/dev/BILLO-extras") + let miss = Self.project("other", path: "/y/two") + + let result = ProjectGrouping.filter([byName, byPath, miss], query: "billo") + + #expect(result == [byName, byPath]) + } + + // MARK: - sort(favourites 优先 → lastActiveMs 降序 → 输入序稳定) + + @Test("sort:收藏优先,其余按 lastActiveMs 降序,缺失视为 0") + func sortFavouritesFirstThenRecency() { + let fav = Self.project("fav", lastActiveMs: 1) + let newer = Self.project("newer", lastActiveMs: 100) + let older = Self.project("older", lastActiveMs: 50) + let never = Self.project("never", lastActiveMs: nil) + + let result = ProjectGrouping.sort( + [never, older, fav, newer], favourites: [fav.path] + ) + + #expect(result == [fav, newer, older, never]) + } + + @Test("sort:全键相等时保持输入顺序(稳定排序,镜像 JS stable sort)") + func sortIsStableOnTies() { + let a = Self.project("a", lastActiveMs: 5) + let b = Self.project("b", lastActiveMs: 5) + let c = Self.project("c", lastActiveMs: 5) + + #expect(ProjectGrouping.sort([a, b, c], favourites: []) == [a, b, c]) + } + + // MARK: - namespace 分组 + + @Test("group:≥2 成员的 namespace 成组,单成员与无点名塌进 Other") + func groupNamespacesAndOther() { + let apiProj = Self.project("Billo.Platform.api", lastActiveMs: 2) + let webProj = Self.project("Billo.Platform.web", lastActiveMs: 1) + let solo = Self.project("solo.thing") + let plain = Self.project("plain") + + let groups = ProjectGrouping.group( + [apiProj, webProj, solo, plain], favourites: [] + ) + + #expect(groups.count == 2) + #expect(groups[0].kind == .namespace) + #expect(groups[0].key == "Billo.Platform") + #expect(groups[0].label == "Billo.Platform") + #expect(groups[0].projects == [apiProj, webProj]) + #expect(groups[1].kind == .other) + #expect(groups[1].key == ProjectGrouping.otherGroupKey) + // 镜像 web groupProjects:other 先收无点名(noNamespace),再追加 + // 塌掉的单成员 namespace(public/projects.ts:163-166)→ plain 在前。 + #expect(groups[1].projects == [plain, solo]) + } + + @Test("group:namespace 桶大小写不敏感合并,显示名取首见大小写") + func groupBucketsCaseInsensitively() { + let first = Self.project("Billo.Platform.a") + let second = Self.project("billo.platform.b") + + let groups = ProjectGrouping.group([first, second], favourites: []) + + #expect(groups.count == 1) + #expect(groups[0].key == "Billo.Platform") + #expect(groups[0].projects == [first, second]) + } + + @Test("group:没有任何 namespace 组 → 单一 flat 组(全部项目,无 chrome 回退)") + func groupFlatFallback() { + let solo = Self.project("solo.thing", lastActiveMs: 1) + let plain = Self.project("plain", lastActiveMs: 2) + + let groups = ProjectGrouping.group([solo, plain], favourites: []) + + #expect(groups.count == 1) + #expect(groups[0].kind == .flat) + #expect(groups[0].key == ProjectGrouping.otherGroupKey) + #expect(groups[0].projects == [plain, solo]) // recency 降序 + #expect(groups[0].isCollapsible == false) + } + + @Test("group:运行中的项目复制进置顶 Active 组;activeCount 逐组统计") + func groupActivePinning() { + let runningA = Self.project("Billo.Platform.api", lastActiveMs: 2, running: true) + let idleB = Self.project("Billo.Platform.web", lastActiveMs: 1) + let exitedOnly = Self.project("Billo.Platform.cli", exitedSession: true) + + let groups = ProjectGrouping.group( + [runningA, idleB, exitedOnly], favourites: [] + ) + + #expect(groups.count == 2) + #expect(groups[0].kind == .active) + #expect(groups[0].key == ProjectGrouping.activeGroupKey) + #expect(groups[0].projects == [runningA]) // exited 会话不算 running + #expect(groups[0].activeCount == 1) + #expect(groups[1].kind == .namespace) + #expect(groups[1].projects.contains(runningA)) // 复制而非移动 + #expect(groups[1].activeCount == 1) + #expect(groups[0].isCollapsible == false) // Active now 永远展开 + #expect(groups[1].isCollapsible == true) + } + + @Test("group:namespace 组按组内最新 lastActiveMs 降序,平局按 label 升序") + func groupOrdersNamespacesByRecencyThenLabel() { + let older1 = Self.project("Aaa.Team.x", lastActiveMs: 10) + let older2 = Self.project("Aaa.Team.y", lastActiveMs: 20) + let newer1 = Self.project("Zzz.Team.x", lastActiveMs: 5) + let newer2 = Self.project("Zzz.Team.y", lastActiveMs: 99) + let tieA1 = Self.project("Bbb.Tie.x", lastActiveMs: 20) + let tieA2 = Self.project("Bbb.Tie.y", lastActiveMs: 3) + + let groups = ProjectGrouping.group( + [older1, older2, newer1, newer2, tieA1, tieA2], favourites: [] + ) + + #expect(groups.map(\.key) == ["Zzz.Team", "Aaa.Team", "Bbb.Tie"]) + } + + // MARK: - displayLabel(组内卡片名去掉 namespace 前缀) + + @Test("displayLabel:namespace 组内剥前缀(大小写不敏感);哨兵组保留全名") + func displayLabelStripsNamespacePrefix() { + #expect(ProjectGrouping.displayLabel( + name: "Billo.Platform.api", groupKey: "Billo.Platform" + ) == "api") + #expect(ProjectGrouping.displayLabel( + name: "billo.platform.api", groupKey: "Billo.Platform" + ) == "api") + #expect(ProjectGrouping.displayLabel( + name: "unrelated", groupKey: "Billo.Platform" + ) == "unrelated") + #expect(ProjectGrouping.displayLabel( + name: "Billo.Platform.api", groupKey: ProjectGrouping.activeGroupKey + ) == "Billo.Platform.api") + #expect(ProjectGrouping.displayLabel( + name: "Billo.Platform.api", groupKey: ProjectGrouping.otherGroupKey + ) == "Billo.Platform.api") + } + + @Test("group:组内排序收藏优先(favourites 参与 sortProjects)") + func groupSortsFavouritesFirstWithinGroup() { + let apiProj = Self.project("Ns.Grp.api", lastActiveMs: 9) + let webProj = Self.project("Ns.Grp.web", lastActiveMs: 1) + + let groups = ProjectGrouping.group( + [apiProj, webProj], favourites: [webProj.path] + ) + + #expect(groups.count == 1) + #expect(groups[0].projects == [webProj, apiProj]) + } +} diff --git a/ios/App/WebTermTests/ProjectOpenWiringTests.swift b/ios/App/WebTermTests/ProjectOpenWiringTests.swift new file mode 100644 index 0000000..6425ef2 --- /dev/null +++ b/ios/App/WebTermTests/ProjectOpenWiringTests.swift @@ -0,0 +1,90 @@ +import APIClient +import Foundation +import HostRegistry +import SessionCore +import TestSupport +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-26 · "在此仓库开新会话" 的接线证明:`TerminalSessionController` 以 +/// `spawnCwd` + `bootstrapInput` 启动时,线上帧序 = `attach(null, cwd)` → +/// `input("claude\r")`(engine 的 attach-first 队列语义保证 bootstrap 绝不 +/// 先于 attach 出线);带 sessionId 的常规打开则既无 cwd 也无 bootstrap。 +/// +/// 真 `SessionEngine` over `FakeTransport`(零真实等待):帧断言经 +/// `openTask` + `waitUntilProcessed` 双屏障(open+send 已提交 ∧ attach +/// 握手已完成 → 帧必已落地)。 +@MainActor +@Suite("ProjectOpenWiring") +struct ProjectOpenWiringTests { + private struct Fixture { + let transport: FakeTransport + let host: HostRegistry.Host + let environment: AppEnvironment + } + + private func makeFixture() throws -> Fixture { + let baseURL = try #require(URL(string: "http://192.168.1.5:3000")) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let transport = FakeTransport() + let defaults = try #require(UserDefaults(suiteName: "ProjectOpenWiringTests")) + return Fixture( + transport: transport, + host: HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint), + environment: AppEnvironment( + hostStore: InMemoryHostStore(), + lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults), + http: FakeHTTPTransport(), + termTransport: transport, + probe: { _ in .failure(.timeout) } + ) + ) + } + + /// 双屏障:controller 的 open+bootstrap Task 完成(send 已提交/入队)∧ + /// attach 握手完成(.connecting/.connected 已到 VM → 队列已冲刷)。 + private func awaitAttachSettled(_ controller: TerminalSessionController) async { + await controller.openTask?.value + await controller.terminalViewModel.waitUntilProcessed(eventCount: 2) + } + + @Test("spawn 变体:帧序 = attach(null, cwd) → input(claude\\r)") + func spawnSendsAttachWithCwdThenBootstrapInput() async throws { + let fixture = try makeFixture() + let controller = TerminalSessionController( + host: fixture.host, sessionId: nil, environment: fixture.environment, + onPendingChanged: { _, _ in }, + spawnCwd: "/repos/api", + bootstrapInput: ProjectLaunch.claudeBootstrapInput + ) + + controller.start() + await awaitAttachSettled(controller) + + let frames = await fixture.transport.sentFrames + #expect(frames == [ + MessageCodec.encode(.attach(sessionId: nil, cwd: "/repos/api")), + MessageCodec.encode(.input(data: ProjectLaunch.claudeBootstrapInput)), + ]) + controller.teardown() + } + + @Test("常规打开(带 sessionId):只有 attach,无 cwd、无 bootstrap") + func plainOpenSendsBareAttach() async throws { + let fixture = try makeFixture() + let sessionId = UUID() + let controller = TerminalSessionController( + host: fixture.host, sessionId: sessionId, + environment: fixture.environment, + onPendingChanged: { _, _ in } + ) + + controller.start() + await awaitAttachSettled(controller) + + let frames = await fixture.transport.sentFrames + #expect(frames == [MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil))]) + controller.teardown() + } +} diff --git a/ios/App/WebTermTests/ProjectsViewModelTests.swift b/ios/App/WebTermTests/ProjectsViewModelTests.swift new file mode 100644 index 0000000..56675ea --- /dev/null +++ b/ios/App/WebTermTests/ProjectsViewModelTests.swift @@ -0,0 +1,289 @@ +import APIClient +import Foundation +import HostRegistry +import TestSupport +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-26 · ProjectsViewModel(列表 + favourites/collapse 的 /prefs 往返)。 +/// +/// 覆盖面(Steps 测试先行): +/// - load:`GET /projects` + `GET /prefs` → 分组行 + 收藏/折叠状态; +/// - **merge 关键测试**:PUT body 必须保留 web/未来服务器写入的未知顶层键 +/// (UiPrefs 的 unknown-key-preserving API —— PUT 整体替换服务器 blob, +/// 丢键=清库); +/// - prefs 加载失败 → 项目照常显示、明确文案,且**绝不 PUT**(空底盘上写会 +/// 清掉服务器收藏); +/// - PUT 失败 → 本地改动保留 + 同步失败文案; +/// - 搜索中强制展开所有分组(镜像 web renderGrid 的 searching 规则); +/// - "在此仓库开新会话" → ProjectOpenRequest{cwd + claude\r};相对路径拒绝。 +/// +/// 全部 HTTP 走真 `APIClient` over `FakeHTTPTransport`(零真实网络/等待)。 +@MainActor +@Suite("ProjectsViewModel") +struct ProjectsViewModelTests { + private nonisolated static let base = "http://192.168.1.5:3000" + + private nonisolated static let projectsJSON = """ + [{"name":"Billo.Platform.api","path":"/repos/api","isGit":true,"branch":"main",\ + "dirty":true,"lastActiveMs":200,"sessions":[]},\ + {"name":"Billo.Platform.web","path":"/repos/web","isGit":true,\ + "lastActiveMs":100,"sessions":[]}] + """ + + /// 未知顶层键 `webOnly` 是 merge 测试的探针。 + private nonisolated static let prefsJSON = """ + {"favourites":["/repos/web"],"collapsed":{"Billo.Platform":true},\ + "webOnly":{"theme":"dark"}} + """ + + // MARK: - Harness + + private struct Fixture { + let http: FakeHTTPTransport + let host: HostRegistry.Host + let viewModel: ProjectsViewModel + let projectsURL: URL + let prefsURL: URL + } + + private func makeFixture() throws -> Fixture { + let baseURL = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint) + let http = FakeHTTPTransport() + return Fixture( + http: http, + host: host, + viewModel: ProjectsViewModel(host: host, http: http), + projectsURL: try #require(URL(string: Self.base + "/projects")), + prefsURL: try #require(URL(string: Self.base + "/prefs")) + ) + } + + private func loadHappyPath(_ fixture: Fixture) async throws { + await fixture.http.queueSuccess( + url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8) + ) + await fixture.http.queueSuccess( + url: fixture.prefsURL, body: Data(Self.prefsJSON.utf8) + ) + await fixture.viewModel.load() + } + + private func recordedPutBodies(_ fixture: Fixture) async throws -> [[String: Any]] { + let requests = await fixture.http.recordedRequests + return try requests + .filter { $0.httpMethod == "PUT" } + .map { request in + let body = try #require(request.httpBody) + let json = try JSONSerialization.jsonObject(with: body) + return try #require(json as? [String: Any]) + } + } + + // MARK: - load + + @Test("load 成功:项目解码 + prefs 应用(收藏排序、折叠状态、分组)") + func loadAppliesProjectsAndPrefs() async throws { + let fixture = try makeFixture() + + try await loadHappyPath(fixture) + + #expect(fixture.viewModel.hasLoadedOnce) + #expect(fixture.viewModel.fetchErrorMessage == nil) + #expect(fixture.viewModel.prefsErrorMessage == nil) + #expect(fixture.viewModel.favourites == ["/repos/web"]) + #expect(fixture.viewModel.collapsedGroups == ["Billo.Platform": true]) + // 分组:两个同 namespace 项目成一组;收藏的 web 排在前(虽然更旧)。 + let groups = fixture.viewModel.groups + #expect(groups.count == 1) + #expect(groups[0].key == "Billo.Platform") + #expect(groups[0].projects.map(\.path) == ["/repos/web", "/repos/api"]) + #expect(fixture.viewModel.isCollapsed(groups[0])) + } + + @Test("load:/projects 传输失败 → 显式错误文案;重试成功后清除且列表可用") + func loadProjectsFailureIsExplicitAndRetryable() async throws { + let fixture = try makeFixture() + await fixture.http.queueFailure( + url: fixture.projectsURL, error: URLError(.notConnectedToInternet) + ) + await fixture.http.queueSuccess( + url: fixture.prefsURL, body: Data(Self.prefsJSON.utf8) + ) + + await fixture.viewModel.load() + + #expect(fixture.viewModel.fetchErrorMessage != nil) + #expect(fixture.viewModel.hasLoadedOnce == false) + + await fixture.http.queueSuccess( + url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8) + ) + await fixture.viewModel.refresh() + + #expect(fixture.viewModel.fetchErrorMessage == nil) + #expect(fixture.viewModel.hasLoadedOnce) + #expect(fixture.viewModel.groups.isEmpty == false) + } + + @Test("refresh 只重取 /projects,不再打 /prefs(prefs 留在内存,镜像 web init)") + func refreshDoesNotRefetchPrefs() async throws { + let fixture = try makeFixture() + try await loadHappyPath(fixture) + await fixture.http.queueSuccess( + url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8) + ) + + await fixture.viewModel.refresh() + + let prefsGets = await fixture.http.recordedRequests.filter { + $0.url == fixture.prefsURL && $0.httpMethod == "GET" + } + #expect(prefsGets.count == 1) + } + + // MARK: - prefs 失败路径(clobber 防线) + + @Test("prefs 加载失败:项目照常显示 + 明确文案;随后 toggle 只改本地、绝不 PUT") + func prefsLoadFailureNeverPuts() async throws { + let fixture = try makeFixture() + await fixture.http.queueSuccess( + url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8) + ) + await fixture.http.queueFailure( + url: fixture.prefsURL, error: URLError(.timedOut) + ) + await fixture.viewModel.load() + + #expect(fixture.viewModel.prefsErrorMessage == ProjectsCopy.prefsLoadFailed) + #expect(fixture.viewModel.groups.isEmpty == false) + + await fixture.viewModel.toggleFavourite(path: "/repos/api") + + #expect(fixture.viewModel.favourites == ["/repos/api"]) // 本地仍生效 + let puts = await fixture.http.recordedRequests.filter { $0.httpMethod == "PUT" } + #expect(puts.isEmpty) // 空底盘上 PUT = 清掉服务器 blob,绝不允许 + } + + // MARK: - favourites / collapse 往返(THE merge 测试) + + @Test("toggleFavourite:PUT body 保留未知键 webOnly + collapsed 原样 + Origin 铁律") + func toggleFavouritePreservesUnknownKeysInPutBody() async throws { + let fixture = try makeFixture() + try await loadHappyPath(fixture) + let echo = """ + {"favourites":["/repos/web","/repos/api"],"collapsed":{"Billo.Platform":true}} + """ + await fixture.http.queueSuccess( + method: "PUT", url: fixture.prefsURL, body: Data(echo.utf8) + ) + + await fixture.viewModel.toggleFavourite(path: "/repos/api") + + let bodies = try await recordedPutBodies(fixture) + #expect(bodies.count == 1) + let body = try #require(bodies.first) + #expect(body["favourites"] as? [String] == ["/repos/web", "/repos/api"]) + #expect(body["collapsed"] as? [String: Bool] == ["Billo.Platform": true]) + #expect(body["webOnly"] as? [String: String] == ["theme": "dark"]) // 未知键保留 + let put = try #require( + await fixture.http.recordedRequests.first { $0.httpMethod == "PUT" } + ) + #expect( + put.value(forHTTPHeaderField: "Origin") == fixture.host.endpoint.originHeader + ) + // 采纳服务器 echo 为新真相。 + #expect(fixture.viewModel.favourites == ["/repos/web", "/repos/api"]) + #expect(fixture.viewModel.prefsSyncErrorMessage == nil) + } + + @Test("toggleFavourite 取消收藏:从数组移除,其余顺序不变") + func toggleFavouriteRemoves() async throws { + let fixture = try makeFixture() + try await loadHappyPath(fixture) + await fixture.http.queueSuccess( + method: "PUT", url: fixture.prefsURL, + body: Data(#"{"favourites":[],"collapsed":{"Billo.Platform":true}}"#.utf8) + ) + + await fixture.viewModel.toggleFavourite(path: "/repos/web") + + let body = try #require(try await recordedPutBodies(fixture).first) + #expect(body["favourites"] as? [String] == []) + } + + @Test("toggleCollapsed:展开只删自己的 key;favourites 与未知键原样保留") + func toggleCollapsedRemovesOnlyItsKey() async throws { + let fixture = try makeFixture() + try await loadHappyPath(fixture) + await fixture.http.queueSuccess( + method: "PUT", url: fixture.prefsURL, + body: Data(#"{"favourites":["/repos/web"],"collapsed":{}}"#.utf8) + ) + + await fixture.viewModel.toggleCollapsed(key: "Billo.Platform") // 展开 + + let body = try #require(try await recordedPutBodies(fixture).first) + #expect(body["collapsed"] as? [String: Bool] == [:]) + #expect(body["favourites"] as? [String] == ["/repos/web"]) + #expect(body["webOnly"] as? [String: String] == ["theme": "dark"]) + #expect(fixture.viewModel.collapsedGroups == [:]) + } + + @Test("PUT 失败:本地改动保留 + 同步失败文案(下次 toggle 重试)") + func putFailureKeepsLocalChangeAndSurfacesError() async throws { + let fixture = try makeFixture() + try await loadHappyPath(fixture) + await fixture.http.queueFailure( + method: "PUT", url: fixture.prefsURL, error: URLError(.timedOut) + ) + + await fixture.viewModel.toggleFavourite(path: "/repos/api") + + #expect(fixture.viewModel.favourites == ["/repos/web", "/repos/api"]) + #expect(fixture.viewModel.prefsSyncErrorMessage != nil) + } + + // MARK: - 折叠与搜索 + + @Test("isCollapsed:搜索中强制展开(结果绝不藏在折叠 caret 后)") + func searchingForcesSectionsOpen() async throws { + let fixture = try makeFixture() + try await loadHappyPath(fixture) + let group = try #require(fixture.viewModel.groups.first) + #expect(fixture.viewModel.isCollapsed(group)) + + fixture.viewModel.searchText = "api" + + let searched = try #require(fixture.viewModel.groups.first) + #expect(fixture.viewModel.isCollapsed(searched) == false) + } + + // MARK: - 在此仓库开新会话 + + @Test("requestOpenClaude:绝对路径 → OpenRequest{host,cwd,claude\\r}") + func requestOpenClaudeMintsRequest() async throws { + let fixture = try makeFixture() + + fixture.viewModel.requestOpenClaude(cwd: "/repos/api") + + let request = try #require(fixture.viewModel.openRequest) + #expect(request.host == fixture.host) + #expect(request.cwd == "/repos/api") + #expect(request.bootstrapInput == ProjectLaunch.claudeBootstrapInput) + #expect(ProjectLaunch.claudeBootstrapInput == "claude\r") // Enter 是 \r 不是 \n + } + + @Test("requestOpenClaude:相对路径拒绝(服务器数据不可信)→ 无请求 + 文案") + func requestOpenClaudeRejectsRelativePath() async throws { + let fixture = try makeFixture() + + fixture.viewModel.requestOpenClaude(cwd: "repos/api") + + #expect(fixture.viewModel.openRequest == nil) + #expect(fixture.viewModel.openErrorMessage == ProjectsCopy.openClaudeInvalidPath) + } +} diff --git a/ios/App/WebTermTests/PushRegistrarTests.swift b/ios/App/WebTermTests/PushRegistrarTests.swift new file mode 100644 index 0000000..23a0c00 --- /dev/null +++ b/ios/App/WebTermTests/PushRegistrarTests.swift @@ -0,0 +1,366 @@ +import APIClient +import Foundation +import HostRegistry +import Testing +import TestSupport +import UserNotifications +import WireProtocol +@testable import WebTerm + +/// T-iOS-21 · WEBTERM_GATE category 注册形状(安全注:Allow 必带 +/// `.authenticationRequired`、两动作均不带 `.foreground`)。 +@MainActor +@Suite("GateNotificationCategory") +struct GateNotificationCategoryTests { + @Test("category id 与服务器 GATE_CATEGORY 一致(src/push/apns.ts:52)") + func categoryIdentifierMatchesServer() { + #expect(GateNotificationCategory.category().identifier == "WEBTERM_GATE") + } + + @Test("恰好两动作,顺序 Allow→Deny,标题为中文具名常量") + func actionsShapeAndTitles() { + let actions = GateNotificationCategory.category().actions + #expect(actions.count == 2) + #expect(actions.first?.identifier == GateNotificationCategory.allowActionId) + #expect(actions.last?.identifier == GateNotificationCategory.denyActionId) + #expect(actions.first?.title == GateNotificationCategory.allowTitle) + #expect(actions.last?.title == GateNotificationCategory.denyTitle) + } + + @Test("安全注:Allow 必须带 .authenticationRequired(锁屏批准 = 授权主机执行命令)") + func allowRequiresAuthentication() { + let allow = GateNotificationCategory.category().actions.first + #expect(allow?.options.contains(.authenticationRequired) == true) + } + + @Test("安全注:Deny 保持免认证(fail-safe——旁观者只能拒绝)") + func denyStaysUnauthenticated() { + let deny = GateNotificationCategory.category().actions.last + #expect(deny?.options.contains(.authenticationRequired) == false) + } + + @Test("安全注:两动作都不带 .foreground(锁屏两次手势闭环,不拉起 UI)") + func neitherActionForegrounds() { + let actions = GateNotificationCategory.category().actions + for action in actions { + #expect(!action.options.contains(.foreground)) + } + } +} + +/// T-iOS-21 · PushRegistrar:授权流程(真授权而非 provisional,因锁屏动作需要 +/// alert 级展示)、device token → 每个已配对主机逐一注册、失败续命、移除钩子。 +@MainActor +@Suite("PushRegistrar") +struct PushRegistrarTests { + // MARK: - Fakes(seam 替身;真 UNUserNotificationCenter 无法在单测实例化流程) + + @MainActor + private final class FakeNotificationCenter: NotificationCenterClient { + var status: UNAuthorizationStatus = .notDetermined + var grantResult: Result = .success(true) + private(set) var requestedOptions: [UNAuthorizationOptions] = [] + private(set) var registeredCategorySets: [Set] = [] + private(set) var addedRequests: [UNNotificationRequest] = [] + + func authorizationStatus() async -> UNAuthorizationStatus { status } + + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { + requestedOptions.append(options) + return try grantResult.get() + } + + func setNotificationCategories(_ categories: Set) { + registeredCategorySets.append(categories) + } + + func add(_ request: UNNotificationRequest) async throws { + addedRequests.append(request) + } + } + + @MainActor + private final class FakeRemoteRegistrar: RemoteNotificationRegistering { + private(set) var registerCallCount = 0 + func registerForRemoteNotifications() { registerCallCount += 1 } + } + + private enum StubError: Error { case authorizationFailed } + + // MARK: - Fixtures + + private static let hostABase = "http://192.168.1.5:3000" + private static let hostBBase = "http://192.168.1.6:3000" + /// 32 字节 device token → 64 位小写 hex(APNs 现行长度)。 + private static let tokenData = Data((0..<32).map { UInt8($0) }) + private static let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() + + private static func makeHost(base: String, name: String = "mac") throws -> HostRegistry.Host { + let url = try #require(URL(string: base)) + let endpoint = try #require(HostEndpoint(baseURL: url)) + return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint) + } + + private static func apnsTokenURL(base: String) throws -> URL { + try #require(URL(string: "\(base)/push/apns-token")) + } + + private static func makeRegistrar( + hosts: [HostRegistry.Host], + http: FakeHTTPTransport, + center: FakeNotificationCenter, + remote: FakeRemoteRegistrar + ) -> PushRegistrar { + PushRegistrar( + hostStore: InMemoryHostStore(hosts: hosts), + http: http, + center: center, + remote: remote + ) + } + + // MARK: - activate():授权流程 + + @Test("activate → 注册含 WEBTERM_GATE 的 category 集合") + func activateRegistersGateCategory() async throws { + let center = FakeNotificationCenter() + let registrar = Self.makeRegistrar( + hosts: [], http: FakeHTTPTransport(), center: center, remote: FakeRemoteRegistrar() + ) + + await registrar.activate() + + let registered = try #require(center.registeredCategorySets.first) + #expect(registered.contains(where: { $0.identifier == GateNotificationCategory.identifier })) + } + + @Test("无已配对主机 → 不请求授权、不注册远程通知(无推送来源不打扰)") + func activateWithoutHostsSkipsAuthorization() async { + let center = FakeNotificationCenter() + let remote = FakeRemoteRegistrar() + let registrar = Self.makeRegistrar( + hosts: [], http: FakeHTTPTransport(), center: center, remote: remote + ) + + await registrar.activate() + + #expect(center.requestedOptions.isEmpty) + #expect(remote.registerCallCount == 0) + } + + @Test("有主机 + notDetermined + 授予 → 以 [.alert,.sound] 请求(真授权,非 provisional)并注册远程通知") + func activateRequestsRealAuthorizationAndRegisters() async throws { + let center = FakeNotificationCenter() + let remote = FakeRemoteRegistrar() + let registrar = Self.makeRegistrar( + hosts: [try Self.makeHost(base: Self.hostABase)], + http: FakeHTTPTransport(), center: center, remote: remote + ) + + await registrar.activate() + + #expect(center.requestedOptions == [[.alert, .sound]]) + // provisional 只静默进通知中心,锁屏 Allow/Deny 需要 alert 级授权。 + #expect(center.requestedOptions.first?.contains(.provisional) == false) + #expect(remote.registerCallCount == 1) + } + + @Test("用户拒绝授权 → 不注册远程通知、不 crash") + func activateDeniedGrantSkipsRegistration() async throws { + let center = FakeNotificationCenter() + center.grantResult = .success(false) + let remote = FakeRemoteRegistrar() + let registrar = Self.makeRegistrar( + hosts: [try Self.makeHost(base: Self.hostABase)], + http: FakeHTTPTransport(), center: center, remote: remote + ) + + await registrar.activate() + + #expect(remote.registerCallCount == 0) + } + + @Test("授权请求抛错 → 记日志、不注册、不 crash") + func activateAuthorizationErrorHandled() async throws { + let center = FakeNotificationCenter() + center.grantResult = .failure(StubError.authorizationFailed) + let remote = FakeRemoteRegistrar() + let registrar = Self.makeRegistrar( + hosts: [try Self.makeHost(base: Self.hostABase)], + http: FakeHTTPTransport(), center: center, remote: remote + ) + + await registrar.activate() + + #expect(remote.registerCallCount == 0) + } + + @Test("状态已 denied → 不再弹请求,也不注册") + func activateDeniedStatusShortCircuits() async throws { + let center = FakeNotificationCenter() + center.status = .denied + let remote = FakeRemoteRegistrar() + let registrar = Self.makeRegistrar( + hosts: [try Self.makeHost(base: Self.hostABase)], + http: FakeHTTPTransport(), center: center, remote: remote + ) + + await registrar.activate() + + #expect(center.requestedOptions.isEmpty) + #expect(remote.registerCallCount == 0) + } + + @Test("状态已 authorized → 跳过请求直接注册远程通知") + func activateAuthorizedStatusRegistersDirectly() async throws { + let center = FakeNotificationCenter() + center.status = .authorized + let remote = FakeRemoteRegistrar() + let registrar = Self.makeRegistrar( + hosts: [try Self.makeHost(base: Self.hostABase)], + http: FakeHTTPTransport(), center: center, remote: remote + ) + + await registrar.activate() + + #expect(center.requestedOptions.isEmpty) + #expect(remote.registerCallCount == 1) + } + + // MARK: - handleDeviceToken:逐主机注册 + + @Test("device token → 小写 hex,并对每个已配对主机 POST /push/apns-token(带 Origin)") + func deviceTokenRegistersWithEveryHost() async throws { + let hostA = try Self.makeHost(base: Self.hostABase) + let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini") + let http = FakeHTTPTransport() + await http.queueSuccess( + method: "POST", url: try Self.apnsTokenURL(base: Self.hostABase), status: 204 + ) + await http.queueSuccess( + method: "POST", url: try Self.apnsTokenURL(base: Self.hostBBase), status: 204 + ) + let registrar = Self.makeRegistrar( + hosts: [hostA, hostB], http: http, + center: FakeNotificationCenter(), remote: FakeRemoteRegistrar() + ) + + await registrar.handleDeviceToken(Self.tokenData) + + #expect(registrar.currentTokenHex == Self.tokenHex) + let requests = await http.recordedRequests + #expect(requests.count == 2) + let urls = Set(requests.compactMap { $0.url?.absoluteString }) + #expect(urls == [ + "\(Self.hostABase)/push/apns-token", "\(Self.hostBBase)/push/apns-token", + ]) + for request in requests { + #expect(request.httpMethod == "POST") + // G 端点铁律:Origin 必与 endpoint.originHeader 逐字节一致。 + let origin = request.value(forHTTPHeaderField: "Origin") + #expect(origin == request.url?.absoluteString.replacingOccurrences( + of: "/push/apns-token", with: "" + )) + let body = try #require(request.httpBody) + let decoded = try JSONDecoder().decode([String: String].self, from: body) + #expect(decoded == ["token": Self.tokenHex]) + } + } + + @Test("一主机失败 → 其余主机照常注册;同 token 重试只补失败的主机") + func failedHostRetriedNextTime() async throws { + let hostA = try Self.makeHost(base: Self.hostABase) + let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini") + let http = FakeHTTPTransport() + let urlA = try Self.apnsTokenURL(base: Self.hostABase) + let urlB = try Self.apnsTokenURL(base: Self.hostBBase) + await http.queueFailure(method: "POST", url: urlA, error: URLError(.cannotConnectToHost)) + await http.queueSuccess(method: "POST", url: urlB, status: 204) + let registrar = Self.makeRegistrar( + hosts: [hostA, hostB], http: http, + center: FakeNotificationCenter(), remote: FakeRemoteRegistrar() + ) + + await registrar.handleDeviceToken(Self.tokenData) + #expect(await http.recordedRequests.count == 2) + + // 第二次同 token 送达(下次启动/激活的重试路径):只补 hostA。 + await http.queueSuccess(method: "POST", url: urlA, status: 204) + await registrar.handleDeviceToken(Self.tokenData) + + let requests = await http.recordedRequests + #expect(requests.count == 3) + #expect(requests.last?.url == urlA) + } + + @Test("token 变化 → 对全部主机重新注册") + func tokenChangeReRegistersAllHosts() async throws { + let hostA = try Self.makeHost(base: Self.hostABase) + let http = FakeHTTPTransport() + let urlA = try Self.apnsTokenURL(base: Self.hostABase) + await http.queueSuccess(method: "POST", url: urlA, status: 204) + await http.queueSuccess(method: "POST", url: urlA, status: 204) + let registrar = Self.makeRegistrar( + hosts: [hostA], http: http, + center: FakeNotificationCenter(), remote: FakeRemoteRegistrar() + ) + + await registrar.handleDeviceToken(Self.tokenData) + let changed = Data((0..<32).map { UInt8($0 &+ 1) }) + await registrar.handleDeviceToken(changed) + + #expect(await http.recordedRequests.count == 2) + #expect(registrar.currentTokenHex == changed.map { String(format: "%02x", $0) }.joined()) + } + + // MARK: - 主机移除钩子(additive hook:目前无 UI 移除路径,见任务决策记录) + + @Test("handleHostRemoved(有 token)→ 对该主机 DELETE /push/apns-token") + func hostRemovedUnregistersToken() async throws { + let hostA = try Self.makeHost(base: Self.hostABase) + let http = FakeHTTPTransport() + let urlA = try Self.apnsTokenURL(base: Self.hostABase) + await http.queueSuccess(method: "POST", url: urlA, status: 204) + await http.queueSuccess(method: "DELETE", url: urlA, status: 204) + let registrar = Self.makeRegistrar( + hosts: [hostA], http: http, + center: FakeNotificationCenter(), remote: FakeRemoteRegistrar() + ) + await registrar.handleDeviceToken(Self.tokenData) + + await registrar.handleHostRemoved(hostA) + + let last = try #require(await http.recordedRequests.last) + #expect(last.httpMethod == "DELETE") + #expect(last.url == urlA) + let body = try #require(last.httpBody) + #expect(try JSONDecoder().decode([String: String].self, from: body) + == ["token": Self.tokenHex]) + } + + @Test("handleHostRemoved(无 token)→ 不发任何请求") + func hostRemovedWithoutTokenIsNoOp() async throws { + let hostA = try Self.makeHost(base: Self.hostABase) + let http = FakeHTTPTransport() + let registrar = Self.makeRegistrar( + hosts: [hostA], http: http, + center: FakeNotificationCenter(), remote: FakeRemoteRegistrar() + ) + + await registrar.handleHostRemoved(hostA) + + #expect(await http.recordedRequests.isEmpty) + } + + @Test("远程注册失败回调 → 只记日志,不 crash") + func registrationFailureLoggedOnly() throws { + let registrar = Self.makeRegistrar( + hosts: [], http: FakeHTTPTransport(), + center: FakeNotificationCenter(), remote: FakeRemoteRegistrar() + ) + + registrar.handleRegistrationFailure(URLError(.notConnectedToInternet)) + + #expect(registrar.currentTokenHex == nil) + } +} diff --git a/ios/App/WebTermTests/QuickReplyTests.swift b/ios/App/WebTermTests/QuickReplyTests.swift new file mode 100644 index 0000000..2dd6663 --- /dev/null +++ b/ios/App/WebTermTests/QuickReplyTests.swift @@ -0,0 +1,393 @@ +import Foundation +import SessionCore +import TestSupport +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-25 · Quick-reply chips + 常用语面板。 +/// +/// 覆盖面(Steps 测试先行): +/// - 内置 chips 逐项镜像 web `public/quick-reply.ts` `BUILT_IN_CHIPS` +/// (id/text/label/appendEnter 与顺序;Esc 字节经 `KeyByteMap` 解析—— +/// App 层禁止手写转义序列); +/// - chip 点击 → `input` 帧(文本 + `\r`,Enter 是 0x0D 不是 0x0A), +/// 复用 TerminalViewModel 的有序发送泵:快速连点 = 按序两帧、绝不交错; +/// - 自定义面板 CRUD + 重排:纯不可变操作(镜像 add/remove/reorder/update +/// Chip),UserDefaults 持久化(可注入 suite;非机密,不进 Keychain), +/// 越界重排返回原样、损坏/异形存档 → 逐项过滤而非 crash(存储边界不可信); +/// - 浮出时机:仅 waiting(SessionEvent 流上的持牌 gate——status waiting +/// pending=true 的流内投影)才显示;gate 解除、exited/failed(read-only) +/// → 隐藏。 +@MainActor +@Suite("QuickReply") +struct QuickReplyTests { + // MARK: - Fixtures + + private enum ServerFrames { + static func attached(_ id: UUID) -> String { + #"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"# + } + + static func status(pending: Bool, gate: String? = nil) -> String { + var fields = ["\"type\":\"status\"", "\"status\":\"waiting\"", "\"pending\":\(pending)"] + if let gate { fields.append("\"gate\":\"\(gate)\"") } + return "{\(fields.joined(separator: ","))}" + } + + static func exit(code: Int) -> String { + #"{"type":"exit","code":\#(code)}"# + } + } + + private static func chip( + id: String = "user_t", text: String = "yes", label: String = "yes", + appendEnter: Bool = true + ) -> QuickReplyChip { + QuickReplyChip(id: id, text: text, label: label, appendEnter: appendEnter) + } + + /// 每测试独立的 UserDefaults suite(模式同 LastSessionStoreTests)。 + private static func makeSuiteName() -> String { + "quick-reply-tests-\(UUID().uuidString)" + } + + @MainActor + private final class NoopHaptics: HapticSignaling { + func gateDidArrive() {} + } + + // MARK: - Harness(真 engine + 生产同款 fan-out 双分支接线) + + @MainActor + private final class Harness { + let transport = FakeTransport() + let clock = FakeClock() + let engine: SessionEngine + let fanOut: EventFanOut + let terminalViewModel: TerminalViewModel + let gateViewModel: GateViewModel + + init() throws { + let baseURL = try #require(URL(string: "http://192.168.1.5:3000")) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + engine = SessionEngine( + transport: transport, clock: clock, endpoint: endpoint, + eventsSource: { _ in [] } + ) + fanOut = EventFanOut(source: engine.events, branchCount: 2) + terminalViewModel = TerminalViewModel(engine: engine, events: fanOut.branches[0]) + gateViewModel = GateViewModel( + engine: engine, events: fanOut.branches[1], + haptics: NoopHaptics(), clock: clock + ) + terminalViewModel.start() + gateViewModel.start() + } + + /// 标准前奏:open → .connecting → .connected → attached(3 事件到两个 VM)。 + func openAndAdopt(_ id: UUID = UUID()) async { + await engine.open(sessionId: nil, cwd: nil) + await transport.emit(frame: ServerFrames.attached(id)) + await waitBothProcessed(eventCount: 3) + } + + /// 两条 fan-out 分支各自达到事件水位(可见性由两个 VM 共同驱动)。 + func waitBothProcessed(eventCount: Int) async { + await terminalViewModel.waitUntilProcessed(eventCount: eventCount) + await gateViewModel.waitUntilProcessed(eventCount: eventCount) + } + + var isBarVisible: Bool { + QuickReplyBar.isVisible( + gate: gateViewModel.currentGate, + isReadOnly: terminalViewModel.isReadOnly + ) + } + + /// 连接 0 上客户端发出的帧(attach 先行,随后 input)。 + func wireFrames() async -> [String] { + let byConnection = await transport.sentFramesByConnection + return byConnection.first ?? [] + } + } + + // MARK: - 内置 chips(镜像 public/quick-reply.ts BUILT_IN_CHIPS) + + @Test("内置 chips 逐项镜像 web:id/text/label/appendEnter 与顺序完全一致") + func builtInChipsMirrorWebDefaults() { + let chips = QuickReplyPalette.builtInChips + + #expect(chips.map(\.id) == ["__yes", "__continue", "__1", "__2", "__3", "__esc"]) + #expect(chips.map(\.text) == [ + "yes", "continue", "1", "2", "3", KeyByteMap.bytes(for: .esc), + ]) + #expect(chips.map(\.label) == ["yes", "continue", "1", "2", "3", "Esc"]) + #expect(chips.map(\.appendEnter) == [true, true, true, true, true, false]) + } + + @Test("payload:appendEnter → 文本 + Enter(\\r,0x0D——CLAUDE.md gotcha);否则原文") + func payloadComposition() { + let enter = Self.chip(text: "continue", appendEnter: true) + #expect(QuickReplyPalette.payload(for: enter) + == "continue" + KeyByteMap.bytes(for: .enter)) + #expect(QuickReplyPalette.payload(for: enter) == "continue\r") + + let bare = Self.chip(text: KeyByteMap.bytes(for: .esc), appendEnter: false) + #expect(QuickReplyPalette.payload(for: bare) == KeyByteMap.bytes(for: .esc)) + } + + // MARK: - 纯不可变 CRUD(镜像 addChip/removeChip/reorderChip/updateChip) + + @Test("adding 追加到末尾;removing 按 id 过滤;未知 id 原样") + func addingAndRemoving() { + let a = Self.chip(id: "user_a") + let b = Self.chip(id: "user_b") + + let added = QuickReplyPalette.adding([a], b) + #expect(added == [a, b]) + + #expect(QuickReplyPalette.removing(added, id: "user_a") == [b]) + #expect(QuickReplyPalette.removing(added, id: "user_zzz") == [a, b]) + } + + @Test("reordering:前移/后移语义同 web splice;任一索引越界 → 原样返回") + func reorderingMovesAndBoundsChecks() { + let a = Self.chip(id: "user_a") + let b = Self.chip(id: "user_b") + let c = Self.chip(id: "user_c") + let chips = [a, b, c] + + #expect(QuickReplyPalette.reordering(chips, fromIndex: 0, toIndex: 2) == [b, c, a]) + #expect(QuickReplyPalette.reordering(chips, fromIndex: 2, toIndex: 0) == [c, a, b]) + #expect(QuickReplyPalette.reordering(chips, fromIndex: 3, toIndex: 0) == chips) + #expect(QuickReplyPalette.reordering(chips, fromIndex: 0, toIndex: -1) == chips) + } + + @Test("updating:按 id 浅合并 patch,id 不可变,其余 chip 不受影响;未知 id 原样") + func updatingPatchesMatchingChipOnly() { + let a = Self.chip(id: "user_a", text: "yes", label: "yes", appendEnter: true) + let b = Self.chip(id: "user_b", text: "go", label: "Go", appendEnter: false) + + let updated = QuickReplyPalette.updating( + [a, b], id: "user_a", text: "no", label: "No" + ) + #expect(updated == [ + QuickReplyChip(id: "user_a", text: "no", label: "No", appendEnter: true), + b, + ]) + + #expect(QuickReplyPalette.updating([a, b], id: "user_zzz", text: "x") == [a, b]) + } + + // MARK: - Store:UserDefaults 持久化(可注入 suite) + + @Test("addChip:修剪空白、label 空则取 text、id 带 user_ 前缀且唯一;跨实例持久") + func addPersistsAcrossInstances() throws { + let suiteName = Self.makeSuiteName() + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = QuickReplyStore(defaults: defaults) + #expect(store.addChip(text: " 跑测试 ", label: " ", appendEnter: true)) + #expect(store.addChip(text: "git status", label: "状态", appendEnter: false)) + + let chips = store.userChips + #expect(chips.map(\.text) == ["跑测试", "git status"]) + #expect(chips.map(\.label) == ["跑测试", "状态"]) + #expect(chips.map(\.appendEnter) == [true, false]) + #expect(chips.allSatisfy { $0.id.hasPrefix(QuickReplyPalette.userChipIdPrefix) }) + #expect(chips[0].id != chips[1].id) + + // 全部 chips = 内置在前 + 自定义在后(web render 顺序)。 + #expect(store.allChips == QuickReplyPalette.builtInChips + chips) + + // 新实例(同 suite)读回同一面板。 + let reloaded = QuickReplyStore(defaults: defaults) + #expect(reloaded.userChips == chips) + } + + @Test("addChip:空文本(含纯空白)是 no-op,返回 false 且不持久化") + func emptyTextAddIsNoOp() throws { + let suiteName = Self.makeSuiteName() + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = QuickReplyStore(defaults: defaults) + #expect(!store.addChip(text: " ", label: "标签", appendEnter: true)) + #expect(store.userChips.isEmpty) + #expect(QuickReplyStore(defaults: defaults).userChips.isEmpty) + } + + @Test("remove/update/reorder 均持久化:新实例读回操作后的面板") + func mutationsPersistAcrossInstances() throws { + let suiteName = Self.makeSuiteName() + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = QuickReplyStore(defaults: defaults) + #expect(store.addChip(text: "a", label: "A", appendEnter: true)) + #expect(store.addChip(text: "b", label: "B", appendEnter: true)) + #expect(store.addChip(text: "c", label: "C", appendEnter: true)) + let ids = store.userChips.map(\.id) + + // reorder: A,B,C → B,C,A;update B 的 label;remove C。 + store.moveChip(fromIndex: 0, toIndex: 2) + store.updateChip(id: ids[1], label: "B改") + store.removeChip(id: ids[2]) + + let expected = [ + QuickReplyChip(id: ids[1], text: "b", label: "B改", appendEnter: true), + QuickReplyChip(id: ids[0], text: "a", label: "A", appendEnter: true), + ] + #expect(store.userChips == expected) + #expect(QuickReplyStore(defaults: defaults).userChips == expected) + } + + @Test("SwiftUI onMove 适配(IndexSet + 目的地约定)→ 与纯 reordering 同结果") + func onMoveAdapterMatchesReorderingSemantics() throws { + let suiteName = Self.makeSuiteName() + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = QuickReplyStore(defaults: defaults) + #expect(store.addChip(text: "a", label: "A", appendEnter: true)) + #expect(store.addChip(text: "b", label: "B", appendEnter: true)) + #expect(store.addChip(text: "c", label: "C", appendEnter: true)) + + // List.onMove:把第 0 行拖到末尾 → fromOffsets {0}, toOffset 3。 + store.moveChips(fromOffsets: IndexSet(integer: 0), toOffset: 3) + #expect(store.userChips.map(\.text) == ["b", "c", "a"]) + } + + @Test("损坏存档 → 空面板;异形数组 → 逐项过滤只留合法 chip(存储边界不可信)") + func corruptOrPartiallyInvalidArchiveIsFiltered() throws { + let suiteName = Self.makeSuiteName() + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + // 整体损坏(非 JSON)→ []。 + defaults.set(Data("not json at all".utf8), forKey: QuickReplyStore.paletteDefaultsKey) + #expect(QuickReplyStore(defaults: defaults).userChips.isEmpty) + + // 根不是数组 → []。 + defaults.set(Data(#"{"id":"user_a"}"#.utf8), forKey: QuickReplyStore.paletteDefaultsKey) + #expect(QuickReplyStore(defaults: defaults).userChips.isEmpty) + + // 数组里混入垃圾条目 → 只留形状完整的(镜像 web isValidChip filter)。 + let mixed = #""" + [ + {"id":"user_a","text":"yes","label":"yes","appendEnter":true}, + {"id":42,"text":"x","label":"x","appendEnter":true}, + "junk", + {"id":"user_b","text":"go","label":"Go"}, + {"id":"user_c","text":"go","label":"Go","appendEnter":false} + ] + """# + defaults.set(Data(mixed.utf8), forKey: QuickReplyStore.paletteDefaultsKey) + #expect(QuickReplyStore(defaults: defaults).userChips == [ + QuickReplyChip(id: "user_a", text: "yes", label: "yes", appendEnter: true), + QuickReplyChip(id: "user_c", text: "go", label: "Go", appendEnter: false), + ]) + } + + // MARK: - 浮出时机(waiting = 流上的持牌 gate;read-only → 隐藏) + + @Test("无 gate → 隐藏;gate 升起(tool/plan 均可)→ 浮出;gate 解除 → 隐藏") + func visibilityFollowsHeldGate() async throws { + let harness = try Harness() + await harness.openAndAdopt() + #expect(!harness.isBarVisible) + + await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool")) + await harness.waitBothProcessed(eventCount: 4) + #expect(harness.isBarVisible) + + await harness.transport.emit(frame: ServerFrames.status(pending: false)) + await harness.waitBothProcessed(eventCount: 5) + #expect(!harness.isBarVisible) + + await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan")) + await harness.waitBothProcessed(eventCount: 6) + #expect(harness.isBarVisible) + } + + @Test("exited(read-only)→ 即使 gate 仍持牌也隐藏") + func exitedTerminalHidesChipsEvenWithHeldGate() async throws { + let harness = try Harness() + await harness.openAndAdopt() + await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool")) + await harness.waitBothProcessed(eventCount: 4) + #expect(harness.isBarVisible) + + await harness.transport.emit(frame: ServerFrames.exit(code: 0)) + await harness.waitBothProcessed(eventCount: 5) + #expect(harness.terminalViewModel.isReadOnly) + #expect(!harness.isBarVisible) + } + + // MARK: - 发送路径(经 TerminalViewModel 有序泵 → engine → wire) + + @Test("chip 点击 → input 帧(文本+\\r)经有序泵上线") + func chipTapSendsInputFrame() async throws { + let harness = try Harness() + await harness.openAndAdopt() + let yes = QuickReplyPalette.builtInChips[0] + + QuickReplyBar.send(yes, through: harness.terminalViewModel) + await harness.terminalViewModel.waitUntilForwarded(sendCount: 1) + + #expect(await harness.wireFrames() == [ + MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), + MessageCodec.encode(.input(data: "yes\r")), + ]) + } + + @Test("快速连点:两帧按点击顺序上线,绝不交错(复用 VM 发送泵)") + func rapidDoubleTapStaysOrdered() async throws { + let harness = try Harness() + await harness.openAndAdopt() + let yes = QuickReplyPalette.builtInChips[0] + let one = QuickReplyPalette.builtInChips[2] + + QuickReplyBar.send(yes, through: harness.terminalViewModel) + QuickReplyBar.send(one, through: harness.terminalViewModel) + await harness.terminalViewModel.waitUntilForwarded(sendCount: 2) + + #expect(await harness.wireFrames() == [ + MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), + MessageCodec.encode(.input(data: "yes\r")), + MessageCodec.encode(.input(data: "1\r")), + ]) + } + + @Test("exited 后点击 → VM read-only 守卫丢弃,wire 上无 input 帧") + func tapOnExitedTerminalIsDropped() async throws { + let harness = try Harness() + await harness.openAndAdopt() + await harness.transport.emit(frame: ServerFrames.exit(code: 0)) + await harness.waitBothProcessed(eventCount: 4) + + QuickReplyBar.send(QuickReplyPalette.builtInChips[0], through: harness.terminalViewModel) + + #expect(harness.terminalViewModel.droppedReadOnlyInputCount == 1) + #expect(await harness.wireFrames() == [ + MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), + ]) + } + + // MARK: - 面板文案(中文具名常量) + + @Test("面板/编辑器文案:中文具名常量在位") + func panelCopyConstants() { + #expect(QuickReplyPanel.Copy.title == "常用语") + #expect(QuickReplyPanel.Copy.addSection == "添加新常用语") + #expect(QuickReplyPanel.Copy.customSection == "自定义常用语") + #expect(QuickReplyPanel.Copy.textPlaceholder == "要发送的文本") + #expect(QuickReplyPanel.Copy.labelPlaceholder == "标签(可选,默认同文本)") + #expect(QuickReplyPanel.Copy.appendEnterToggle == "发送后自动回车") + #expect(QuickReplyPanel.Copy.addButton == "添加") + #expect(QuickReplyPanel.Copy.emptyState == "还没有自定义常用语") + #expect(QuickReplyBar.Copy.managePhrases == "管理常用语") + } +} diff --git a/ios/App/WebTermTests/SessionListViewModelTests.swift b/ios/App/WebTermTests/SessionListViewModelTests.swift index 4550a1f..25cb9d7 100644 --- a/ios/App/WebTermTests/SessionListViewModelTests.swift +++ b/ios/App/WebTermTests/SessionListViewModelTests.swift @@ -83,6 +83,7 @@ struct SessionListViewModelTests { hostStore: InMemoryHostStore(hosts: hosts), http: http, clock: clock, + unreadStore: InMemoryUnreadWatermarkStore(), // T-iOS-23 seam nowMs: nowMs ) } @@ -233,6 +234,7 @@ struct SessionListViewModelTests { hostStore: ThrowingHostStore(), http: FakeHTTPTransport(), clock: FakeClock(), + unreadStore: InMemoryUnreadWatermarkStore(), nowMs: { Self.nowMs } ) diff --git a/ios/App/WebTermTests/SessionSwitcherTests.swift b/ios/App/WebTermTests/SessionSwitcherTests.swift new file mode 100644 index 0000000..33d227b --- /dev/null +++ b/ios/App/WebTermTests/SessionSwitcherTests.swift @@ -0,0 +1,411 @@ +import APIClient +import Foundation +import HostRegistry +import SessionCore +import TestSupport +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-23 · 多会话切换器:unread 点(`lastOutputAt` vs 本地水位,经 +/// `UnreadLedger` + App 侧 UserDefaults 持久化)+ OSC 标题(SwiftTerm +/// `setTerminalTitle` delegate → `TitleSanitizer` → 列表行)。 +/// +/// 分层: +/// - 列表 VM:unread 判定/清除/持久化、标题注册表(净化在两个边界各自执行); +/// - TerminalViewModel:delegate 原始标题在此边界净化,adopted 后携 id 上浮; +/// - 接线:controller 把 title hook 接到新旧 VM(suspend→resume 重建后不掉线); +/// coordinator 的 closeTerminal 记 last-seen 水位、title 流入列表行。 +/// 单活 WS 不变式:切会话 = close→open(deep-link 路径 `openDeepLinkedSession` +/// 与列表返回→再开共用 `closeTerminal`,一次只有一个 engine —— 见接线用例)。 +@MainActor +@Suite("SessionSwitcher (T-iOS-23)") +struct SessionSwitcherTests { + private nonisolated static let base = "http://192.168.1.5:3000" + private nonisolated static let nowMs = 1_700_000_100_000 + + // MARK: - Fixtures + + private func makeHost() throws -> HostRegistry.Host { + let url = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: url)) + return HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint) + } + + private func listURL() throws -> URL { + try #require(URL(string: "\(Self.base)/live-sessions")) + } + + private func sessionJSON(id: UUID, lastOutputAt: Int? = nil, cwd: String = "/Users/dev/proj") -> String { + let lastOutputJSON = lastOutputAt.map { ",\"lastOutputAt\":\($0)" } ?? "" + return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":1700000000000" + + ",\"clientCount\":1,\"status\":\"working\",\"exited\":false" + + ",\"cwd\":\"\(cwd)\",\"cols\":80,\"rows\":24\(lastOutputJSON)}" + } + + private func listBody(_ entries: [String]) -> Data { + Data("[\(entries.joined(separator: ","))]".utf8) + } + + private func makeListViewModel( + hosts: [HostRegistry.Host], + http: any HTTPTransport, + unreadStore: any UnreadWatermarkStore + ) -> SessionListViewModel { + SessionListViewModel( + hostStore: InMemoryHostStore(hosts: hosts), + http: http, + clock: FakeClock(), + unreadStore: unreadStore, + nowMs: { Self.nowMs } + ) + } + + private func loadOnce(_ viewModel: SessionListViewModel) async { + await viewModel.reloadHosts() + await viewModel.refresh() + } + + // MARK: - 列表 unread 点(水位判定 + 清除 + 复燃) + + @Test("lastOutputAt 高于水位 → unread 点亮;markSeen 熄灭;更新的输出再点亮") + func unreadDotLifecycle() async throws { + // Arrange + let http = FakeHTTPTransport() + let host = try makeHost() + let store = InMemoryUnreadWatermarkStore() + let sessionId = UUID() + let url = try listURL() + await http.queueSuccess( + url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs - 1_000)]) + ) + let viewModel = makeListViewModel(hosts: [host], http: http, unreadStore: store) + + // Act & Assert: 从未看过 → unread。 + await loadOnce(viewModel) + #expect(viewModel.rows.first?.isUnread == true) + + // Act & Assert: 标记已读 → 立即熄灭(不等下一次轮询)。 + viewModel.markSeen(sessionId: sessionId) + #expect(viewModel.rows.first?.isUnread == false) + + // Act & Assert: 服务器又有新输出 → 复燃。 + await http.queueSuccess( + url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs + 5_000)]) + ) + await viewModel.refresh() + #expect(viewModel.rows.first?.isUnread == true) + } + + @Test("lastOutputAt 缺席(旧服务器)→ 绝不点灯") + func absentLastOutputAtNeverLights() async throws { + // Arrange + let http = FakeHTTPTransport() + let host = try makeHost() + let sessionId = UUID() + await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionId)])) + let viewModel = makeListViewModel( + hosts: [host], http: http, unreadStore: InMemoryUnreadWatermarkStore() + ) + + // Act + await loadOnce(viewModel) + + // Assert + #expect(viewModel.rows.first?.isUnread == false) + } + + @Test("markSeen 持久化水位;新 VM(重启模拟)读回后照样已读") + func watermarkPersistsAcrossViewModels() async throws { + // Arrange + let http = FakeHTTPTransport() + let host = try makeHost() + let store = InMemoryUnreadWatermarkStore() + let sessionId = UUID() + let url = try listURL() + let outputAt = Self.nowMs - 1_000 + await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: outputAt)])) + let first = makeListViewModel(hosts: [host], http: http, unreadStore: store) + await loadOnce(first) + + // Act: 标记已读 → 存储必须收到水位。 + first.markSeen(sessionId: sessionId) + #expect(store.snapshot[sessionId] == Self.nowMs) + + // Arrange: "重启"——新 VM 从同一 store 载入。 + await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: outputAt)])) + let second = makeListViewModel(hosts: [host], http: http, unreadStore: store) + + // Act & Assert + await loadOnce(second) + #expect(second.rows.first?.isUnread == false) + } + + @Test("点开会话行(openSession)即记 last-seen —— 点即熄灭") + func openSessionMarksSeen() async throws { + // Arrange + let http = FakeHTTPTransport() + let host = try makeHost() + let store = InMemoryUnreadWatermarkStore() + let sessionId = UUID() + await http.queueSuccess( + url: try listURL(), + body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs - 1_000)]) + ) + let viewModel = makeListViewModel(hosts: [host], http: http, unreadStore: store) + await loadOnce(viewModel) + #expect(viewModel.rows.first?.isUnread == true) + + // Act + viewModel.openSession(id: sessionId) + + // Assert: 导航信号照发,同时水位已记、点已熄。 + #expect(viewModel.openRequest?.sessionId == sessionId) + #expect(viewModel.rows.first?.isUnread == false) + #expect(store.snapshot[sessionId] == Self.nowMs) + } + + // MARK: - 列表标题注册表(敌意标题在列表边界再净化一次) + + @Test("setSessionTitle 净化敌意标题后上行;净化后为空 → 回退 cwd 名(title nil)") + func sessionTitleRegistrySanitizes() async throws { + // Arrange + let http = FakeHTTPTransport() + let host = try makeHost() + let sessionId = UUID() + await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionId)])) + let viewModel = makeListViewModel( + hosts: [host], http: http, unreadStore: InMemoryUnreadWatermarkStore() + ) + await loadOnce(viewModel) + #expect(viewModel.rows.first?.title == nil) + + // Act: RLO + ESC 注入的敌意标题直接打列表接口(不信任上游净化)。 + viewModel.setSessionTitle(sessionId: sessionId, title: "npm \u{202E}run\u{1B}[2J build") + + // Assert + #expect(viewModel.rows.first?.title == "npm run[2J build") + + // Act & Assert: 全零宽标题 → 清空注册表条目,回退 nil。 + viewModel.setSessionTitle(sessionId: sessionId, title: "\u{200B}\u{200D}") + #expect(viewModel.rows.first?.title == nil) + } + + // MARK: - TerminalViewModel:delegate 边界净化 + adopted 后携 id 上浮 + + /// TerminalViewModelTests 的同款 harness:真 engine over FakeTransport。 + @MainActor + private final class TerminalHarness { + let transport = FakeTransport() + let engine: SessionEngine + let viewModel: TerminalViewModel + + init() throws { + let baseURL = try #require(URL(string: SessionSwitcherTests.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + engine = SessionEngine( + transport: transport, clock: FakeClock(), endpoint: endpoint, + eventsSource: { _ in [] } + ) + viewModel = TerminalViewModel(engine: engine, events: engine.events) + viewModel.start() + } + + func openAndAdopt(_ id: UUID) async { + await engine.open(sessionId: nil, cwd: nil) + await transport.emit( + frame: #"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"# + ) + await viewModel.waitUntilProcessed(eventCount: 3) + } + } + + @Test("setTerminalTitle:敌意原始标题在 VM 边界净化,terminalTitle 暴露净化值并携 adopted id 上浮") + func terminalTitleSanitizedAndForwarded() async throws { + // Arrange + let harness = try TerminalHarness() + let adopted = UUID() + var forwarded: [(UUID, String)] = [] + harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) } + await harness.openAndAdopt(adopted) + + // Act: SwiftTerm delegate 原样送来的敌意 OSC 标题。 + harness.viewModel.setTerminalTitle(" claude\u{202E}\u{07} · fix bug ") + + // Assert + #expect(harness.viewModel.terminalTitle == "claude · fix bug") + #expect(forwarded.count == 1) + #expect(forwarded.first?.0 == adopted) + #expect(forwarded.first?.1 == "claude · fix bug") + } + + @Test("adopted 前到达的标题被暂存,adopted 后补发一次(不丢、不提前)") + func titleBeforeAdoptionIsHeldThenForwarded() async throws { + // Arrange + let harness = try TerminalHarness() + let adopted = UUID() + var forwarded: [(UUID, String)] = [] + harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) } + + // Act: 标题先到(防御路径——真实线序 attached 永远在 output 前)。 + harness.viewModel.setTerminalTitle("early") + #expect(forwarded.isEmpty) + await harness.openAndAdopt(adopted) + + // Assert + #expect(forwarded.count == 1) + #expect(forwarded.first?.0 == adopted) + #expect(forwarded.first?.1 == "early") + } + + @Test("标题被清空(净化后空串)→ terminalTitle 归 nil,上浮空串让注册表清条目") + func clearedTitleForwardsEmpty() async throws { + // Arrange + let harness = try TerminalHarness() + let adopted = UUID() + var forwarded: [(UUID, String)] = [] + harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) } + await harness.openAndAdopt(adopted) + harness.viewModel.setTerminalTitle("work") + + // Act + harness.viewModel.setTerminalTitle(" ") + + // Assert + #expect(harness.viewModel.terminalTitle == nil) + #expect(forwarded.map(\.1) == ["work", ""]) + } + + // MARK: - 接线:controller 挂 hook(重建后不掉)+ coordinator 全链路 + + private struct WiringFixture { + let transport: FakeTransport + let http: FakeHTTPTransport + let host: HostRegistry.Host + let unreadStore: InMemoryUnreadWatermarkStore + let environment: AppEnvironment + } + + private func makeWiringFixture(suiteName: String) throws -> WiringFixture { + let baseURL = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let transport = FakeTransport() + let http = FakeHTTPTransport() + let unreadStore = InMemoryUnreadWatermarkStore() + let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint) + let defaults = try #require(UserDefaults(suiteName: suiteName)) + return WiringFixture( + transport: transport, + http: http, + host: host, + unreadStore: unreadStore, + environment: AppEnvironment( + hostStore: InMemoryHostStore(hosts: [host]), + lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults), + http: http, + termTransport: transport, + probe: { _ in .failure(.timeout) }, + unreadStore: unreadStore + ) + ) + } + + private func adopt( + _ controller: TerminalSessionController, + transport: FakeTransport, + sessionId: UUID + ) async { + await controller.openTask?.value + await transport.emit( + frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"# + ) + await controller.terminalViewModel.waitUntilProcessed(eventCount: 3) + } + + @Test("controller 把 title hook 接到 VM;suspend→resume 重建后新 VM 依然上浮") + func controllerRewiresTitleHookAcrossRebuild() async throws { + // Arrange + let fixture = try makeWiringFixture(suiteName: "SessionSwitcherTests.rewire") + let adopted = UUID() + var forwarded: [(UUID, String)] = [] + let controller = TerminalSessionController( + host: fixture.host, sessionId: nil, environment: fixture.environment, + onPendingChanged: { _, _ in }, + onTitleChanged: { forwarded.append(($0, $1)) } + ) + controller.start() + await adopt(controller, transport: fixture.transport, sessionId: adopted) + + // Act: 第一代 VM 上浮。 + controller.terminalViewModel.setTerminalTitle("gen0") + + // Assert + #expect(forwarded.map(\.1) == ["gen0"]) + + // Act: 后台→前台重建(新 engine + 新 VM),再上浮一次。 + controller.suspend() + controller.resumeIfNeeded() + await adopt(controller, transport: fixture.transport, sessionId: adopted) + controller.terminalViewModel.setTerminalTitle("gen1") + + // Assert: 重建没有弄丢 hook,id 仍是 adopted id。 + #expect(forwarded.map(\.1) == ["gen0", "gen1"]) + #expect(forwarded.allSatisfy { $0.0 == adopted }) + controller.teardown() + } + + @Test("coordinator:closeTerminal 记 last-seen 水位(切会话/返回列表即已读);title 流入列表行") + func coordinatorMarksSeenOnCloseAndRoutesTitles() async throws { + // Arrange + let fixture = try makeWiringFixture(suiteName: "SessionSwitcherTests.coordinator") + let coordinator = AppCoordinator(environment: fixture.environment) + let adopted = UUID() + await coordinator.sessionList.reloadHosts() + + // Act: 打开新会话 → 服务器 adopted。 + coordinator.open(SessionListViewModel.OpenRequest( + id: UUID(), host: fixture.host, sessionId: nil + )) + let controller = try #require(coordinator.terminalController) + await adopt(controller, transport: fixture.transport, sessionId: adopted) + + // Act: OSC 标题上浮(模拟 SwiftTerm delegate)→ 列表行可见。 + controller.terminalViewModel.setTerminalTitle("vim server.ts") + await fixture.http.queueSuccess( + url: try listURL(), body: listBody([sessionJSON(id: adopted)]) + ) + await coordinator.sessionList.refresh() + #expect(coordinator.sessionList.rows.first?.title == "vim server.ts") + + // Act: 返回列表(单活 WS 的切会话前半程:close)。 + coordinator.closeTerminal() + + // Assert: adopted 会话的 last-seen 已入库 —— 切换回列表即已读。 + #expect(coordinator.terminalController == nil) + #expect(fixture.unreadStore.snapshot[adopted] != nil) + } +} + +// MARK: - 测试替身(App 层 UnreadWatermarkStore 的内存实现) + +/// 线程安全的内存水位存储(SessionListViewModelTests 也复用)。 +final class InMemoryUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable { + private let lock = NSLock() + private var stored: [UUID: Int] + + init(_ initial: [UUID: Int] = [:]) { + stored = initial + } + + func load() -> [UUID: Int] { + lock.withLock { stored } + } + + func save(_ watermarks: [UUID: Int]) { + lock.withLock { stored = watermarks } + } + + var snapshot: [UUID: Int] { + lock.withLock { stored } + } +} diff --git a/ios/App/WebTermTests/SessionThumbnailTests.swift b/ios/App/WebTermTests/SessionThumbnailTests.swift new file mode 100644 index 0000000..5fd3965 --- /dev/null +++ b/ios/App/WebTermTests/SessionThumbnailTests.swift @@ -0,0 +1,434 @@ +import APIClient +import Foundation +import Testing +import UIKit +import WireProtocol +@testable import WebTerm + +/// T-iOS-28 · 会话缩略图(offscreen SwiftTerm)。 +/// +/// 覆盖面(Steps 测试先行): +/// - 缓存键 (sessionId, lastOutputAt):未变化的会话绝不重渲染;lastOutputAt +/// 变化 → 新键重渲染;nil(P1 前旧服务器)→ 渲染一次后永久命中(无失效 +/// 信号时宁可陈旧也不无界重渲染,文档化降级); +/// - 并发上限调度器:离屏渲染受具名常量上限(2)约束,列表滚动绝不无界 +/// spawn 终端;排队 FIFO、permit 转移;全程零真实等待(continuation 屏障, +/// 同 FakeClock.waitForSleepers 手法); +/// - 404/错误/校验失败 → placeholder(且同键缓存失败结果,防滚动打爆故障主机); +/// - 预览数据是不可信字节:id 不匹配 / data 超限 / 几何出 resizeRange → +/// placeholder 且 renderer 不被调用(data 只喂 SwiftTerm,永不字符串处理); +/// - 真像素冒烟(仅模拟器):小 ANSI 样例 → 非 nil、非单色快照。 +@MainActor +@Suite("SessionThumbnail") +struct SessionThumbnailTests { + // MARK: - Fixtures + + private static func makeEndpoint() -> HostEndpoint { + guard let url = URL(string: "http://192.168.1.20:3000"), + let endpoint = HostEndpoint(baseURL: url) else { + fatalError("fixture endpoint must be valid") + } + return endpoint + } + + private static func makeRequest( + sessionId: UUID = UUID(), lastOutputAt: Int? = 1_000 + ) -> SessionThumbnailRequest { + SessionThumbnailRequest( + endpoint: makeEndpoint(), sessionId: sessionId, lastOutputAt: lastOutputAt + ) + } + + private static func makePreview( + for request: SessionThumbnailRequest, cols: Int = 80, rows: Int = 24, + data: String = "hello" + ) -> SessionPreview { + SessionPreview(id: request.sessionId, cols: cols, rows: rows, data: data) + } + + /// 主 actor 上的调用计数器(loader/renderer 侧记录)。 + @MainActor + private final class Recorder { + private(set) var loaderCalls: [SessionThumbnailRequest] = [] + private(set) var rendererCalls: [(data: String, cols: Int, rows: Int)] = [] + func recordLoad(_ request: SessionThumbnailRequest) { + loaderCalls = loaderCalls + [request] + } + func recordRender(data: String, cols: Int, rows: Int) { + rendererCalls = rendererCalls + [(data, cols, rows)] + } + } + + /// 可控放行的 loader 闸(测试决定何时放行;open 后一路直通)。 + /// 屏障用 continuation,不轮询不真睡(与 FakeClock.waitForSleepers 同手法)。 + @MainActor + private final class LoaderHold { + private var held: [CheckedContinuation] = [] + private var barriers: [(target: Int, cont: CheckedContinuation)] = [] + private var isOpen = false + var heldCount: Int { held.count } + + func pass() async { + if isOpen { return } + await withCheckedContinuation { cont in + held = held + [cont] + notifyBarriers() + } + } + + /// 挂起直到至少 target 个 loader 停在闸上。 + func waitUntilHeld(count target: Int) async { + if held.count >= target { return } + await withCheckedContinuation { cont in + barriers = barriers + [(target, cont)] + } + } + + /// 放行当前所有被扣住的 loader,并让后续 loader 直通。 + func open() { + isOpen = true + let releasing = held + held = [] + for cont in releasing { cont.resume() } + } + + private func notifyBarriers() { + let met = barriers.filter { $0.target <= held.count } + barriers = barriers.filter { $0.target > held.count } + for barrier in met { barrier.cont.resume() } + } + } + + private static func makePipeline( + recorder: Recorder, + gate: SessionThumbnailRenderGate = SessionThumbnailRenderGate( + limit: SessionThumbnailTunable.maxConcurrentRenders + ), + maxCacheEntries: Int = SessionThumbnailTunable.maxCacheEntries, + hold: LoaderHold? = nil, + loaderError: (any Error)? = nil, + preview: ((SessionThumbnailRequest) -> SessionPreview)? = nil, + rendered: UIImage? = UIImage() + ) -> SessionThumbnailPipeline { + SessionThumbnailPipeline( + loader: { request in + if let hold { await hold.pass() } + recorder.recordLoad(request) + if let loaderError { throw loaderError } + return preview?(request) ?? Self.makePreview(for: request) + }, + renderer: { data, cols, rows in + recorder.recordRender(data: data, cols: cols, rows: rows) + return rendered + }, + gate: gate, + maxCacheEntries: maxCacheEntries + ) + } + + // MARK: - 缓存键 (sessionId, lastOutputAt) + + @Test("同键第二次请求命中缓存——loader 只调一次,返回同一图像") + func sameKeyHitsCache() async { + let recorder = Recorder() + let pipeline = Self.makePipeline(recorder: recorder) + let request = Self.makeRequest() + + let first = await pipeline.thumbnail(for: request) + let second = await pipeline.thumbnail(for: request) + + #expect(recorder.loaderCalls.count == 1) + #expect(first == second) + #expect(!first.isPlaceholder) + } + + @Test("lastOutputAt 变化(会话有新输出)→ 新键重新渲染") + func newerLastOutputAtRerenders() async { + let recorder = Recorder() + let pipeline = Self.makePipeline(recorder: recorder) + let sessionId = UUID() + + _ = await pipeline.thumbnail(for: Self.makeRequest(sessionId: sessionId, lastOutputAt: 1_000)) + _ = await pipeline.thumbnail(for: Self.makeRequest(sessionId: sessionId, lastOutputAt: 2_000)) + + #expect(recorder.loaderCalls.count == 2) + } + + @Test("lastOutputAt 为 nil(P1 前服务器)→ 渲染一次后命中缓存(文档化降级)") + func nilLastOutputAtCachesOnce() async { + let recorder = Recorder() + let pipeline = Self.makePipeline(recorder: recorder) + let request = Self.makeRequest(lastOutputAt: nil) + + _ = await pipeline.thumbnail(for: request) + _ = await pipeline.thumbnail(for: request) + + #expect(recorder.loaderCalls.count == 1) + } + + @Test("并发去重:同键两个并发请求 → loader 一次、两个调用者拿到同一结果") + func concurrentSameKeyDeduplicates() async { + let recorder = Recorder() + let hold = LoaderHold() + let pipeline = Self.makePipeline(recorder: recorder, hold: hold) + let request = Self.makeRequest() + + let taskA = Task { await pipeline.thumbnail(for: request) } + let taskB = Task { await pipeline.thumbnail(for: request) } + await hold.waitUntilHeld(count: 1) // 只有一个 loader 在飞 + hold.open() + let a = await taskA.value + let b = await taskB.value + + #expect(recorder.loaderCalls.count == 1) + #expect(a == b) + } + + // MARK: - 并发上限调度器(滚动不得无界 spawn 离屏终端) + + @Test("5 个不同键并发:同时在渲染管线内的至多 maxConcurrentRenders(2),其余排队") + func concurrencyCapHoldsUnderBurst() async { + let recorder = Recorder() + let gate = SessionThumbnailRenderGate(limit: SessionThumbnailTunable.maxConcurrentRenders) + let hold = LoaderHold() + let pipeline = Self.makePipeline(recorder: recorder, gate: gate, hold: hold) + let requests = (0..<5).map { _ in Self.makeRequest() } + + let tasks = requests.map { request in + Task { await pipeline.thumbnail(for: request) } + } + await hold.waitUntilHeld(count: 2) // 两个已过闸进入 loader + await gate.waitUntilWaiting(count: 3) // 其余三个停在渲染闸上 + + #expect(gate.activeCount == 2) + #expect(gate.peakActiveCount == 2) + #expect(hold.heldCount == 2) + + hold.open() + for task in tasks { _ = await task.value } + + #expect(gate.peakActiveCount == SessionThumbnailTunable.maxConcurrentRenders) + #expect(recorder.loaderCalls.count == 5) + #expect(gate.activeCount == 0) + } + + @Test("gate FIFO:permit 按排队顺序转移") + func gateIsFifo() async { + let gate = SessionThumbnailRenderGate(limit: 1) + await gate.acquire() + let order = OrderBox() + + let third = Task { + await gate.acquire() + order.append(3) + gate.release() + } + await gate.waitUntilWaiting(count: 1) + let fourth = Task { + await gate.acquire() + order.append(4) + gate.release() + } + await gate.waitUntilWaiting(count: 2) + + gate.release() // permit 依次传给 3 再 4 + _ = await third.value + _ = await fourth.value + + #expect(order.values == [3, 4]) + #expect(gate.activeCount == 0) + } + + @MainActor + private final class OrderBox { + private(set) var values: [Int] = [] + func append(_ value: Int) { values = values + [value] } + } + + // MARK: - placeholder 路径(404/错误/校验失败) + + @Test("loader 抛错(如 404 sessionNotFound)→ placeholder,且失败同键缓存不重试") + func loaderFailureYieldsCachedPlaceholder() async { + let recorder = Recorder() + let pipeline = Self.makePipeline( + recorder: recorder, loaderError: APIClientError.sessionNotFound + ) + let request = Self.makeRequest() + + let first = await pipeline.thumbnail(for: request) + let second = await pipeline.thumbnail(for: request) + + #expect(first.isPlaceholder) + #expect(second.isPlaceholder) + #expect(recorder.loaderCalls.count == 1) // 失败也按键缓存 + #expect(recorder.rendererCalls.isEmpty) + } + + @Test("renderer 返回 nil(快照失败)→ placeholder") + func rendererNilYieldsPlaceholder() async { + let recorder = Recorder() + let pipeline = Self.makePipeline(recorder: recorder, rendered: nil) + + let result = await pipeline.thumbnail(for: Self.makeRequest()) + + #expect(result.isPlaceholder) + #expect(recorder.rendererCalls.count == 1) + } + + // MARK: - 不可信服务器数据(renderer 不被调用即为「拒于边界」) + + @Test("preview.id 与请求的 sessionId 不匹配 → placeholder,renderer 不调") + func mismatchedPreviewIdRejected() async { + let recorder = Recorder() + let pipeline = Self.makePipeline(recorder: recorder, preview: { _ in + SessionPreview(id: UUID(), cols: 80, rows: 24, data: "x") // id ≠ request.sessionId + }) + + let result = await pipeline.thumbnail(for: Self.makeRequest()) + + #expect(result.isPlaceholder) + #expect(recorder.rendererCalls.isEmpty) + } + + @Test("data 超出 maxPreviewDataBytes → placeholder,renderer 不调") + func oversizedPreviewDataRejected() async { + let recorder = Recorder() + let oversized = String( + repeating: "a", count: SessionThumbnailTunable.maxPreviewDataBytes + 1 + ) + let pipeline = Self.makePipeline(recorder: recorder, preview: { request in + Self.makePreview(for: request, data: oversized) + }) + + let result = await pipeline.thumbnail(for: Self.makeRequest()) + + #expect(result.isPlaceholder) + #expect(recorder.rendererCalls.isEmpty) + } + + @Test("几何出服务器 resizeRange(0 / 1001)→ placeholder,renderer 不调", arguments: [ + (0, 24), (80, 0), (1_001, 24), (80, 1_001), + ]) + func outOfRangeGeometryRejected(cols: Int, rows: Int) async { + let recorder = Recorder() + let pipeline = Self.makePipeline(recorder: recorder, preview: { request in + Self.makePreview(for: request, cols: cols, rows: rows) + }) + + let result = await pipeline.thumbnail(for: Self.makeRequest()) + + #expect(result.isPlaceholder) + #expect(recorder.rendererCalls.isEmpty) + } + + @Test("几何钳制:合法值透传;1→2(镜像 web max(2,·));大值钳到缩略图上限") + func geometryClamping() { + let normal = SessionThumbnailPipeline.clampedGeometry(cols: 80, rows: 24) + #expect(normal?.cols == 80 && normal?.rows == 24) + + let tiny = SessionThumbnailPipeline.clampedGeometry(cols: 1, rows: 1) + #expect(tiny?.cols == SessionThumbnailTunable.minRenderGrid) + #expect(tiny?.rows == SessionThumbnailTunable.minRenderGrid) + + let huge = SessionThumbnailPipeline.clampedGeometry(cols: 1_000, rows: 1_000) + #expect(huge?.cols == SessionThumbnailTunable.maxRenderCols) + #expect(huge?.rows == SessionThumbnailTunable.maxRenderRows) + + #expect(SessionThumbnailPipeline.clampedGeometry(cols: 0, rows: 24) == nil) + #expect(SessionThumbnailPipeline.clampedGeometry(cols: 80, rows: 1_001) == nil) + } + + @Test("渲染几何真被钳制后才交给 renderer") + func rendererReceivesClampedGeometry() async { + let recorder = Recorder() + let pipeline = Self.makePipeline(recorder: recorder, preview: { request in + Self.makePreview(for: request, cols: 1_000, rows: 1) + }) + + _ = await pipeline.thumbnail(for: Self.makeRequest()) + + #expect(recorder.rendererCalls.count == 1) + #expect(recorder.rendererCalls.first?.cols == SessionThumbnailTunable.maxRenderCols) + #expect(recorder.rendererCalls.first?.rows == SessionThumbnailTunable.minRenderGrid) + } + + // MARK: - LRU 缓存 + + @Test("LRU:容量 3 时命中会前移——插 A,B,C、命中 A、插 D → 逐出 B,A/C/D 仍在") + func lruEvictsLeastRecentlyUsed() async { + let recorder = Recorder() + let pipeline = Self.makePipeline(recorder: recorder, maxCacheEntries: 3) + let a = Self.makeRequest() + let b = Self.makeRequest() + let c = Self.makeRequest() + let d = Self.makeRequest() + + _ = await pipeline.thumbnail(for: a) + _ = await pipeline.thumbnail(for: b) + _ = await pipeline.thumbnail(for: c) + _ = await pipeline.thumbnail(for: a) // bump A → LRU 现在是 B + _ = await pipeline.thumbnail(for: d) // 逐出 B + #expect(recorder.loaderCalls.count == 4) + + _ = await pipeline.thumbnail(for: b) // B 已被逐出 → 重新加载 + #expect(recorder.loaderCalls.count == 5) + + _ = await pipeline.thumbnail(for: a) // C 被 B 的回插逐出也无妨——A 仍应在 + #expect(recorder.loaderCalls.count == 5) + } + + // MARK: - 常量与文案 + + @Test("具名常量在位:并发上限 2、缓存上限 > 0、数据上限覆盖服务器默认 24KB") + func tunablesArePinned() { + #expect(SessionThumbnailTunable.maxConcurrentRenders == 2) + #expect(SessionThumbnailTunable.maxCacheEntries > 0) + // 服务器默认 previewBytes = 24*1024(src/config.ts:46,env 可调)—— + // 客户端防御上限必须至少覆盖默认值。 + #expect(SessionThumbnailTunable.maxPreviewDataBytes >= 24 * 1024) + #expect(SessionThumbnailTunable.minRenderGrid == 2) + } + + @Test("用户可见文案:中文具名常量、非空") + func copyIsNonEmptyChinese() { + #expect(!SessionThumbnailCopy.thumbnailLabel.isEmpty) + } + + // MARK: - 像素冒烟(仅模拟器;真 SwiftTerm 离屏渲染) + + #if targetEnvironment(simulator) + @Test("离屏渲染小 ANSI 样例 → 非 nil、宽>高、非单色、宽度受快照预算约束") + func rendererSmokeProducesNonBlankImage() throws { + let sample = "\u{1b}[31mRED\u{1b}[0m plain \u{1b}[44m BLUE-BG \u{1b}[0m" + let image = SessionThumbnailRenderer.render(data: sample, cols: 20, rows: 5) + + let snapshot = try #require(image) + #expect(snapshot.size.width > 0 && snapshot.size.height > 0) + #expect(snapshot.size.width > snapshot.size.height) // 20×5 网格是横幅 + #expect(snapshot.size.width <= SessionThumbnailRenderer.snapshotTargetWidth + 1) + #expect(Self.hasMultipleColors(snapshot)) // 空白/纯色 = 渲染管线断了 + } + + /// 把快照解到 RGBA 缓冲里扫描:出现 ≥2 种像素值即「画上东西了」。 + private static func hasMultipleColors(_ image: UIImage) -> Bool { + guard let cgImage = image.cgImage else { return false } + let width = min(cgImage.width, 64) + let height = min(cgImage.height, 64) + let bytesPerPixel = 4 + var buffer = [UInt8](repeating: 0, count: width * height * bytesPerPixel) + guard let context = CGContext( + data: &buffer, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width * bytesPerPixel, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return false } + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + let first = Array(buffer.prefix(bytesPerPixel)) + for pixel in stride(from: 0, to: buffer.count, by: bytesPerPixel) + where Array(buffer[pixel..<(pixel + bytesPerPixel)]) != first { + return true + } + return false + } + #endif +} diff --git a/ios/App/WebTermTests/TimelineSheetTests.swift b/ios/App/WebTermTests/TimelineSheetTests.swift new file mode 100644 index 0000000..9842600 --- /dev/null +++ b/ios/App/WebTermTests/TimelineSheetTests.swift @@ -0,0 +1,197 @@ +import Foundation +import SwiftUI +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-24 · Timeline sheet(完整时间线钻取)。 +/// +/// 覆盖面(Steps 测试先行): +/// - `/live-sessions/:id/events` 全量渲染管线:呈现顺序**逐行镜像 web** +/// (public/timeline.ts:174-189 render()——`slice(0, maxEvents)` 先截断 +/// 再 reverse,newest-first 展示); +/// - class → 图标映射逐字镜像 web `timelineIcon`(public/timeline.ts:88-96), +/// 颜色为语义映射(web CSS 未定义 tl-icon-* 颜色——iOS 对齐 +/// SessionListScreen 的状态色约定);未知 class 走 fallback(防御纵深, +/// 服务器是不可信输入源——即使 APIClient.decodeList 已丢弃未知类); +/// - timeline disabled(服务器回 `[]`,src/server.ts:589-591)→ 空态而非错误; +/// - fetch 失败 → 显式可重试错误态(retry 后可达 .loaded); +/// - digest「展开」入口的装配缝:`forSession` 工厂(sessionId 未知 → 不出 +/// sheet;已知 → 原样传给 events source)。 +@MainActor +@Suite("TimelineSheet") +struct TimelineSheetTests { + // MARK: - Fixtures + + private static func event( + at: Int, cls: String = "tool", label: String = "ran Bash" + ) -> TimelineEvent { + TimelineEvent(at: at, class: cls, toolName: nil, label: label) + } + + private struct FetchError: Error {} + + /// 记录 source 收到的 sessionId(@Sendable 闭包内可变状态 → actor)。 + private actor SourceRecorder { + private(set) var ids: [UUID] = [] + func record(_ id: UUID) { ids = ids + [id] } + } + + // MARK: - Phase 状态机 + + @Test("初始 phase = .loading(load 前不渲染内容)") + func initialPhaseIsLoading() { + let vm = TimelineViewModel(fetch: { [] }) + + #expect(vm.phase == .loading) + } + + @Test("load 成功:服务器 oldest-first → 展示 newest-first(镜像 web reverse)") + func loadReversesToNewestFirst() async { + let oldestFirst = [ + Self.event(at: 1, label: "ran Bash"), + Self.event(at: 2, cls: "waiting", label: "waiting for approval"), + Self.event(at: 3, cls: "done", label: "finished"), + ] + let vm = TimelineViewModel(fetch: { oldestFirst }) + + await vm.load() + + #expect(vm.phase == .loaded(Array(oldestFirst.reversed()))) + } + + @Test("超过 maxEvents:镜像 web slice(0,50) 再 reverse——先截前 50 条再反转") + func loadCapsThenReversesLikeWeb() async throws { + let sixty = (1...60).map { Self.event(at: $0) } + let vm = TimelineViewModel(fetch: { sixty }) + + await vm.load() + + guard case .loaded(let shown) = vm.phase else { + Issue.record("期望 .loaded,实际 \(vm.phase)") + return + } + #expect(shown.count == TimelineViewModel.maxEvents) + #expect(shown.first?.at == 50) // slice(0,50) 的最新一条 + #expect(shown.last?.at == 1) // 反转后最旧的在末尾 + } + + @Test("maxEvents 钉住 web parity 值 50(public/timeline.ts:20)") + func maxEventsMirrorsWebDefault() { + #expect(TimelineViewModel.maxEvents == 50) + } + + @Test("服务器回空数组(timeline disabled)→ .empty,绝不是 .failed") + func emptyArrayIsEmptyStateNotError() async { + let vm = TimelineViewModel(fetch: { [] }) + + await vm.load() + + #expect(vm.phase == .empty) + } + + @Test("fetch 抛错 → .failed(显式可重试错误态)") + func fetchFailureBecomesFailedPhase() async { + let vm = TimelineViewModel(fetch: { throw FetchError() }) + + await vm.load() + + #expect(vm.phase == .failed) + } + + @Test("重试:失败后再次 load 成功 → .loaded(错误态可恢复)") + func retryAfterFailureRecovers() async { + let flag = SourceRecorder() // 复用 actor 当计数器:首轮抛错,次轮成功 + let events = [Self.event(at: 7)] + let vm = TimelineViewModel(fetch: { + if await flag.ids.isEmpty { + await flag.record(UUID()) + throw FetchError() + } + return events + }) + + await vm.load() + #expect(vm.phase == .failed) + + await vm.load() // TimelineSheet 的「重试」按钮走的就是这条路径 + + #expect(vm.phase == .loaded(events)) + } + + // MARK: - digest「展开」入口的装配缝(forSession 工厂) + + @Test("forSession(nil):会话尚未 adopted → 不构造 VM(无 sheet 可出)") + func forSessionWithoutIdReturnsNil() { + let vm = TimelineViewModel.forSession(nil, source: { _ in [] }) + + #expect(vm == nil) + } + + @Test("forSession(id):load 把 sessionId 原样传给 events source") + func forSessionPassesSessionIdThrough() async throws { + let expected = UUID() + let recorder = SourceRecorder() + let events = [Self.event(at: 42)] + let vm = try #require(TimelineViewModel.forSession(expected, source: { id in + await recorder.record(id) + return events + })) + + await vm.load() + + #expect(await recorder.ids == [expected]) + #expect(vm.phase == .loaded(events)) + } + + // MARK: - class → 图标 / 颜色映射 + + @Test("图标逐字镜像 web timelineIcon(public/timeline.ts:88-96)", arguments: [ + ("tool", "🔧"), + ("waiting", "⏳"), + ("done", "✓"), + ("stuck", "⚠"), + ("user", "💬"), + ]) + func glyphMirrorsWebTimelineIcon(cls: String, expected: String) { + #expect(TimelineClassStyle.glyph(for: cls) == expected) + } + + @Test("未知 class → fallback 图标(服务器不可信,映射必须全函数)") + func unknownClassFallsBackToNeutralGlyph() { + #expect(TimelineClassStyle.glyph(for: "future-class") == TimelineClassStyle.fallbackGlyph) + #expect(TimelineClassStyle.glyph(for: "") == TimelineClassStyle.fallbackGlyph) + } + + @Test("颜色语义映射:对齐 SessionListScreen 状态色约定;未知 → secondary") + func colorMappingIsSemantic() { + #expect(TimelineClassStyle.color(for: "waiting") == .orange) // 列表 waiting 同色 + #expect(TimelineClassStyle.color(for: "stuck") == .red) // 列表 stuck 同色 + #expect(TimelineClassStyle.color(for: "done") == .green) + #expect(TimelineClassStyle.color(for: "tool") == .blue) + #expect(TimelineClassStyle.color(for: "user") == .purple) + #expect(TimelineClassStyle.color(for: "future-class") == .secondary) + } + + // MARK: - 行时间格式(镜像 web formatHHMM:24h 墙钟 HH:mm) + + @Test("timeLabel:24h HH:mm,固定时区下确定性输出") + func timeLabelIs24HourWallClock() throws { + let utc = try #require(TimeZone(identifier: "UTC")) + let onePM = (13 * 3_600 + 5 * 60) * 1_000 // 1970-01-01 13:05 UTC + + #expect(TimelineRowFormat.timeLabel(atMs: 0, timeZone: utc) == "00:00") + #expect(TimelineRowFormat.timeLabel(atMs: onePM, timeZone: utc) == "13:05") + } + + // MARK: - 用户可见文案(中文具名常量,空态 ≠ 错误态) + + @Test("空态与错误态文案存在、非空、且互不相同") + func emptyAndErrorCopyAreDistinct() { + #expect(!TimelineCopy.title.isEmpty) + #expect(!TimelineCopy.emptyTitle.isEmpty) + #expect(!TimelineCopy.loadFailed.isEmpty) + #expect(!TimelineCopy.retry.isEmpty) + #expect(TimelineCopy.emptyTitle != TimelineCopy.loadFailed) + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Models.swift b/ios/Packages/APIClient/Sources/APIClient/Models.swift index f4f8553..d21b3f9 100644 --- a/ios/Packages/APIClient/Sources/APIClient/Models.swift +++ b/ios/Packages/APIClient/Sources/APIClient/Models.swift @@ -28,6 +28,10 @@ public struct LiveSessionInfo: Sendable, Equatable { public let rows: Int /// Latest statusLine telemetry, if any (B2). public let telemetry: StatusTelemetry? + /// Server ms timestamp of the last PTY output (== createdAt until first + /// output). OPTIONAL additive field (T-iOS-37, src/types.ts:259-261) — + /// pre-P1 servers omit it; nil means "no unread data source" (T-iOS-23). + public let lastOutputAt: Int? public init( id: UUID, @@ -38,7 +42,8 @@ public struct LiveSessionInfo: Sendable, Equatable { cwd: String?, cols: Int, rows: Int, - telemetry: StatusTelemetry? + telemetry: StatusTelemetry?, + lastOutputAt: Int? = nil ) { self.id = id self.createdAt = createdAt @@ -49,12 +54,14 @@ public struct LiveSessionInfo: Sendable, Equatable { self.cols = cols self.rows = rows self.telemetry = telemetry + self.lastOutputAt = lastOutputAt } } extension LiveSessionInfo: Decodable { private enum CodingKeys: String, CodingKey { case id, createdAt, clientCount, status, exited, cwd, cols, rows, telemetry + case lastOutputAt } public init(from decoder: any Decoder) throws { @@ -71,6 +78,7 @@ extension LiveSessionInfo: Decodable { // Optional/nullable fields: wrong type degrades to nil (tolerant). cwd = try? container.decode(String.self, forKey: .cwd) telemetry = try? container.decode(StatusTelemetry.self, forKey: .telemetry) + lastOutputAt = try? container.decode(Int.self, forKey: .lastOutputAt) } /// Decode a `/live-sessions` response body. diff --git a/ios/Packages/APIClient/Tests/APIClientTests/ModelDecodingTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/ModelDecodingTests.swift index 1a1866f..f6203dd 100644 --- a/ios/Packages/APIClient/Tests/APIClientTests/ModelDecodingTests.swift +++ b/ios/Packages/APIClient/Tests/APIClientTests/ModelDecodingTests.swift @@ -107,6 +107,28 @@ struct ModelDecodingTests { #expect(session.rows == 24) } + @Test("LiveSessionInfo.lastOutputAt 可选字段(T-iOS-37/23):在场→解出;缺席(旧服务器)→nil;坏类型→nil 不丢条目") + func lastOutputAtDecodesAsOptional() async throws { + // Arrange: 在场 / 缺席 / 坏类型(不可信服务器)三个条目。 + let present = Self.noTelemetrySessionJSON + .replacingOccurrences(of: #""createdAt":1720000000000"#, + with: #""createdAt":1720000000000,"lastOutputAt":1720000007000"#) + let wrongType = Self.fullSessionJSON + .replacingOccurrences(of: #""createdAt":1720000000000"#, + with: #""createdAt":1720000000000,"lastOutputAt":"soon""#) + + // Act + let sessions = try await fetchSessions( + body: "[\(present),\(Self.noTelemetrySessionJSON),\(wrongType)]" + ) + + // Assert + #expect(sessions.count == 3) + #expect(sessions[0].lastOutputAt == 1_720_000_007_000) + #expect(sessions[1].lastOutputAt == nil) + #expect(sessions[2].lastOutputAt == nil) + } + @Test("未知 status 字符串 → .unknown(不丢弃整个会话条目)") func unknownStatusStringMapsToUnknownWithoutDroppingEntry() async throws { // Arrange diff --git a/ios/Packages/SessionCore/Sources/SessionCore/TitleSanitizer.swift b/ios/Packages/SessionCore/Sources/SessionCore/TitleSanitizer.swift new file mode 100644 index 0000000..a4a79ae --- /dev/null +++ b/ios/Packages/SessionCore/Sources/SessionCore/TitleSanitizer.swift @@ -0,0 +1,45 @@ +import WireProtocol + +/// T-iOS-23 · OSC-title sanitiser. Terminal titles (OSC 0/2, surfaced by +/// SwiftTerm's `setTerminalTitle` delegate) are HOST/ATTACKER-CONTROLLED input +/// — they must pass through here before any list/registry/UI use (plan §7). +/// +/// Rules, in order: +/// 1. Strip ALL Unicode control characters (C0 + C1 + DEL). SwiftTerm's OSC +/// parsing should already exclude C0, but that is NOT assumed — escape +/// injection gets zero tolerance at this boundary. +/// 2. Strip the spoofing vectors OSC parsing does NOT touch: zero-width and +/// bidirectional formatting characters — U+200B–200F (zero-width space / +/// non-joiner / joiner, LRM/RLM), U+202A–202E (bidi embeddings + the +/// classic RLO override), U+2066–2069 (bidi isolates), plus U+FEFF +/// (zero-width no-break space / BOM). Note: stripping U+200D (ZWJ) splits +/// multi-person emoji into their parts — safety over ligature aesthetics. +/// 3. Trim leading/trailing whitespace (mirrors web `title.trim() || null`, +/// public/tabs.ts:626 — the caller treats "" as "no title"). +/// 4. Truncate to `Tunables.titleMaxLength` CHARACTERS (grapheme clusters — +/// an emoji flood caps at 256 visible glyphs and no glyph is ever split). +/// +/// Pure + idempotent: sanitize(sanitize(x)) == sanitize(x). +public enum TitleSanitizer { + /// Zero-width / bidi scalar ranges stripped on top of the control set. + private static let strippedScalarRanges: [ClosedRange] = [ + 0x200B...0x200F, // zero-width space/non-joiner/joiner, LRM, RLM + 0x202A...0x202E, // bidi embeddings, pops and overrides (incl. RLO) + 0x2066...0x2069, // bidi isolates (LRI/RLI/FSI/PDI) + 0xFEFF...0xFEFF, // zero-width no-break space / BOM + ] + + public static func sanitize(_ raw: String) -> String { + let kept = raw.unicodeScalars.filter { !isDisallowed($0) } + let trimmed = String(String.UnicodeScalarView(kept)) + .trimmingCharacters(in: .whitespacesAndNewlines) + return String(trimmed.prefix(Tunables.titleMaxLength)) + } + + private static func isDisallowed(_ scalar: Unicode.Scalar) -> Bool { + // generalCategory .control covers C0 (U+0000–001F), DEL (U+007F) + // and C1 (U+0080–009F) in one authoritative check. + if scalar.properties.generalCategory == .control { return true } + return strippedScalarRanges.contains { $0.contains(scalar.value) } + } +} diff --git a/ios/Packages/SessionCore/Sources/SessionCore/UnreadLedger.swift b/ios/Packages/SessionCore/Sources/SessionCore/UnreadLedger.swift new file mode 100644 index 0000000..89c3855 --- /dev/null +++ b/ios/Packages/SessionCore/Sources/SessionCore/UnreadLedger.swift @@ -0,0 +1,66 @@ +import Foundation + +/// T-iOS-23 · Pure unread-watermark bookkeeping for the session switcher. +/// +/// A session shows an unread dot iff the server's `lastOutputAt` snapshot +/// (`GET /live-sessions`, T-iOS-37 optional field) is STRICTLY newer than the +/// local last-seen watermark — mirroring the web tab dot (public/tabs.ts +/// `hasActivity`: inactive tab got output since last viewed; viewing clears it). +/// +/// Semantics (documented decisions): +/// - **Missing watermark = 0**: a session never seen on this device has unseen +/// output by definition (`lastOutputAt >= createdAt > 0` server-side), so it +/// lights up until first viewed. +/// - **`lastOutputAt` nil / non-positive → never unread**: nil means a pre-P1 +/// server that does not serialize the field; non-positive is malformed +/// untrusted input — no data, no dot (plan §4: never trust, never guess). +/// - **Monotonic**: recording an EARLIER time never lowers an existing +/// watermark (late/out-of-order callbacks cannot resurrect a dot). +/// - **Capped**: at most `maxEntries` watermarks, oldest-seen dropped first — +/// the App persists this dictionary (UserDefaults) and sessions are +/// ephemeral, so unbounded growth is a slow leak. +/// +/// Persistence-agnostic pure struct (plan §7): the App layer owns loading / +/// saving `watermarks` (UserDefaults); this type never does I/O. +public struct UnreadLedger: Sendable, Equatable { + /// Upper bound on persisted watermarks (oldest dropped beyond it). + public static let maxEntries = 512 + + /// sessionId → last-seen instant (ms since epoch, same clock as the + /// server's `lastOutputAt`). + public let watermarks: [UUID: Int] + + public init(watermarks: [UUID: Int] = [:]) { + self.watermarks = Self.capped(watermarks) + } + + /// New ledger with `sessionId` marked seen at `ms` (monotonic max — an + /// earlier timestamp never lowers the stored watermark). The receiver is + /// untouched (immutable style). + public func record(seen sessionId: UUID, at ms: Int) -> UnreadLedger { + UnreadLedger( + watermarks: watermarks.merging([sessionId: ms]) { existing, new in + max(existing, new) + } + ) + } + + /// Unread test: strictly newer server output than the local watermark. + /// nil / non-positive `lastOutputAt` is never unread (see type doc). + public func isUnread(sessionId: UUID, lastOutputAt: Int?) -> Bool { + guard let lastOutputAt, lastOutputAt > 0 else { return false } + return lastOutputAt > (watermarks[sessionId] ?? 0) + } + + /// Keep the `maxEntries` NEWEST watermarks (deterministic tie-break by + /// uuid string so equal timestamps cannot flap across runs). + private static func capped(_ watermarks: [UUID: Int]) -> [UUID: Int] { + guard watermarks.count > maxEntries else { return watermarks } + let newestFirst = watermarks.sorted { lhs, rhs in + lhs.value != rhs.value + ? lhs.value > rhs.value + : lhs.key.uuidString < rhs.key.uuidString + } + return Dictionary(uniqueKeysWithValues: Array(newestFirst.prefix(maxEntries))) + } +} diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/TitleSanitizerTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/TitleSanitizerTests.swift new file mode 100644 index 0000000..d0393b3 --- /dev/null +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/TitleSanitizerTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +import WireProtocol +@testable import SessionCore + +/// T-iOS-23 · TitleSanitizer — OSC 标题是**主机/攻击者可控输入**(plan §7): +/// 入列表前必须净化。规则: +/// - 剥 C0/C1/DEL 控制字符(OSC 解析理应已排除 C0,但不假设 —— 一律再滤); +/// - 剥 bidi 覆写/嵌入/隔离与零宽字符(U+200B–200F、U+202A–202E、U+2066–2069 +/// —— 真正的仿冒向量;另含 U+FEFF 零宽不换行空格); +/// - 截断到 `Tunables.titleMaxLength`(按 Character/字素簇计,emoji 不被劈开); +/// - 首尾空白修剪(镜像 web `title.trim() || null`,public/tabs.ts:626)。 +@Suite("TitleSanitizer") +struct TitleSanitizerTests { + // MARK: - 正常路径 + + @Test("普通标题原样通过(含中文与合法标点)") + func normalTitlePassesThrough() { + #expect(TitleSanitizer.sanitize("vim · 修改 server.ts") == "vim · 修改 server.ts") + } + + @Test("首尾空白/换行被修剪;全空白 → 空串(调用方视为无标题)") + func whitespaceIsTrimmed() { + #expect(TitleSanitizer.sanitize(" claude \n") == "claude") + #expect(TitleSanitizer.sanitize(" \t\n ").isEmpty) + #expect(TitleSanitizer.sanitize("").isEmpty) + } + + // MARK: - 敌意标题(任务 RED 清单) + + @Test("超长标题截断到 titleMaxLength 个字符") + func overlongTitleIsTruncated() { + // Arrange + let overlong = String(repeating: "A", count: Tunables.titleMaxLength * 4) + + // Act + let sanitized = TitleSanitizer.sanitize(overlong) + + // Assert + #expect(sanitized.count == Tunables.titleMaxLength) + } + + @Test("U+202E RLO 仿冒 payload:bidi 覆写被剥掉,其余字符保留") + func rloSpoofIsStripped() { + // Arrange: 经典 RLO 文件名仿冒 "gpj.exe" → 显示成 "exe.jpg"。 + let spoof = "evil\u{202E}gpj.exe" + + // Act & Assert + #expect(TitleSanitizer.sanitize(spoof) == "evilgpj.exe") + } + + @Test("零宽/双向控制全表被剥:U+200B–200F、U+202A–202E、U+2066–2069、U+FEFF") + func zeroWidthAndBidiRangesAreStripped() { + // Arrange: 每段区间取首尾端点 + 中间夹正常字符。 + let hostile = "a\u{200B}b\u{200F}c\u{202A}d\u{202E}e\u{2066}f\u{2069}g\u{FEFF}h" + + // Act & Assert + #expect(TitleSanitizer.sanitize(hostile) == "abcdefgh") + } + + @Test("C0/C1/DEL 控制字符被剥(不信任 OSC 解析已排除 —— ESC 注入零容忍)") + func controlCharactersAreStripped() { + // Arrange: ESC + CSI 序列、BEL、DEL、C1 CSI(U+009B)。 + let hostile = "ok\u{1B}[31mred\u{07}\u{7F}\u{9B}2Jend" + + // Act & Assert + #expect(TitleSanitizer.sanitize(hostile) == "ok[31mred2Jend") + } + + @Test("emoji 洪泛:按字素簇截断,不劈开 emoji、不 crash") + func emojiFloodIsCappedByGrapheme() { + // Arrange: 家庭 emoji(多 scalar 字素簇)× 4 倍上限。 + let flood = String(repeating: "🧑‍🧑‍🧒", count: Tunables.titleMaxLength * 4) + + // Act + let sanitized = TitleSanitizer.sanitize(flood) + + // Assert: 上限以内(ZWJ U+200D 在剥除区间内 —— 家庭簇会拆成单人 + // emoji,安全优先于连字美观),且每个 Character 都是合法字素。 + #expect(sanitized.count <= Tunables.titleMaxLength) + #expect(!sanitized.isEmpty) + #expect(sanitized.allSatisfy { !$0.unicodeScalars.isEmpty }) + } + + @Test("净化幂等:sanitize(sanitize(x)) == sanitize(x)") + func sanitizeIsIdempotent() { + // Arrange + let hostile = " \u{202E}evil\u{1B}]0;fake\u{07}\u{200B} 标题 " + + // Act + let once = TitleSanitizer.sanitize(hostile) + let twice = TitleSanitizer.sanitize(once) + + // Assert + #expect(once == twice) + } +} diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/UnreadLedgerTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/UnreadLedgerTests.swift new file mode 100644 index 0000000..2b8556c --- /dev/null +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/UnreadLedgerTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing +import WireProtocol +@testable import SessionCore + +/// T-iOS-23 · UnreadLedger — pure unread-watermark bookkeeping (plan §7). +/// Mirrors the web tab dot semantics (public/tabs.ts `hasActivity`, viewing +/// clears it) against the server's `lastOutputAt` snapshot (T-iOS-37 field): +/// a session is unread iff its `lastOutputAt` is strictly newer than the local +/// last-seen watermark. Persistence-agnostic — the App layer wires UserDefaults. +@Suite("UnreadLedger") +struct UnreadLedgerTests { + private let sessionId = UUID() + private let seenAt = 1_700_000_100_000 + + // MARK: - Unread判定(lastOutputAt vs 水位) + + @Test("lastOutputAt 严格大于已记录水位 → unread;等于/小于 → 已读") + func unreadIsStrictlyNewerThanWatermark() { + // Arrange + let ledger = UnreadLedger().record(seen: sessionId, at: seenAt) + + // Assert + #expect(ledger.isUnread(sessionId: sessionId, lastOutputAt: seenAt + 1)) + #expect(!ledger.isUnread(sessionId: sessionId, lastOutputAt: seenAt)) + #expect(!ledger.isUnread(sessionId: sessionId, lastOutputAt: seenAt - 1)) + } + + @Test("从未看过的会话(无水位):任何正的 lastOutputAt 都是 unread") + func neverSeenSessionIsUnread() { + // Arrange + let ledger = UnreadLedger() + + // Assert: 水位缺省按 0 —— 服务器保证 lastOutputAt ≥ createdAt > 0。 + #expect(ledger.isUnread(sessionId: sessionId, lastOutputAt: 1)) + #expect(ledger.isUnread(sessionId: sessionId, lastOutputAt: seenAt)) + } + + @Test("lastOutputAt 缺失(旧服务器)或非正值(不可信输入)→ 绝不显示 unread") + func missingOrGarbageLastOutputAtIsNeverUnread() { + // Arrange + let ledger = UnreadLedger() + + // Assert: nil = 服务器没给数据;0/负值 = 畸形数据 —— 都不点灯。 + #expect(!ledger.isUnread(sessionId: sessionId, lastOutputAt: nil)) + #expect(!ledger.isUnread(sessionId: sessionId, lastOutputAt: 0)) + #expect(!ledger.isUnread(sessionId: sessionId, lastOutputAt: -5)) + } + + // MARK: - record:不可变 + 单调 + + @Test("record 返回新副本,原 ledger 不变(不可变风格)") + func recordIsImmutable() { + // Arrange + let original = UnreadLedger() + + // Act + let recorded = original.record(seen: sessionId, at: seenAt) + + // Assert + #expect(original == UnreadLedger()) + #expect(original.watermarks[sessionId] == nil) + #expect(recorded.watermarks[sessionId] == seenAt) + } + + @Test("record 单调不回退:更早的时间不会降低已有水位") + func recordIsMonotonic() { + // Arrange + let ledger = UnreadLedger().record(seen: sessionId, at: seenAt) + + // Act: 乱序到达的更早 record(如迟到的回调)。 + let after = ledger.record(seen: sessionId, at: seenAt - 1_000) + + // Assert + #expect(after.watermarks[sessionId] == seenAt) + #expect(!after.isUnread(sessionId: sessionId, lastOutputAt: seenAt)) + } + + // MARK: - 容量上限(持久化字典不可无界增长) + + @Test("超过 maxEntries 时丢弃最旧水位,保留最新的 maxEntries 条") + func capDropsOldestWatermarks() throws { + // Arrange: 恰好灌满上限。 + var ledger = UnreadLedger() + for offset in 0..