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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -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 复跑验收)。

View 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
}
}

View 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)
}
}

View File

@@ -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

View 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 =
/// = nilP1
/// 5s
///
/// - **** spawn SwiftTerm
/// `SessionThumbnailRenderGate`FIFOpermit +
/// 线 `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 KiBsrc/config.ts:46
/// DEFAULT_PREVIEW_BYTESenv `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: - FIFOpermit
/// 线`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 URLSessionpreview
/// 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 nilmin 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"
}

View 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
/// ptframe 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 MetalMetal
/// `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)
}
}
}

View 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()
}
}

View 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 ownerPLAN §6 Owns
// fetcher `HTTPTransport`
// APIClient ** APIClient T-iOS-38 owner
// follow-up**//
//
// Origin plan §3.4/§5.1iff-G`/projects/diff` RO
// requireAllowedOriginsrc/server.ts:601 "no Origin guard; same
// threat model as /projects"** Origin**
// G
//
// plan §4 web
// normalizeDiffResultpublic/diff.ts:38-102
// file/hunk/line kind .contextstatus
// .modified crash
// MARK: - src/types.ts:445-479App 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
/// 404SEC-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 importT-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/portpath/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
}
}

View 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 targetService 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
/// - 403token /
// 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` SendabledidReceive nonisolated
/// MainActorUN 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: - Parsepayload
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 tokenT-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: - DecisionAllow/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 {
// 403token //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 resolutionpayload 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: - UNUserNotificationCenterDelegateUNNotificationResponse
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
/// closeopenopenDeepLinkedSession 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))
}
}

View 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
///
/// crashdevice token
/// `registerForRemoteNotifications` delegate launch/scene
/// `activate()``registeredHostIds` /
/// upsert 5/min/IP
// MARK: - SeamsUNUserNotificationCenter / 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:52T-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 hexiOS
///
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 64160 hexT-iOS-38
static func hexToken(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - PushAppDelegateapp
/// `@UIApplicationDelegateAdaptor` remote-notification
/// UIApplicationDelegate SwiftUI App
///
/// 线wiring SwiftUI
/// `WebTermApp.init` coordinator `bootstrap`
/// `didFinishLaunching` registrar/handler handler
/// UNUserNotificationCenter delegateApple 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"
}

View 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
/// TerminalScreenT-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 = "重试"
}

View 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 claudeIconpublic/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) 个客户端"
}
}

View 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 sectionflat chromenamespace/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
}

View File

@@ -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) 台设备在看"

View File

@@ -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
/// "" closeopen fresh spawncwd
/// `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) {}

View File

@@ -0,0 +1,166 @@
import SwiftUI
import WireProtocol
/// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the
/// away-digestaffordance (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 = "重试"
}

View 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
/// filestruncated
case empty(truncated: Bool)
case loaded(DiffPresentation)
case failed(Failure)
}
private(set) var phase: Phase = .loading
/// false = unstagedtrue = --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 thepath: 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-166id
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 webpublic/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
}

View 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
}
}
}

View File

@@ -0,0 +1,192 @@
import APIClient
import Foundation
/// T-iOS-26 · Projects web v0.6
/// public/projects.tsfilterProjects / 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-269running = `!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 filterProjectsname/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 }
}

View 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 blobnil =
/// 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
/// echoprefs
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) 活跃"
}
}

View File

@@ -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
)
)
}
}

View File

@@ -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):

View 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
/// digesttap), 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 digestentry 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 thepath: 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()))
}
}

View File

@@ -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
/// UIApplicationDelegateSwiftUI `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()
}
}
}
}

View File

@@ -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 closeopen 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 controllercwd
/// 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? {

View File

@@ -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()

View File

@@ -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 = "项目"
}

View File

@@ -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 digestaffordance 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 {

View File

@@ -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
/// suspendresume
@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 attachfresh spawn id
/// nil cwd attach + attach bootstrapT-iOS-26suspend
/// 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
}

View 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)
}
}

