feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
This commit is contained in:
@@ -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 复跑验收)。
|
||||
|
||||
251
ios/App/WebTerm/Components/QuickReply.swift
Normal file
251
ios/App/WebTerm/Components/QuickReply.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
200
ios/App/WebTerm/Components/QuickReplyStore.swift
Normal file
200
ios/App/WebTerm/Components/QuickReplyStore.swift
Normal file
@@ -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_<ts>_<rand>`; 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
365
ios/App/WebTerm/Components/SessionThumbnail.swift
Normal file
365
ios/App/WebTerm/Components/SessionThumbnail.swift
Normal file
@@ -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<Void, Never>] = []
|
||||
private var barriers: [(target: Int, cont: CheckedContinuation<Void, Never>)] = []
|
||||
|
||||
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<SessionThumbnailImage, Never>] = [:]
|
||||
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"
|
||||
}
|
||||
85
ios/App/WebTerm/Components/SessionThumbnailRenderer.swift
Normal file
85
ios/App/WebTerm/Components/SessionThumbnailRenderer.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
251
ios/App/WebTerm/DeepLinkRouter.swift
Normal file
251
ios/App/WebTerm/DeepLinkRouter.swift
Normal file
@@ -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=<uuid>&join=<uuid>` (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<String> = ["", "/"]
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
234
ios/App/WebTerm/DiffFetcher.swift
Normal file
234
ios/App/WebTerm/DiffFetcher.swift
Normal file
@@ -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<DiffLine>].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<DiffHunk>].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<DiffFile>].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<Wrapped: Decodable>: 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
|
||||
}
|
||||
}
|
||||
310
ios/App/WebTerm/Push/NotificationActionHandler.swift
Normal file
310
ios/App/WebTerm/Push/NotificationActionHandler.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
331
ios/App/WebTerm/Push/PushRegistrar.swift
Normal file
331
ios/App/WebTerm/Push/PushRegistrar.swift
Normal file
@@ -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<UNNotificationCategory>)
|
||||
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<UNNotificationCategory>) {
|
||||
center.setNotificationCategories(categories)
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) 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<UUID> = []
|
||||
|
||||
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"
|
||||
}
|
||||
269
ios/App/WebTerm/Screens/DiffScreen.swift
Normal file
269
ios/App/WebTerm/Screens/DiffScreen.swift
Normal file
@@ -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 = "重试"
|
||||
}
|
||||
286
ios/App/WebTerm/Screens/ProjectDetailScreen.swift
Normal file
286
ios/App/WebTerm/Screens/ProjectDetailScreen.swift
Normal file
@@ -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) 个客户端"
|
||||
}
|
||||
}
|
||||
184
ios/App/WebTerm/Screens/ProjectsScreen.swift
Normal file
184
ios/App/WebTerm/Screens/ProjectsScreen.swift
Normal file
@@ -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
|
||||
}
|
||||
@@ -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) 台设备在看"
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
166
ios/App/WebTerm/Screens/TimelineSheet.swift
Normal file
166
ios/App/WebTerm/Screens/TimelineSheet.swift
Normal file
@@ -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 = "重试"
|
||||
}
|
||||
164
ios/App/WebTerm/ViewModels/DiffViewModel.swift
Normal file
164
ios/App/WebTerm/ViewModels/DiffViewModel.swift
Normal file
@@ -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
|
||||
}
|
||||
76
ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift
Normal file
76
ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
192
ios/App/WebTerm/ViewModels/ProjectGrouping.swift
Normal file
192
ios/App/WebTerm/ViewModels/ProjectGrouping.swift
Normal file
@@ -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<String>) -> [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<String>) -> [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 组剥掉 `<key>.` 前缀(大小写不敏感),免得每张
|
||||
/// 卡都在喊 `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<String>
|
||||
) -> (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<String>
|
||||
) -> 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 }
|
||||
}
|
||||
236
ios/App/WebTerm/ViewModels/ProjectsViewModel.swift
Normal file
236
ios/App/WebTerm/ViewModels/ProjectsViewModel.swift
Normal file
@@ -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) 活跃"
|
||||
}
|
||||
}
|
||||
@@ -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<Duration>
|
||||
/// 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<Void, Never>?
|
||||
@ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
|
||||
@ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
|
||||
@ObservationIgnored private var pendingSessionIds: Set<UUID> = []
|
||||
/// 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<Duration>,
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>
|
||||
@@ -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):
|
||||
|
||||
81
ios/App/WebTerm/ViewModels/TimelineViewModel.swift
Normal file
81
ios/App/WebTerm/ViewModels/TimelineViewModel.swift
Normal file
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Bool> {
|
||||
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 = "项目"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
@ObservationIgnored private var engine: SessionEngine
|
||||
@ObservationIgnored private var fanOut: EventFanOut<SessionEvent>
|
||||
@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
|
||||
}
|
||||
|
||||
|
||||
44
ios/App/WebTerm/Wiring/UnreadWatermarkStore.swift
Normal file
44
ios/App/WebTerm/Wiring/UnreadWatermarkStore.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
338
ios/App/WebTermTests/DeepLinkRouterTests.swift
Normal file
338
ios/App/WebTermTests/DeepLinkRouterTests.swift
Normal file
@@ -0,0 +1,338 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-22 · Deep-link routing: `webterminal://open?host=<uuid>&join=<uuid>`
|
||||
/// 解析(全字段白名单,任一非法 → .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)
|
||||
}
|
||||
}
|
||||
250
ios/App/WebTermTests/DiffFetcherTests.swift
Normal file
250
ios/App/WebTermTests/DiffFetcherTests.swift
Normal file
@@ -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=<enc>&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)
|
||||
}
|
||||
}
|
||||
}
|
||||
242
ios/App/WebTermTests/DiffViewModelTests.swift
Normal file
242
ios/App/WebTermTests/DiffViewModelTests.swift
Normal file
@@ -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) == "删除")
|
||||
}
|
||||
}
|
||||
290
ios/App/WebTermTests/NewSessionInCwdTests.swift
Normal file
290
ios/App/WebTermTests/NewSessionInCwdTests.swift
Normal file
@@ -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")))
|
||||
}
|
||||
}
|
||||
329
ios/App/WebTermTests/NotificationActionHandlerTests.swift
Normal file
329
ios/App/WebTermTests/NotificationActionHandlerTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
104
ios/App/WebTermTests/NotificationActionParseTests.swift
Normal file
104
ios/App/WebTermTests/NotificationActionParseTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
118
ios/App/WebTermTests/ProjectDetailViewModelTests.swift
Normal file
118
ios/App/WebTermTests/ProjectDetailViewModelTests.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
211
ios/App/WebTermTests/ProjectGroupingTests.swift
Normal file
211
ios/App/WebTermTests/ProjectGroupingTests.swift
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
90
ios/App/WebTermTests/ProjectOpenWiringTests.swift
Normal file
90
ios/App/WebTermTests/ProjectOpenWiringTests.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
289
ios/App/WebTermTests/ProjectsViewModelTests.swift
Normal file
289
ios/App/WebTermTests/ProjectsViewModelTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
366
ios/App/WebTermTests/PushRegistrarTests.swift
Normal file
366
ios/App/WebTermTests/PushRegistrarTests.swift
Normal file
@@ -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<Bool, any Error> = .success(true)
|
||||
private(set) var requestedOptions: [UNAuthorizationOptions] = []
|
||||
private(set) var registeredCategorySets: [Set<UNNotificationCategory>] = []
|
||||
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<UNNotificationCategory>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
393
ios/App/WebTermTests/QuickReplyTests.swift
Normal file
393
ios/App/WebTermTests/QuickReplyTests.swift
Normal file
@@ -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<SessionEvent>
|
||||
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 == "管理常用语")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
|
||||
411
ios/App/WebTermTests/SessionSwitcherTests.swift
Normal file
411
ios/App/WebTermTests/SessionSwitcherTests.swift
Normal file
@@ -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 }
|
||||
}
|
||||
}
|
||||
434
ios/App/WebTermTests/SessionThumbnailTests.swift
Normal file
434
ios/App/WebTermTests/SessionThumbnailTests.swift
Normal file
@@ -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<Void, Never>] = []
|
||||
private var barriers: [(target: Int, cont: CheckedContinuation<Void, Never>)] = []
|
||||
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
|
||||
}
|
||||
197
ios/App/WebTermTests/TimelineSheetTests.swift
Normal file
197
ios/App/WebTermTests/TimelineSheetTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<UInt32>] = [
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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..<UnreadLedger.maxEntries {
|
||||
ledger = ledger.record(seen: UUID(), at: seenAt + offset)
|
||||
}
|
||||
let oldestId = try #require(ledger.watermarks.min { $0.value < $1.value }?.key)
|
||||
|
||||
// Act: 再多一条 —— 最旧的那条必须让位。
|
||||
let newcomer = UUID()
|
||||
let capped = ledger.record(seen: newcomer, at: seenAt + UnreadLedger.maxEntries)
|
||||
|
||||
// Assert
|
||||
#expect(capped.watermarks.count == UnreadLedger.maxEntries)
|
||||
#expect(capped.watermarks[newcomer] != nil)
|
||||
#expect(capped.watermarks[oldestId] == nil)
|
||||
}
|
||||
|
||||
@Test("init 从持久化载入时同样施加上限(旧存量超限不带病复活)")
|
||||
func initCapsOversizedPersistedState() {
|
||||
// Arrange: 直接构造一个超限字典(模拟旧版本存量)。
|
||||
let oversized = Dictionary(
|
||||
uniqueKeysWithValues: (0..<(UnreadLedger.maxEntries + 10)).map {
|
||||
(UUID(), seenAt + $0)
|
||||
}
|
||||
)
|
||||
|
||||
// Act
|
||||
let ledger = UnreadLedger(watermarks: oversized)
|
||||
|
||||
// Assert: 截到上限,且留下的是最新的(最小值被丢弃端)。
|
||||
#expect(ledger.watermarks.count == UnreadLedger.maxEntries)
|
||||
let keptMin = ledger.watermarks.values.min()
|
||||
#expect(keptMin == seenAt + 10)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user