View 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>`
/// .ignoreWEBTERM_GATE push
/// payload DeepLinkHandler stash / host
/// /
@MainActor
@Suite("DeepLinkRouter")
struct DeepLinkRouterTests {
// MARK: - Fixtures
/// v4 UUIDversion nibble=4variant=8 Validation.isValidSessionId
private static let validHostId = "11111111-2222-4333-8444-555555555555"
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
/// version nibble=1Foundation UUID(uuidString:)
/// SESSION_ID_REv4 " 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 非 v4version 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 payloadWEBTERM_GATET-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 idUUID 合法但不在 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)
}
}

View 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 requireAllowedOriginsrc/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 .pathInvalid404 .projectNotFoundSEC-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无 OriginRO 端点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)
}
}
}

View 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 returnrenamed
@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 → .emptytruncated 数据位透传)", 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 = truebanner 提示位)")
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("untracked0 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/非法请求 → pathInvalid404 → 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) == "删除")
}
}

View 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
/// closeopen `attach(null, cwd)` web tabs.ts
/// `newTab()` M6 cwdcwd /live-sessions
/// server-adopted id controller spawnCwdT-iOS-26
/// fresh-spawn
/// ProjectsViewModel
/// 2. exited + exit TerminalViewModel P0 +
/// ""src/session/manager.ts:145-153exited
/// 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: - FixturesSessionSwitcherTests 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 adoptedopenTask 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()
// Assertcloseopen 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.spawnCwdbootstrap 绝不复注入")
func newInCwdFallsBackToSpawnCwdWithoutBootstrap() async throws {
// Arrange fresh spawnspawnCwd + 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-153reap
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)
// Assertexit
#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")))
}
}

View 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.handleAllow/Deny POST
/// /hook/decision403
/// 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("403token 过期/已用)→ 本地通知兜底提示进 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("无主机可配 → 不打开、不 crashApp 已被点按拉起,落在列表页即可)")
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)
}
}

View 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 → .dismissedno-op")
func parseDismissAndUnknownActions() {
#expect(NotificationActionHandler.parse(
actionIdentifier: UNNotificationDismissActionIdentifier,
userInfo: Self.gateUserInfo()
) == .dismissed)
#expect(NotificationActionHandler.parse(
actionIdentifier: "EVIL_ACTION", userInfo: Self.gateUserInfo()
) == .dismissed)
}
}

View 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("初始 .loadingload 成功 → .loadedsessions/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
}
}

View 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("filtername 与 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: - sortfavourites 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 groupProjectsother noNamespace
// namespacepublic/projects.ts:163-166 plain
#expect(groups[1].projects == [plain, solo])
}
@Test("groupnamespace 桶大小写不敏感合并,显示名取首见大小写")
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("groupnamespace 组按组内最新 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("displayLabelnamespace 组内剥前缀(大小写不敏感);哨兵组保留全名")
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])
}
}

View 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()
}
}

View 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不再打 /prefsprefs 留在内存,镜像 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("toggleFavouritePUT 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展开只删自己的 keyfavourites 与未知键原样保留")
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)
}
}

View 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: - Fakesseam 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 hexAPNs
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)
}
}

View 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
/// ChipUserDefaults suite Keychain
/// / crash
/// - waitingSessionEvent gatestatus waiting
/// pending=true gate exited/failedread-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 attached3 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 逐项镜像 webid/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("payloadappendEnter → 文本 + Enter\\r0x0D——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 浅合并 patchid 不可变,其余 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: - StoreUserDefaults 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,Aupdate B labelremove 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 = gateread-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("exitedread-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 == "管理常用语")
}
}

View File

@@ -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 }
)

View 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`
///
///
/// - VMunread //
/// - TerminalViewModeldelegate adopted id
/// - 线controller title hook VMsuspendresume 线
/// coordinator closeTerminal last-seen title
/// WS = closeopendeep-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: - TerminalViewModeldelegate + 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 接到 VMsuspend→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: hookid adopted id
#expect(forwarded.map(\.1) == ["gen0", "gen1"])
#expect(forwarded.allSatisfy { $0.0 == adopted })
controller.teardown()
}
@Test("coordinatorcloseTerminal 记 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 }
}
}

View 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
/// nilP1
///
/// - 2
/// spawn FIFOpermit 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 为 nilP1 前服务器)→ 渲染一次后命中缓存(文档化降级)")
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 FIFOpermit 按排队顺序转移")
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 不匹配 → placeholderrenderer 不调")
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 → placeholderrenderer 不调")
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("几何出服务器 resizeRange0 / 1001→ placeholderrenderer 不调", 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 → 逐出 BA/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*1024src/config.ts:46env
//
#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
}

View 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)`
/// reversenewest-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 = .loadingload 前不渲染内容)")
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 值 50public/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: - digestforSession
@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 timelineIconpublic/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 formatHHMM24h HH:mm
@Test("timeLabel24h 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)
}
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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+200B200F (zero-width space /
/// non-joiner / joiner, LRM/RLM), U+202A202E (bidi embeddings + the
/// classic RLO override), U+20662069 (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+0000001F), DEL (U+007F)
// and C1 (U+0080009F) in one authoritative check.
if scalar.properties.generalCategory == .control { return true }
return strippedScalarRanges.contains { $0.contains(scalar.value) }
}
}

View File

@@ -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)))
}
}

View File

@@ -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+200B200FU+202A202EU+20662069
/// 仿 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 仿冒 payloadbidi 覆写被剥掉,其余字符保留")
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+200B200F、U+202A202E、U+20662069、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 BELDELC1 CSIU+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)
}
}

View File

@@ -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: - UnreadlastOutputAt 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)
}
}