feat(ios): W3 UI layer + integration CI + ntfy docs

T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM)
T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling
T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field)
T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics
T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed)
T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites)
Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
Yaojia Wang
2026-07-05 00:13:14 +02:00
parent 5c8c87fb7a
commit 5098643355
33 changed files with 5946 additions and 616 deletions

View File

@@ -0,0 +1,111 @@
import SessionCore
import SwiftUI
import WireProtocol
/// T-iOS-14 · "What happened while I was away" banner rendered above the
/// terminal after a reconnect. Render-only: `GateViewModel` owns the state
/// an ALL-ZERO digest never reaches this view (the VM keeps `digest` nil),
/// the collapsed row auto-fades after `Tunables.digestFadeDelay`, and manual
/// expansion (which cancels the fade) reveals the recent entries.
struct AwayDigestView: View {
let digest: AwayDigest
let isExpanded: Bool
let onExpand: () -> Void
let onDismiss: () -> Void
private enum Metrics {
static let spacing: CGFloat = 8
static let rowSpacing: CGFloat = 4
static let horizontalPadding: CGFloat = 14
static let verticalPadding: CGFloat = 10
static let cornerRadius: CGFloat = 12
/// Recent entries shown when expanded (newest kept the engine's
/// digest is uncapped, the banner must not be).
static let maxRecentRows = 12
}
private enum Copy {
static let prefix = "离开期间:"
static let separator = " · "
static let expandTitle = "展开明细"
static let dismissTitle = "关闭摘要"
}
/// Summary line: non-zero parts only, joined web-statusline style. A
/// non-empty digest whose counted signals are all zero (e.g. only `user`
/// events) falls back to a plain activity count so the row is never blank.
static func summary(for digest: AwayDigest) -> String {
let parts = [
digest.toolRuns > 0 ? "工具调用 \(digest.toolRuns)" : nil,
digest.waitingCount > 0 ? "等待审批 \(digest.waitingCount)" : nil,
digest.sawDone ? "已完成" : nil,
digest.sawStuck ? "曾卡住" : nil,
].compactMap { $0 }
guard !parts.isEmpty else {
return "\(Copy.prefix)活动 \(digest.recent.count)"
}
return Copy.prefix + parts.joined(separator: Copy.separator)
}
var body: some View {
VStack(alignment: .leading, spacing: Metrics.spacing) {
summaryRow
if isExpanded {
recentRows
}
}
.padding(.horizontal, Metrics.horizontalPadding)
.padding(.vertical, Metrics.verticalPadding)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: Metrics.cornerRadius))
.accessibilityElement(children: .contain)
}
private var summaryRow: some View {
HStack(spacing: Metrics.spacing) {
Image(systemName: "clock.arrow.circlepath")
.foregroundStyle(.secondary)
Text(Self.summary(for: digest))
.font(.footnote.weight(.medium))
.lineLimit(1)
Spacer(minLength: Metrics.spacing)
if !isExpanded {
Button(Copy.expandTitle, systemImage: "chevron.down", action: onExpand)
.labelStyle(.iconOnly)
}
Button(Copy.dismissTitle, systemImage: "xmark", action: onDismiss)
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.font(.footnote)
}
/// Newest `maxRecentRows` entries, oldestnewest. Server text (`label`) is
/// untrusted display input rendered as plain Text only.
private var recentRows: some View {
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
ForEach(
Array(digest.recent.suffix(Metrics.maxRecentRows).enumerated()),
id: \.offset
) { _, event in
recentRow(event)
}
}
}
private func recentRow(_ event: TimelineEvent) -> some View {
HStack(spacing: Metrics.spacing) {
Text(Self.time(of: event))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
Text(event.label)
.font(.caption)
.lineLimit(1)
}
}
private static func time(of event: TimelineEvent) -> String {
let millisecondsPerSecond = 1_000.0
let date = Date(timeIntervalSince1970: Double(event.at) / millisecondsPerSecond)
return date.formatted(date: .omitted, time: .shortened)
}
}

View File

@@ -0,0 +1,86 @@
import SessionCore
import SwiftUI
/// Copy + wire pairing for one gate button the SINGLE label source for both
/// gate surfaces (`GateBanner`, `PlanGateSheet`). Labels mirror
/// public/tabs.ts:334-350 byte-for-byte; the affordanceClientMessage mapping
/// itself is frozen in SessionCore (`GateState.Affordance.clientMessage`).
struct GateChoiceSpec: Equatable {
let label: String
let affordance: GateState.Affordance
/// Ordered button set for `gate` (tool two, plan three), driven by the
/// frozen `GateState.affordances` list.
static func specs(for gate: GateState) -> [GateChoiceSpec] {
gate.affordances.map { GateChoiceSpec(label: label(for: $0), affordance: $0) }
}
static func label(for affordance: GateState.Affordance) -> String {
switch affordance {
case .approveAuto: return "✓ Approve + Auto"
case .approveReview: return "✓ Approve + Review"
case .keepPlanning: return "✎ Keep Planning"
case .approve: return "✓ Approve"
case .reject: return "✗ Reject"
}
}
}
/// T-iOS-14 · Two-button banner for an ordinary TOOL gate (plan gates get the
/// three-way `PlanGateSheet`). Render-only: state lives in `GateViewModel`.
/// Every tap reports the affordance PLUS the epoch of the gate THIS banner
/// rendered the VM drops stale taps (first-line guard); wiring into
/// TerminalScreen is T-iOS-15.
struct GateBanner: View {
let gate: GateState
let onDecide: (GateState.Affordance, Int) -> Void
private enum Metrics {
static let spacing: CGFloat = 8
static let horizontalPadding: CGFloat = 14
static let verticalPadding: CGFloat = 10
static let cornerRadius: CGFloat = 12
static let messageLineLimit = 2
}
/// Banner headline, mirror of public/tabs.ts:329:
/// `Claude wants to use ${pendingTool ?? 'a tool'}`. `detail` is
/// server-supplied display text (untrusted) rendered as plain Text only.
static func message(for gate: GateState) -> String {
"Claude wants to use \(gate.detail ?? "a tool")"
}
var body: some View {
VStack(alignment: .leading, spacing: Metrics.spacing) {
Text(Self.message(for: gate))
.font(.footnote.weight(.semibold))
.lineLimit(Metrics.messageLineLimit)
HStack(spacing: Metrics.spacing) {
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
decisionButton(spec)
}
}
}
.padding(.horizontal, Metrics.horizontalPadding)
.padding(.vertical, Metrics.verticalPadding)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: Metrics.cornerRadius))
.accessibilityElement(children: .contain)
}
private func decisionButton(_ spec: GateChoiceSpec) -> some View {
Button(spec.label) {
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(tint(for: spec.affordance))
}
private func tint(for affordance: GateState.Affordance) -> Color {
switch affordance {
case .approve, .approveAuto, .approveReview: return .green
case .reject: return .red
case .keepPlanning: return .indigo
}
}
}

View File

@@ -0,0 +1,227 @@
import SessionCore
import UIKit
/// T-iOS-11 · Mobile key bar + hardware key mapping, mirroring
/// `public/keybar.ts` the Claude Code keys a phone keyboard can't produce,
/// most-used first. EVERY labelbytes lookup resolves through
/// `KeyByteMap` (SessionCore) no byte literal lives in this file.
///
/// The bar is installed as the terminal's `inputAccessoryView` and sends via
/// the ViewModel/engine DIRECTLY (bypassing SwiftTerm's text path so a tap
/// never pops or fights the soft keyboard same reason the web bar bypasses
/// xterm and calls `ws.send`).
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
/// One key-bar button: glyph, short function caption (shown under the glyph,
/// self-documenting on touch) and the full title for accessibility.
struct KeyBarButtonSpec: Equatable, Sendable {
let key: KeyByteMap.Key
let label: String
let caption: String
let title: String
let isPrimary: Bool
init(key: KeyByteMap.Key, label: String, caption: String, title: String,
isPrimary: Bool = false) {
self.key = key
self.label = label
self.caption = caption
self.title = title
self.isPrimary = isPrimary
}
}
/// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS`
/// (most-used Claude Code keys first). The 🎤 voice button is P2 (T-iOS-31).
enum KeyBarLayout {
static let buttons: [KeyBarButtonSpec] = [
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
KeyBarButtonSpec(key: .escEsc, label: "Esc²", caption: "回溯",
title: "Esc Esc — rewind / clear draft"),
KeyBarButtonSpec(key: .shiftTab, label: "⇧Tab", caption: "模式",
title: "Shift+Tab — cycle plan / auto-accept mode"),
KeyBarButtonSpec(key: .arrowUp, label: "", caption: "上一个",
title: "Up — previous option / history"),
KeyBarButtonSpec(key: .arrowDown, label: "", caption: "下一个",
title: "Down — next option / history"),
KeyBarButtonSpec(key: .enter, label: "", caption: "确认",
title: "Enter — confirm"),
KeyBarButtonSpec(key: .ctrlC, label: "^C", caption: "取消",
title: "Ctrl+C — cancel / quit"),
KeyBarButtonSpec(key: .ctrlR, label: "^R", caption: "搜历史",
title: "Ctrl+R — reverse-search command history"),
KeyBarButtonSpec(key: .ctrlO, label: "^O", caption: "详情",
title: "Ctrl+O — expand transcript / tool detail"),
KeyBarButtonSpec(key: .ctrlL, label: "^L", caption: "重绘",
title: "Ctrl+L — redraw screen"),
KeyBarButtonSpec(key: .ctrlT, label: "^T", caption: "任务",
title: "Ctrl+T — toggle task list"),
KeyBarButtonSpec(key: .ctrlB, label: "^B", caption: "后台",
title: "Ctrl+B — background running task"),
KeyBarButtonSpec(key: .ctrlD, label: "^D", caption: "退出",
title: "Ctrl+D — exit session (EOF)"),
KeyBarButtonSpec(key: .tab, label: "Tab", caption: "补全",
title: "Tab — complete / toggle"),
KeyBarButtonSpec(key: .arrowLeft, label: "", caption: "左移",
title: "Left — move cursor left"),
KeyBarButtonSpec(key: .arrowRight, label: "", caption: "右移",
title: "Right — move cursor right"),
KeyBarButtonSpec(key: .slash, label: "/", caption: "命令",
title: "Slash — command launcher"),
]
}
private enum KeyBarMetrics {
static let barHeight: CGFloat = 52
static let buttonSpacing: CGFloat = 6
static let contentInset: CGFloat = 8
static let buttonInsets = NSDirectionalEdgeInsets(
top: 4, leading: 9, bottom: 4, trailing: 9
)
}
// MARK: - KeyBarView (inputAccessoryView)
/// Horizontally scrolling key bar, installed as the terminal's
/// `inputAccessoryView`. Taps call `onKey` the screen routes them to
/// `TerminalViewModel.send(key:)`, which resolves bytes via `KeyByteMap`.
final class KeyBarView: UIInputView {
/// Tap outlet. `@MainActor`-typed so the handler can drive the ViewModel.
var onKey: (@MainActor (KeyByteMap.Key) -> Void)?
/// Built buttons in layout order (test-visible).
private(set) var keyButtons: [UIButton] = []
init() {
super.init(
frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight),
inputViewStyle: .keyboard
)
allowsSelfSizing = true
buildBar()
}
@available(*, unavailable, message: "KeyBarView is code-built only")
required init?(coder: NSCoder) {
return nil
}
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: KeyBarMetrics.barHeight)
}
private func buildBar() {
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = KeyBarMetrics.buttonSpacing
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
for spec in KeyBarLayout.buttons {
let button = makeButton(for: spec)
keyButtons = keyButtons + [button]
stack.addArrangedSubview(button)
}
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
scroll.translatesAutoresizingMaskIntoConstraints = false
scroll.addSubview(stack)
addSubview(scroll)
NSLayoutConstraint.activate([
scroll.leadingAnchor.constraint(equalTo: leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: trailingAnchor),
scroll.topAnchor.constraint(equalTo: topAnchor),
scroll.bottomAnchor.constraint(equalTo: bottomAnchor),
stack.leadingAnchor.constraint(
equalTo: scroll.contentLayoutGuide.leadingAnchor,
constant: KeyBarMetrics.contentInset
),
stack.trailingAnchor.constraint(
equalTo: scroll.contentLayoutGuide.trailingAnchor,
constant: -KeyBarMetrics.contentInset
),
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
])
}
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .plain()
var label = AttributedString(spec.label)
label.font = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
weight: .semibold
)
config.attributedTitle = label
var caption = AttributedString(spec.caption)
caption.font = UIFont.preferredFont(forTextStyle: .caption2)
caption.foregroundColor = .secondaryLabel
config.attributedSubtitle = caption
config.titleAlignment = .center
config.contentInsets = KeyBarMetrics.buttonInsets
let button = UIButton(configuration: config)
button.accessibilityLabel = spec.title
button.addAction(
UIAction { [weak self] _ in self?.onKey?(spec.key) },
for: .touchUpInside
)
return button
}
}
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
/// Hardware-keyboard chords routed through the SAME `KeyByteMap` mapping
/// (plan §7 T-iOS-11). Scope decision (documented deviation): only Esc,
/// Shift+Tab and the Ctrl chords are registered
/// - arrows stay SwiftTerm-native: a fixed CSI override would break
/// application-cursor-keys mode (DECCKM: vim/htop expect `ESC O A`);
/// - Enter/Tab/"/" are ordinary typing SwiftTerm already delivers;
/// - Esc·Esc is simply two Esc presses.
/// UIKeyCommand interception replaces (never duplicates) the responder's own
/// press handling, so each chord is sent exactly once.
enum HardwareKeyCommands {
struct Chord: Equatable {
let key: KeyByteMap.Key
let input: String
let modifiers: UIKeyModifierFlags
}
static let chords: [Chord] = [
Chord(key: .esc, input: UIKeyCommand.inputEscape, modifiers: []),
Chord(key: .shiftTab, input: "\t", modifiers: .shift),
Chord(key: .ctrlC, input: "c", modifiers: .control),
Chord(key: .ctrlR, input: "r", modifiers: .control),
Chord(key: .ctrlO, input: "o", modifiers: .control),
Chord(key: .ctrlL, input: "l", modifiers: .control),
Chord(key: .ctrlT, input: "t", modifiers: .control),
Chord(key: .ctrlB, input: "b", modifiers: .control),
Chord(key: .ctrlD, input: "d", modifiers: .control),
]
/// One prioritized `UIKeyCommand` per chord, all firing `action`.
/// (`UIKeyCommand` is `@MainActor` in the SDK, hence the isolation.)
@MainActor
static func build(action: Selector) -> [UIKeyCommand] {
chords.map { chord in
let command = UIKeyCommand(
action: action, input: chord.input, modifierFlags: chord.modifiers
)
command.wantsPriorityOverSystemBehavior = true
return command
}
}
/// Reverse lookup for the command handler: which chord fired.
@MainActor
static func key(matching command: UIKeyCommand) -> KeyByteMap.Key? {
chords.first {
$0.input == command.input && $0.modifiers == command.modifierFlags
}?.key
}
}

View File

@@ -0,0 +1,73 @@
import SessionCore
import SwiftUI
/// T-iOS-14 · Three-way sheet for a PLAN gate (B4): Approve+Auto /
/// Approve+Review / Keep Planning mapping mirrors public/tabs.ts:345-347
/// exactly via `GateState.Affordance.clientMessage` (acceptEdits / default /
/// reject; never raw `auto`, and NO allowAutoMode gating plan §7 T-iOS-14).
///
/// Render-only: state lives in `GateViewModel`; every tap reports the
/// affordance PLUS the epoch of the gate THIS sheet rendered (stale-tap
/// first-line guard). Presentation (`.sheet`) wiring is T-iOS-15.
struct PlanGateSheet: View {
/// Mirror of public/tabs.ts:326 plan-gate headline.
static let title = "Claude finished planning — how should it proceed?"
let gate: GateState
let onDecide: (GateState.Affordance, Int) -> Void
private enum Metrics {
static let spacing: CGFloat = 12
static let padding: CGFloat = 20
static let captionSpacing: CGFloat = 2
}
var body: some View {
VStack(alignment: .leading, spacing: Metrics.spacing) {
Text(Self.title)
.font(.headline)
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
choiceButton(spec)
}
}
.padding(Metrics.padding)
.presentationDetents([.medium])
.accessibilityElement(children: .contain)
}
private func choiceButton(_ spec: GateChoiceSpec) -> some View {
Button {
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
} label: {
VStack(alignment: .leading, spacing: Metrics.captionSpacing) {
Text(spec.label)
.font(.body.weight(.medium))
Text(Self.caption(for: spec.affordance))
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.bordered)
.tint(tint(for: spec.affordance))
}
/// Secondary line under each choice: what the wire mode actually does.
static func caption(for affordance: GateState.Affordance) -> String {
switch affordance {
case .approveAuto: return "执行计划编辑自动接受acceptEdits"
case .approveReview: return "执行计划每次编辑需确认default"
case .keepPlanning: return "继续规划,不执行"
case .approve: return "允许本次工具调用"
case .reject: return "拒绝本次工具调用"
}
}
private func tint(for affordance: GateState.Affordance) -> Color {
switch affordance {
case .approve, .approveAuto, .approveReview: return .green
case .reject: return .red
case .keepPlanning: return .indigo
}
}
}

View File

@@ -0,0 +1,96 @@
import SwiftUI
import WireProtocol
/// T-iOS-11 · Connection-state banner over the terminal (plan §1: the terminal
/// must NEVER "look connected but dead" every non-live state is explicit).
///
/// Render input is the `Model` enum only; the mapping from engine events to a
/// `Model` lives in `TerminalViewModel.bannerModel` (tested there).
struct ReconnectBanner: View {
/// What the banner says. Terminal phases (`failed`/`exited`) win over
/// transient connection states (mapping in `TerminalViewModel.bannerModel`).
enum Model: Equatable {
case connecting
case reconnecting(attempt: Int, retryIn: Duration)
/// Non-retryable failure: actionable copy, NO spinner, NO retry loop.
case failed(message: String)
/// Session over terminal is read-only from here on.
case exited(code: Int, reason: String?)
}
let model: Model
private enum Metrics {
static let contentSpacing: CGFloat = 8
static let horizontalPadding: CGFloat = 14
static let verticalPadding: CGFloat = 8
static let backgroundOpacity = 0.9
}
var body: some View {
HStack(spacing: Metrics.contentSpacing) {
if isSpinning {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: iconName)
}
Text(text)
.font(.footnote)
.multilineTextAlignment(.leading)
}
.padding(.horizontal, Metrics.horizontalPadding)
.padding(.vertical, Metrics.verticalPadding)
.background(tint.opacity(Metrics.backgroundOpacity), in: Capsule())
.foregroundStyle(.white)
.accessibilityElement(children: .combine)
}
private var isSpinning: Bool {
switch model {
case .connecting, .reconnecting: return true
case .failed, .exited: return false
}
}
private var iconName: String {
switch model {
case .failed: return "exclamationmark.octagon.fill"
case .exited: return "flag.checkered"
case .connecting, .reconnecting: return "wifi"
}
}
private var tint: Color {
switch model {
case .connecting: return .gray
case .reconnecting: return .orange
case .failed: return .red
case .exited: return .indigo
}
}
private var text: String {
switch model {
case .connecting:
return "连接中…"
case .reconnecting(let attempt, let retryIn):
return "连接已断开 · 第 \(attempt) 次重连将在 \(retryIn.components.seconds)s 后…"
case .failed(let message):
return message
case .exited(let code, let reason):
return Self.exitText(code: code, reason: reason)
}
}
/// Exit copy: spawn failure (-1, M4) reads as an error; a normal exit reads
/// as a conclusion. `reason` is server-supplied display text (untrusted
/// rendered as plain text only).
private static func exitText(code: Int, reason: String?) -> String {
if code == WireConstants.spawnFailedExitCode {
return "启动 shell 失败:\(reason ?? "未知原因")"
}
let suffix = reason.map { "\($0)" } ?? ""
return "会话已退出 exit \(code)\(suffix) · 终端已只读"
}
}

View File

@@ -0,0 +1,127 @@
import SwiftUI
import WireProtocol
/// T-iOS-13 · Telemetry chips for a session-list row the iOS mirror of the
/// web's `renderTelemetryGauge` (public/preview-grid.ts:197): context-usage %
/// (warn > 80), $cost to 4 places, model chip, PR badge.
///
/// Staleness: chips grey out when `StatusTelemetry.at` is STRICTLY older than
/// `Tunables.telemetryStaleTtlMs` (same `>` rule as the web's `tg-stale`).
/// The comparison lives in `Model`, computed from an injected `nowMs` so the
/// ViewModel tests are deterministic (task RED list).
///
/// Security (mirrors SEC-H5/SEC-L5): every telemetry string is server-supplied
/// UNTRUSTED input rendered as plain `Text` only; the PR badge becomes a
/// tappable `Link` iff its URL parses as https, otherwise it renders as text.
struct TelemetryChips: View {
/// Immutable render input, derived once per list rebuild.
struct Model: Equatable, Sendable {
let contextUsedPct: Double?
/// Pre-formatted `$X.XXXX` (mirrors the web's `toFixed(4)`).
let costText: String?
let modelName: String?
/// Pre-formatted `PR #N`.
let prText: String?
let prReviewState: String?
/// Tappable PR destination https URLs ONLY (SEC-L5 mirror), else nil.
let prURL: URL?
/// True when `at` is strictly older than `Tunables.telemetryStaleTtlMs`.
let isStale: Bool
/// nil when the session has no telemetry at all (row renders no chips).
init?(telemetry: StatusTelemetry?, nowMs: Int) {
guard let telemetry else { return nil }
isStale = nowMs - telemetry.at > Tunables.telemetryStaleTtlMs
contextUsedPct = telemetry.contextUsedPct
costText = telemetry.costUsd.map { String(format: Format.cost, $0) }
modelName = telemetry.model
if let pr = telemetry.pr {
prText = "PR #\(pr.number)"
prReviewState = pr.reviewState
prURL = Self.httpsOnlyURL(pr.url)
} else {
prText = nil
prReviewState = nil
prURL = nil
}
}
/// `ctx NN%` same rounding as the web label (`Math.round`).
var contextText: String? {
contextUsedPct.map { "ctx \(Int($0.rounded()))%" }
}
/// Mirrors the web's `tg-ctx-warn` threshold (strictly > 80).
var isContextWarning: Bool {
(contextUsedPct ?? 0) > Format.contextWarnPct
}
var isEmpty: Bool {
contextUsedPct == nil && costText == nil && modelName == nil && prText == nil
}
/// SEC-L5 mirror: only https URLs are ever offered as link targets.
static func httpsOnlyURL(_ raw: String) -> URL? {
guard let url = URL(string: raw),
url.scheme?.lowercased() == Format.httpsScheme
else { return nil }
return url
}
}
let model: Model
var body: some View {
HStack(spacing: Metrics.chipSpacing) {
if let contextText = model.contextText {
Text(contextText)
.foregroundStyle(model.isContextWarning ? Color.orange : Color.secondary)
}
if let costText = model.costText {
chip(costText)
}
if let modelName = model.modelName {
chip(modelName)
}
prBadge
}
.font(.caption2.monospacedDigit())
.lineLimit(1)
.opacity(model.isStale ? Metrics.staleOpacity : 1)
.grayscale(model.isStale ? 1 : 0)
}
@ViewBuilder private var prBadge: some View {
if let prText = model.prText {
let label = model.prReviewState.map { "\(prText) · \($0)" } ?? prText
if let url = model.prURL {
Link(label, destination: url)
} else {
chip(label)
}
}
}
private func chip(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.padding(.horizontal, Metrics.chipHorizontalPadding)
.background(.quaternary, in: Capsule())
}
// MARK: - Named constants (no magic numbers, plan §4)
private enum Metrics {
static let chipSpacing: CGFloat = 6
static let chipHorizontalPadding: CGFloat = 5
static let staleOpacity = 0.45
}
private enum Format {
/// Mirrors web `$${costUsd.toFixed(4)}`.
static let cost = "$%.4f"
/// Mirrors web `tg-ctx-warn` (`contextUsedPct > 80`).
static let contextWarnPct = 80.0
static let httpsScheme = "https"
}
}

View File

@@ -0,0 +1,350 @@
import HostRegistry
import SwiftUI
import UIKit
#if !targetEnvironment(simulator)
import VisionKit
#endif
/// T-iOS-12 · Pairing screen: QR scan (real device only) + manual URL entry +
/// probe UI. Pure presentation over `PairingViewModel` every rule (input
/// validation, the zero-network-before-confirm gate, §5.4 warning tiers,
/// error copy/actions) lives in the VM where it is unit-tested.
///
/// Presentation-agnostic on purpose: T-iOS-15 pushes it as the first-run
/// screen, and the session list header presents the SAME screen as a sheet to
/// add/switch hosts (the " host " of the task's step list).
///
/// Scanner: VisionKit `DataScannerViewController` compiled out for the
/// simulator (`#if targetEnvironment(simulator)`), where manual entry is the
/// pairing path; on device the entry also hides when scanning is unsupported.
/// `NSCameraUsageDescription` is already declared (project.yml, plan §5.2).
struct PairingScreen: View {
@Bindable var viewModel: PairingViewModel
/// Navigate-on-paired hook for the T-iOS-15 wiring.
var onPaired: (HostRegistry.Host) -> Void = { _ in }
@State private var manualURLText = ""
@State private var isShowingScanner = false
@State private var scannerError: String?
var body: some View {
content
.navigationTitle(ScreenCopy.title)
.onChange(of: viewModel.pairedHost) { _, paired in
guard let paired else { return }
onPaired(paired)
}
}
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .idle:
idleView
case .confirming(let pending):
ConfirmHostView(pending: pending, viewModel: viewModel)
case .probing(let pending):
probingView(pending)
case .failed(let pending, let failure):
FailureView(pending: pending, failure: failure, viewModel: viewModel)
case .paired(let host):
pairedView(host)
}
}
// MARK: - Idle: manual entry + scan entry
private var idleView: some View {
Form {
Section(ScreenCopy.manualSectionTitle) {
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button(ScreenCopy.manualSubmit) {
viewModel.submitManualURL(manualURLText)
}
}
if let rejection = viewModel.inputRejection {
Section {
Text(rejection).foregroundStyle(.red)
}
}
if PairingScanAvailability.isAvailable {
Section {
Button {
scannerError = nil
isShowingScanner = true
} label: {
Label(ScreenCopy.scanButton, systemImage: "qrcode.viewfinder")
}
}
}
Section {
Text(ScreenCopy.qrHint)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.sheet(isPresented: $isShowingScanner) { scannerSheet }
}
@ViewBuilder private var scannerSheet: some View {
#if targetEnvironment(simulator)
// Unreachable: the entry is hidden on the simulator. Kept total.
Text(ScreenCopy.scanUnavailable)
#else
ZStack(alignment: .bottom) {
QRScannerView(
onCode: { payload in
isShowingScanner = false
viewModel.handleScannedCode(payload)
},
onError: { message in scannerError = message }
)
if let scannerError {
Text(scannerError)
.foregroundStyle(.red)
.padding()
.background(.thinMaterial, in: RoundedRectangle(
cornerRadius: Metrics.errorCornerRadius
))
.padding()
}
}
#endif
}
// MARK: - Probing / paired
private func probingView(_ pending: PairingViewModel.PendingHost) -> some View {
VStack(spacing: Metrics.stackSpacing) {
ProgressView()
Text(ScreenCopy.probing(pending.displayAddress))
.foregroundStyle(.secondary)
}
.padding()
}
private func pairedView(_ host: HostRegistry.Host) -> some View {
VStack(spacing: Metrics.stackSpacing) {
Image(systemName: "checkmark.circle.fill")
.font(.largeTitle)
.foregroundStyle(.green)
Text(ScreenCopy.paired(host.name))
}
.padding()
}
}
// MARK: - Confirm page (parsed address + §5.4 warning tier + name)
private struct ConfirmHostView: View {
let pending: PairingViewModel.PendingHost
@Bindable var viewModel: PairingViewModel
var body: some View {
Form {
Section(ScreenCopy.confirmSectionTitle) {
// Single-point-derived scheme://host[:port] never hand-built.
Text(pending.displayAddress)
.font(.system(.body, design: .monospaced))
TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName)
}
warningSection
Section {
Button(ScreenCopy.connect) {
Task { await viewModel.confirmConnect() }
}
Button(ScreenCopy.cancel, role: .cancel) {
viewModel.cancel()
}
}
}
}
@ViewBuilder private var warningSection: some View {
switch pending.warning {
case .none:
EmptyView()
case .tailscaleEncrypted:
Section {
Label(ScreenCopy.tailscaleBadge, systemImage: "lock.shield")
.foregroundStyle(.green)
}
case .plaintextLAN:
Section {
Label(ScreenCopy.plaintextNotice, systemImage: "eye")
.foregroundStyle(.orange)
}
case .publicHostBlocking:
Section {
Label(ScreenCopy.publicWarning, systemImage: "exclamationmark.octagon.fill")
.foregroundStyle(.red)
.font(.headline)
Toggle(ScreenCopy.publicAcknowledge,
isOn: $viewModel.hasAcknowledgedPublicRisk)
if viewModel.needsPublicRiskAcknowledgement {
Text(ScreenCopy.publicAckRequired)
.foregroundStyle(.red)
.font(.footnote)
}
}
}
}
}
// MARK: - Failure page (inline copy + recovery action)
private struct FailureView: View {
let pending: PairingViewModel.PendingHost
let failure: PairingViewModel.FailureDisplay
let viewModel: PairingViewModel
var body: some View {
Form {
Section(pending.displayAddress) {
Label(failure.message, systemImage: "xmark.octagon")
.foregroundStyle(.red)
}
Section {
if failure.action == .openLocalNetworkSettings {
Button(ScreenCopy.openSettings) { openAppSettings() }
}
Button(ScreenCopy.retry) {
Task { await viewModel.retry() }
}
Button(ScreenCopy.back, role: .cancel) {
viewModel.cancel()
}
}
}
}
/// The app's Settings pane hosts its toggle there is no public
/// deep link straight to (plan §5.2 guidance).
private func openAppSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}
// MARK: - Scan availability
enum PairingScanAvailability {
/// Simulator: no camera entry hidden, manual entry is the pairing path
/// (task ruling). Device: also requires VisionKit support.
/// (`DataScannerViewController.isSupported` is MainActor-isolated.)
@MainActor static var isAvailable: Bool {
#if targetEnvironment(simulator)
return false
#else
return DataScannerViewController.isSupported
#endif
}
}
// MARK: - VisionKit scanner (device only)
#if !targetEnvironment(simulator)
/// Thin `DataScannerViewController` wrapper: QR symbology only; the FIRST
/// recognized code wins (the web QR fills the screen no tap-to-pick needed).
/// The payload is handed to the VM untouched validation is the VM's job.
private struct QRScannerView: UIViewControllerRepresentable {
let onCode: (String) -> Void
let onError: (String) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onCode: onCode)
}
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced,
isHighlightingEnabled: true
)
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
guard !scanner.isScanning else { return }
do {
try scanner.startScanning()
} catch {
// Explicit surfacing, never a silent swallow (plan §4): camera
// denied/busy shows inline in the sheet; manual entry remains.
onError(ScreenCopy.scannerStartFailed(error.localizedDescription))
}
}
@MainActor
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
private let onCode: (String) -> Void
private var hasDelivered = false
init(onCode: @escaping (String) -> Void) {
self.onCode = onCode
}
func dataScanner(
_ dataScanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem]
) {
guard !hasDelivered else { return }
for item in addedItems {
if case .barcode(let barcode) = item,
let payload = barcode.payloadStringValue {
hasDelivered = true
onCode(payload)
return
}
}
}
}
}
#endif
// MARK: - Screen constants
private enum Metrics {
static let stackSpacing: CGFloat = 12
static let errorCornerRadius: CGFloat = 8
}
private enum ScreenCopy {
static let title = "配对主机"
static let manualSectionTitle = "输入地址"
static let manualPlaceholder = "http://192.168.1.5:3000"
static let manualSubmit = "连接"
static let scanButton = "扫描二维码"
static let scanUnavailable = "模拟器不支持扫码,请手输地址。"
static let qrHint = "在电脑的 web 终端工具栏点「Connect a device」可显示配对二维码也可直接输入它的地址。"
static let confirmSectionTitle = "确认要连接的主机"
static let namePlaceholder = "主机名称"
static let connect = "连接"
static let cancel = "取消"
static let retry = "重试"
static let back = "返回"
static let openSettings = "去设置"
static let tailscaleBadge = "经 Tailscale 加密WireGuard 网络层)"
static let plaintextNotice =
"ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN推荐 tailscale servewss"
static let publicWarning =
"这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
static let publicAcknowledge = "我已了解风险,仍要连接"
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
static func probing(_ address: String) -> String {
"正在验证 \(address)"
}
static func paired(_ name: String) -> String {
"已配对:\(name)"
}
static func scannerStartFailed(_ reason: String) -> String {
"无法启动相机扫描:\(reason)。可改用手输地址。"
}
}

View File

@@ -0,0 +1,235 @@
import HostRegistry
import SwiftUI
import WireProtocol
/// T-iOS-13 · Session list screen (merged chooser + dashboard). Pure
/// presentation over `SessionListViewModel` polling cadence, badge priority,
/// staleness, optimistic kill and the navigation signal are all VM logic,
/// unit-tested in `SessionListViewModelTests`.
///
/// The T-iOS-15 wiring provides `onOpen` (push `TerminalScreen`, open with
/// `request.sessionId` nil = new session) and `onAddHost` (present the
/// pairing sheet; call `viewModel.reloadHosts()` when it completes).
struct SessionListScreen: View {
var viewModel: SessionListViewModel
/// Navigation hook: fired once per `OpenRequest` (unique id per tap).
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
/// Host-switch header hook: "" entry (pairing sheet, T-iOS-15).
var onAddHost: () -> Void = {}
var body: some View {
content
.navigationTitle(ScreenCopy.title)
.toolbar { hostMenu }
.onAppear { viewModel.appeared() }
.onDisappear { viewModel.disappeared() }
.onChange(of: viewModel.openRequest) { _, request in
guard let request else { return }
onOpen(request)
}
}
@ViewBuilder private var content: some View {
switch viewModel.emptyState {
case .notPaired:
notPairedView
case .noSessions:
noSessionsView
case nil:
sessionList
}
}
// MARK: - List
private var sessionList: some View {
List {
if let message = viewModel.fetchErrorMessage {
errorRow(message)
}
if let message = viewModel.killErrorMessage {
errorRow(message)
}
Button {
viewModel.requestNewSession()
} label: {
Label(ScreenCopy.newSession, systemImage: "plus.circle.fill")
}
ForEach(viewModel.rows) { row in
Button {
viewModel.openSession(id: row.id)
} label: {
SessionRowView(row: row)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.kill(sessionId: row.id) }
} label: {
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
}
}
}
}
.refreshable { await viewModel.refresh() }
}
private func errorRow(_ message: String) -> some View {
Label(message, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.red)
}
// MARK: - Empty states
private var notPairedView: some View {
ContentUnavailableView {
Label(ScreenCopy.notPairedTitle, systemImage: "personalhotspot")
} description: {
Text(ScreenCopy.notPairedHint)
} actions: {
Button(ScreenCopy.addHost) { onAddHost() }
.buttonStyle(.borderedProminent)
}
}
private var noSessionsView: some View {
ContentUnavailableView {
Label(ScreenCopy.noSessionsTitle, systemImage: "terminal")
} description: {
Text(ScreenCopy.noSessionsHint)
} actions: {
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
.buttonStyle(.borderedProminent)
}
}
// MARK: - Host-switch header (multi-host from HostStore)
private var hostMenu: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Menu {
ForEach(viewModel.hosts) { host in
Button {
Task { await viewModel.selectHost(id: host.id) }
} label: {
if host.id == viewModel.activeHost?.id {
Label(host.name, systemImage: "checkmark")
} else {
Text(host.name)
}
}
}
Divider()
Button {
onAddHost()
} label: {
Label(ScreenCopy.addHost, systemImage: "plus")
}
} label: {
Label(
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
systemImage: "desktopcomputer"
)
}
}
}
}
// MARK: - Row
private struct SessionRowView: View {
let row: SessionListViewModel.SessionRow
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)
Text(meta)
.font(.caption)
.foregroundStyle(.secondary)
if let telemetry = row.telemetry, !telemetry.isEmpty {
TelemetryChips(model: telemetry)
}
}
}
.opacity(row.info.exited ? Metrics.exitedOpacity : 1)
.accessibilityElement(children: .combine)
}
/// badge outranks the status dot (VM decides via `indicator`).
@ViewBuilder private var indicator: some View {
switch row.indicator {
case .pendingApproval:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.accessibilityLabel(ScreenCopy.pendingBadgeLabel)
case .status(let status):
Circle()
.fill(Self.dotColor(status))
.frame(width: Metrics.dotSize, height: Metrics.dotSize)
.padding(.top, Metrics.dotTopPadding)
.accessibilityLabel(Text(status.rawValue))
}
}
/// `cwd` is server-supplied display text (untrusted plain Text only).
private var title: String {
guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory }
return URL(fileURLWithPath: cwd).lastPathComponent
}
private var meta: String {
var parts = [
ScreenCopy.clientCount(row.info.clientCount),
"\(row.info.cols)×\(row.info.rows)",
]
if row.info.exited {
parts.append(ScreenCopy.exitedTag)
}
return parts.joined(separator: " · ")
}
private static func dotColor(_ status: ClaudeStatus) -> Color {
switch status {
case .working: return .green
case .waiting: return .orange
case .idle: return .blue
case .stuck: return .red
case .unknown: return .gray
}
}
private enum Metrics {
static let rowSpacing: CGFloat = 10
static let rowInnerSpacing: CGFloat = 3
static let indicatorWidth: CGFloat = 18
static let dotSize: CGFloat = 10
static let dotTopPadding: CGFloat = 5
static let exitedOpacity = 0.55
}
}
// MARK: - Screen copy
private enum ScreenCopy {
static let title = "会话"
static let newSession = "新建会话"
static let kill = "结束"
static let addHost = "配对新主机"
static let hostMenuFallback = "主机"
static let notPairedTitle = "还没有配对的主机"
static let notPairedHint = "先配对你电脑上的 web-terminal扫码或手输地址会话会出现在这里。"
static let noSessionsTitle = "主机上没有运行中的会话"
static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。"
static let unknownDirectory = "未知目录"
static let exitedTag = "已退出"
static let pendingBadgeLabel = "等待审批"
static func clientCount(_ count: Int) -> String {
"\(count) 台设备在看"
}
}

View File

@@ -0,0 +1,145 @@
import SessionCore
import SwiftTerm
import SwiftUI
import UIKit
/// T-iOS-11 · The terminal screen: SwiftTerm view + key bar + status banner.
///
/// Data flow (byte-shuttle preserved end to end):
/// - inbound: `SessionEvent.output` `TerminalViewModel` sink `feed(text:)`
/// (opaque ANSI/UTF-8 the app NEVER parses terminal semantics);
/// - outbound: SwiftTerm delegate `send` `viewModel.sendInput`,
/// `sizeChanged` `viewModel.sendResize`; KeyBar taps and hardware
/// `UIKeyCommand`s go through `viewModel.send(key:)` every labelbytes
/// lookup via `KeyByteMap`, bypassing SwiftTerm's text path so the soft
/// keyboard never pops (mirrors the web bar's ws.send bypass).
/// - IME: NO custom keydown/composition interception SwiftTerm manages
/// composition itself (CLAUDE.md gotcha; plan §7 T-iOS-11).
///
/// Navigation/lifecycle wiring (engine open/close, scenePhase) lands in
/// T-iOS-15 this screen only renders and routes.
struct TerminalScreen: View {
let viewModel: TerminalViewModel
private enum Metrics {
static let bannerHorizontalPadding: CGFloat = 12
static let bannerTopPadding: CGFloat = 8
}
var body: some View {
TerminalHostView(viewModel: viewModel)
.ignoresSafeArea(.container, edges: .bottom)
.overlay(alignment: .top) {
if let model = viewModel.bannerModel {
ReconnectBanner(model: model)
.padding(.horizontal, Metrics.bannerHorizontalPadding)
.padding(.top, Metrics.bannerTopPadding)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.animation(.default, value: viewModel.bannerModel)
.onAppear { viewModel.start() }
}
}
// MARK: - SwiftTerm bridge
/// `UIViewRepresentable` around `SwiftTerm.TerminalView` (plan §3.5). The
/// KeyBar is installed as `inputAccessoryView`; hardware chords come from the
/// `KeyCommandTerminalView` subclass. Both route through the ViewModel.
private struct TerminalHostView: UIViewRepresentable {
let viewModel: TerminalViewModel
func makeCoordinator() -> Coordinator {
Coordinator(viewModel: viewModel)
}
func makeUIView(context: Context) -> KeyCommandTerminalView {
let terminal = KeyCommandTerminalView(frame: .zero)
terminal.terminalDelegate = context.coordinator
let viewModel = viewModel
terminal.onKeyCommand = { key in viewModel.send(key: key) }
let keyBar = KeyBarView()
keyBar.onKey = { key in viewModel.send(key: key) }
terminal.inputAccessoryView = keyBar
// Output sink: buffered replay flushes now, live bytes follow.
// @MainActor-typed closure feeding off the main actor cannot compile.
viewModel.attachTerminalSink { [weak terminal] text in
terminal?.feed(text: text)
}
return terminal
}
func updateUIView(_ uiView: KeyCommandTerminalView, context: Context) {
// State-driven UI lives in SwiftUI (banner overlay); the terminal view
// itself is driven by the sink/delegate, nothing to push here.
}
/// SwiftTerm's delegate is a pre-concurrency protocol; the conformance is
/// `@preconcurrency` SwiftTerm only calls it from the main thread (the
/// view itself is `@MainActor`), which the runtime check enforces.
@MainActor
final class Coordinator: NSObject, @preconcurrency TerminalViewDelegate {
private let viewModel: TerminalViewModel
init(viewModel: TerminalViewModel) {
self.viewModel = viewModel
}
/// User typed into SwiftTerm (soft/hardware keyboard, IME result):
/// raw bytes, passed through verbatim (invariant #9).
func send(source: TerminalView, data: ArraySlice<UInt8>) {
viewModel.sendInput(String(decoding: data, as: UTF8.self))
}
/// Layout produced a new grid server `resize` (SIGWINCH). This is
/// also the latest-writer-wins size claim (v0.4 sizing model).
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
guard newCols > 0, newRows > 0 else { return } // pre-layout noise
viewModel.sendResize(cols: newCols, rows: newRows)
}
/// Tapped link (OSC 8 / detected URL mirrors web M2 WebLinksAddon).
/// The link string is untrusted terminal output: http(s) only.
func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {
guard let url = URL(string: link),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https"
else { return }
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) {}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
func scrolled(source: TerminalView, position: Double) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
/// OSC 52 lets the HOST write the device clipboard silently declined
/// (server output is untrusted input; plan §4).
func clipboardCopy(source: TerminalView, content: Data) {}
}
}
/// `TerminalView` subclass adding hardware-keyboard `UIKeyCommand`s with the
/// SAME `KeyByteMap` mapping as the key bar (scope decision documented on
/// `HardwareKeyCommands`). Everything else rendering, selection, IME
/// is stock SwiftTerm.
final class KeyCommandTerminalView: TerminalView {
/// Chord outlet; the screen routes it to `TerminalViewModel.send(key:)`.
var onKeyCommand: (@MainActor (KeyByteMap.Key) -> Void)?
override var keyCommands: [UIKeyCommand]? {
(super.keyCommands ?? [])
+ HardwareKeyCommands.build(action: #selector(runHardwareKeyCommand(_:)))
}
@objc private func runHardwareKeyCommand(_ sender: UIKeyCommand) {
guard let key = HardwareKeyCommands.key(matching: sender) else { return }
onKeyCommand?(key)
}
}

View File

@@ -0,0 +1,319 @@
import Foundation
import Observation
import SessionCore
import UIKit
import WireProtocol
/// Haptic seam (T-iOS-14): gate-arrival feedback is injected so tests can
/// assert "exactly once per gate epoch" without UIKit hardware.
@MainActor
protocol HapticSignaling {
/// A NEW gate (fresh epoch) is waiting for the user's decision.
func gateDidArrive()
}
/// Production haptics: notification-style buzz a gate is Claude asking for a
/// decision (THE steering primitive), not a mere UI tick.
@MainActor
final class GateHaptics: HapticSignaling {
private let generator = UINotificationFeedbackGenerator()
func gateDidArrive() {
generator.notificationOccurred(.warning)
}
}
/// T-iOS-14 · Gate + away-digest state (plan §3.5): a STANDALONE
/// `@MainActor @Observable` VM consuming the SAME `SessionEvent` stream as
/// `TerminalViewModel` `.gate`/`.digest` are its domain, everything else is
/// ignored here. TerminalScreen wiring (who fans the stream out) is T-iOS-15.
///
/// Stale-tap guard (FIRST line): every rendered gate button carries the epoch
/// of the gate it was rendered against; `decide` compares that epoch and the
/// affordance against the LATEST gate event received and silently drops a
/// mismatch, so a slow tap can never resolve a NEWER gate than the one that
/// was on screen (mirrors the web's pendingEpoch nonce,
/// public/terminal-session.ts:98-102). The engine's `GateTracker.canDecide`
/// is the SECOND line (SessionEngine type doc) both must agree to send.
///
/// Plan-gate mapping is frozen in SessionCore
/// (`GateState.Affordance.clientMessage`, mirror of public/tabs.ts:345-347):
/// Approve+Auto acceptEdits, Approve+Review default, Keep Planning
/// reject. There is NO allowAutoMode gating anywhere the web client never
/// gates the plan three-way, and `uiConfig` is reserved for a future
/// permission-mode picker (plan §3.1 note).
@MainActor
@Observable
final class GateViewModel {
// MARK: - Observable UI state
/// The latest gate event received (nil = lifted). This is what taps are
/// validated against.
private(set) var currentGate: GateState?
/// Visible away digest (nil = nothing rendered; all-zero digests never
/// become visible).
private(set) var digest: AwayDigest?
/// True once the user opened the recent-entries detail; expansion cancels
/// the auto-fade (a digest must never vanish while being read).
private(set) var isDigestExpanded = false
/// Tool gate two-button `GateBanner`.
var toolGate: GateState? { gate(of: .tool) }
/// Plan gate three-way `PlanGateSheet`.
var planGate: GateState? { gate(of: .plan) }
// MARK: - Dependencies & plumbing (not observed)
@ObservationIgnored private let engine: SessionEngine
@ObservationIgnored private let events: AsyncStream<SessionEvent>
@ObservationIgnored private let haptics: any HapticSignaling
@ObservationIgnored private let clock: any Clock<Duration>
@ObservationIgnored private var consumeTask: Task<Void, Never>?
@ObservationIgnored private var fadeTask: Task<Void, Never>?
/// Highest epoch that already buzzed the haptic fires ONCE per epoch
/// (sustained-pending refreshes reuse the epoch and stay silent).
@ObservationIgnored private var lastHapticEpoch = 0
/// Ordered decision queue: taps are synchronous, `engine.send` is async
/// one pump task preserves submission order (same pattern as
/// TerminalViewModel's send pump).
@ObservationIgnored private var decisionQueue: [ClientMessage] = []
@ObservationIgnored private var isPumping = false
// MARK: - Test-visible diagnostics & deterministic barriers (internal)
/// Events applied so far `waitUntilProcessed` barrier counter.
@ObservationIgnored private(set) var processedEventCount = 0
/// Decisions handed to the engine `waitUntilForwarded` barrier counter.
@ObservationIgnored private(set) var forwardedDecisionCount = 0
/// Taps dropped by the first-line guard (stale epoch / lifted gate /
/// affordance no longer offered).
@ObservationIgnored private(set) var droppedStaleDecisionCount = 0
/// Completed auto-fades `waitUntilFadeCompleted` barrier counter.
@ObservationIgnored private(set) var fadeCompletedCount = 0
/// Test tap, called after each event is applied (state already coherent).
@ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?
private struct CountWaiter {
let target: Int
let continuation: CheckedContinuation<Void, Never>
}
@ObservationIgnored private var eventWaiters: [CountWaiter] = []
@ObservationIgnored private var decisionWaiters: [CountWaiter] = []
@ObservationIgnored private var fadeWaiters: [CountWaiter] = []
// MARK: - Lifecycle
/// - Parameters:
/// - engine: send-side dependency (decisions only open/close belong to
/// the T-iOS-15 lifecycle owner).
/// - events: the event stream to consume; the T-iOS-15 wiring passes a
/// fan-out branch of `engine.events` shared with `TerminalViewModel`.
/// - haptics: gate-arrival feedback (production: `GateHaptics`).
/// - clock: drives the digest auto-fade (production: `ContinuousClock`).
init(
engine: SessionEngine, events: AsyncStream<SessionEvent>,
haptics: any HapticSignaling, clock: any Clock<Duration>
) {
self.engine = engine
self.events = events
self.haptics = haptics
self.clock = clock
}
/// Begin consuming events. Idempotent a second call is a no-op.
func start() {
guard consumeTask == nil else { return }
consumeTask = Task { [weak self] in
guard let events = self?.events else { return }
for await event in events {
guard let self else { return }
self.apply(event)
}
self?.releaseAllWaiters() // stream over never leave a test hanging
}
}
/// Stop consuming (screen torn down); cancels the fade timer too.
func stop() {
consumeTask?.cancel()
consumeTask = nil
cancelFade()
releaseAllWaiters()
}
// MARK: - Decisions (Approve / Reject / plan three-way)
/// Resolve the held gate with `affordance`, tapped against the gate whose
/// `epoch` the button rendered. First-line stale guard: the epoch must
/// match the LATEST gate received AND the affordance must still be one the
/// current gate offers (a same-epoch kind morph invalidates old buttons).
/// Mismatch dropped, nothing is sent.
func decide(_ affordance: GateState.Affordance, epoch: Int) {
guard let gate = currentGate, gate.epoch == epoch,
gate.affordances.contains(affordance)
else {
droppedStaleDecisionCount += 1
return
}
decisionQueue = decisionQueue + [affordance.clientMessage]
pumpIfIdle()
}
// MARK: - Digest interactions
/// Show the recent-entries detail; cancels the auto-fade so the digest
/// never vanishes while being read (documented decision).
func expandDigest() {
guard digest != nil else { return }
isDigestExpanded = true
cancelFade()
}
/// Explicitly dismiss the digest (the on the summary row).
func dismissDigest() {
digest = nil
isDigestExpanded = false
cancelFade()
}
// MARK: - Event application (MainActor)
private func apply(_ event: SessionEvent) {
switch event {
case .gate(let gate):
applyGate(gate)
case .digest(let digest):
applyDigest(digest)
case .connection, .adopted, .output, .exited, .telemetry:
break // TerminalViewModel's domain (T-iOS-11)
}
processedEventCount += 1
onEventApplied?(event)
eventWaiters = drainWaiters(eventWaiters, reached: processedEventCount)
}
private func applyGate(_ gate: GateState?) {
currentGate = gate
guard let gate, gate.epoch > lastHapticEpoch else { return }
lastHapticEpoch = gate.epoch // exactly one buzz per epoch
haptics.gateDidArrive()
}
/// All-zero digest render nothing (spec). A visible digest starts
/// collapsed and auto-fades after `Tunables.digestFadeDelay` unless the
/// user expands it first.
private func applyDigest(_ incoming: AwayDigest) {
guard !incoming.isEmpty else { return }
cancelFade()
digest = incoming
isDigestExpanded = false
scheduleFade()
}
private func gate(of kind: GateKind) -> GateState? {
guard let currentGate, currentGate.kind == kind else { return nil }
return currentGate
}
// MARK: - Digest auto-fade (injected clock; zero real waits in tests)
private func scheduleFade() {
let clock = self.clock
fadeTask = Task { [weak self] in
do {
try await clock.sleep(for: Tunables.digestFadeDelay, tolerance: nil)
} catch {
return // cancelled: expanded, dismissed, replaced, or stopped
}
self?.completeFade()
}
}
private func completeFade() {
guard !isDigestExpanded else { return } // expand cancels; belt & braces
digest = nil
fadeTask = nil
fadeCompletedCount += 1
fadeWaiters = drainWaiters(fadeWaiters, reached: fadeCompletedCount)
}
private func cancelFade() {
fadeTask?.cancel()
fadeTask = nil
}
// MARK: - Ordered decision pump
private func pumpIfIdle() {
guard !isPumping else { return }
isPumping = true
Task { await self.pumpDecisions() }
}
private func pumpDecisions() async {
while let next = decisionQueue.first {
decisionQueue = Array(decisionQueue.dropFirst())
await engine.send(next)
forwardedDecisionCount += 1
decisionWaiters = drainWaiters(decisionWaiters, reached: forwardedDecisionCount)
}
isPumping = false
}
// MARK: - Deterministic test barriers (no polling, no real sleeps)
/// Suspends until at least `eventCount` events have been applied.
func waitUntilProcessed(eventCount target: Int) async {
await withCheckedContinuation { continuation in
guard processedEventCount < target else {
continuation.resume()
return
}
eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
/// Suspends until at least `decisionCount` decisions reached the engine.
func waitUntilForwarded(decisionCount target: Int) async {
await withCheckedContinuation { continuation in
guard forwardedDecisionCount < target else {
continuation.resume()
return
}
decisionWaiters = decisionWaiters
+ [CountWaiter(target: target, continuation: continuation)]
}
}
/// Suspends until at least `count` auto-fades have completed.
func waitUntilFadeCompleted(count target: Int) async {
await withCheckedContinuation { continuation in
guard fadeCompletedCount < target else {
continuation.resume()
return
}
fadeWaiters = fadeWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
/// Resumes every waiter satisfied by `count`; returns the still-waiting
/// remainder (immutable style the caller reassigns).
private func drainWaiters(_ waiters: [CountWaiter], reached count: Int) -> [CountWaiter] {
let satisfied = waiters.filter { $0.target <= count }
for waiter in satisfied {
waiter.continuation.resume()
}
return waiters.filter { $0.target > count }
}
private func releaseAllWaiters() {
let all = eventWaiters + decisionWaiters + fadeWaiters
eventWaiters = []
decisionWaiters = []
fadeWaiters = []
for waiter in all {
waiter.continuation.resume()
}
}
}

View File

@@ -0,0 +1,398 @@
import APIClient
import Foundation
import HostRegistry
import Observation
import WireProtocol
/// T-iOS-12 · Pairing state machine (plan §7 / §5.4).
///
/// Flow: scan / manual URL **confirm gate** two-step probe
/// `Host{id,name}` into the `HostStore` + navigate signal.
///
/// Security invariants (plan §5 / task RED list):
/// - Scan payloads are UNTRUSTED external input: parsed exclusively through
/// `HostEndpoint` (single-point derivation no hand-assembly), non-http(s)
/// rejected with copy, and **zero network happens before the user confirms**
/// (probe already GETs the target; probe spawns a PTY on it).
/// - The §5.4 warning tiers render ON the confirm page; the public-host tier
/// is BLOCKING `confirmConnect` refuses to probe until the user has set
/// `hasAcknowledgedPublicRisk` explicitly.
/// - Every `PairingError` maps to inline copy + a recovery action
/// (`localNetworkDenied` Settings deep-link; `originRejected` surfaces the
/// probe's hint VERBATIM; `atsBlocked` uses the §3.4 wording).
///
/// Documented decisions:
/// - **Manual entry reuses the same confirm state as scanning** (task left it
/// free): one code path, and the §5.4 warning tiers apply uniformly to
/// typed URLs too. Convenience: input without `://` gets an `http://`
/// prefix before the `HostEndpoint` parse (scan payloads get NO such help).
/// - **Host classification is (re)implemented here**: APIClient's
/// `PairingError.isPrivateOrLocalHost` is internal AND too coarse for the
/// tiers (it collapses loopback/Tailscale/RFC1918 into one bucket).
/// Duplication noted for the T-iOS-38 dedup pass.
/// - `.local` (mDNS) hosts over http are shown the plaintext-LAN notice: they
/// resolve to LAN addresses, so the §5.4 "ws:// on an untrusted LAN" row
/// applies to them the same way.
///
/// §3.4 contract ruling (2026-07-04): the injected probe returns the validated
/// `HostEndpoint`; `Host{id: UUID(), name:}` is constructed HERE (id/name are
/// not the probe's to know). Production wiring (T-iOS-15) passes
/// `runPairingProbe` with the real transports.
@MainActor
@Observable
final class PairingViewModel {
/// The probe, injected as a closure so tests can both fake results AND
/// assert non-invocation before the user confirms (task RED list).
typealias Probe = @Sendable (HostEndpoint) async -> Result<HostEndpoint, PairingError>
// MARK: - UI state model
/// §5.4 warning tiers, decided from scheme + host class (see `warning(for:)`).
enum SecurityWarning: Equatable, Sendable {
/// https anywhere private-class, or wsloopback: nothing to warn about.
case none
/// ws100.64/10 or `*.ts.net`: WireGuard already encrypts no
/// plaintext warning, an optional positive badge instead.
case tailscaleEncrypted
/// wsRFC1918 / link-local / `.local`: NON-blocking notice keystrokes
/// and output are sniffable on the same LAN; prefer `tailscale serve`.
case plaintextLAN
/// Public host (http AND https alike, §5.4 table): strongest BLOCKING
/// warning anyone who can reach the port gets a shell.
case publicHostBlocking
var isBlocking: Bool { self == .publicHostBlocking }
}
/// The parsed-but-not-yet-probed target shown on the confirm page.
struct PendingHost: Equatable, Sendable {
let endpoint: HostEndpoint
let warning: SecurityWarning
/// `scheme://host[:port]` via `HostEndpoint`'s single-point derivation
/// (browser-Origin serialization) NEVER hand-assembled.
var displayAddress: String { endpoint.originHeader }
}
/// What the failure UI offers besides the message.
enum RecoveryAction: Equatable, Sendable {
case retry
/// iOS Local Network permission was denied deep-link to the app's
/// Settings pane (its toggle lives there).
case openLocalNetworkSettings
}
struct FailureDisplay: Equatable, Sendable {
let message: String
let action: RecoveryAction
}
enum Phase: Equatable {
case idle
case confirming(PendingHost)
case probing(PendingHost)
case failed(PendingHost, FailureDisplay)
case paired(HostRegistry.Host)
}
// MARK: - Observable state
private(set) var phase: Phase = .idle
/// Inline rejection copy for invalid scan/manual input (idle-state error).
private(set) var inputRejection: String?
/// Editable name shown on the confirm page; defaults to the endpoint host
/// and falls back to it when the user clears the field.
var hostName = ""
/// Explicit user acknowledgement for the blocking public-host warning.
var hasAcknowledgedPublicRisk = false
/// Set when confirm was attempted on a blocking warning WITHOUT the
/// acknowledgement the UI highlights the ack control.
private(set) var needsPublicRiskAcknowledgement = false
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
/// observes it to move on to the session list).
private(set) var pairedHost: HostRegistry.Host?
// MARK: - Dependencies (not observed)
@ObservationIgnored private let store: any HostStore
@ObservationIgnored private let probe: Probe
init(store: any HostStore, probe: @escaping Probe) {
self.store = store
self.probe = probe
}
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
/// QR scan result (`public/qr.ts` encodes `location.origin`). Strict: the
/// payload must already be a full http(s) URL no scheme inference for
/// untrusted external input.
func handleScannedCode(_ payload: String) {
guard canAcceptNewTarget else { return }
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed), let endpoint = HostEndpoint(baseURL: url) else {
inputRejection = PairingCopy.scanRejected
return
}
enterConfirming(endpoint)
}
/// Manually typed URL. The user knows what they typed, but it still goes
/// through the SAME confirm state (uniform warning tiers documented
/// decision). Convenience: no `://` `http://` prefix before parsing.
func submitManualURL(_ text: String) {
guard canAcceptNewTarget else { return }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
inputRejection = PairingCopy.manualRejected
return
}
let candidate = trimmed.contains(Self.schemeSeparator)
? trimmed
: Self.defaultManualScheme + trimmed
guard let url = URL(string: candidate), let endpoint = HostEndpoint(baseURL: url) else {
inputRejection = PairingCopy.manualRejected
return
}
enterConfirming(endpoint)
}
/// Back out of confirm/failed to a clean entry state. Never probes.
func cancel() {
phase = .idle
inputRejection = nil
hasAcknowledgedPublicRisk = false
needsPublicRiskAcknowledgement = false
}
// MARK: - Confirm probe store
/// The ONLY way network starts. No-op unless confirming; a blocking
/// warning without explicit acknowledgement refuses and flags the UI.
func confirmConnect() async {
guard case .confirming(let pending) = phase else { return }
if pending.warning.isBlocking && !hasAcknowledgedPublicRisk {
needsPublicRiskAcknowledgement = true
return
}
await runProbe(for: pending)
}
/// Re-run the full probe against the same endpoint after a failure.
func retry() async {
guard case .failed(let pending, _) = phase else { return }
await runProbe(for: pending)
}
private var canAcceptNewTarget: Bool {
switch phase {
case .idle, .confirming, .failed:
return true
case .probing, .paired:
return false
}
}
private func enterConfirming(_ endpoint: HostEndpoint) {
inputRejection = nil
hasAcknowledgedPublicRisk = false
needsPublicRiskAcknowledgement = false
hostName = endpoint.baseURL.host ?? ""
phase = .confirming(PendingHost(
endpoint: endpoint, warning: Self.warning(for: endpoint)
))
}
private func runProbe(for pending: PendingHost) async {
needsPublicRiskAcknowledgement = false
phase = .probing(pending)
switch await probe(pending.endpoint) {
case .failure(let error):
phase = .failed(pending, Self.display(for: error))
case .success(let endpoint):
await storePairedHost(endpoint: endpoint, pending: pending)
}
}
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
/// endpoint. A store failure is surfaced explicitly (never swallowed);
/// retry re-runs the whole confirm flow.
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
let host = HostRegistry.Host(
id: UUID(),
name: trimmedName.isEmpty ? fallbackName : trimmedName,
endpoint: endpoint
)
do {
_ = try await store.upsert(host)
} catch {
phase = .failed(pending, FailureDisplay(
message: PairingCopy.storeFailed, action: .retry
))
return
}
pairedHost = host
phase = .paired(host)
}
// MARK: - PairingError copy + action (task RED list, one case each)
static func display(for error: PairingError) -> FailureDisplay {
switch error {
case .localNetworkDenied:
return FailureDisplay(
message: PairingCopy.localNetworkDenied, action: .openLocalNetworkSettings
)
case .hostUnreachable(let underlying):
return FailureDisplay(
message: PairingCopy.hostUnreachable(underlying), action: .retry
)
case .httpOkButNotWebTerminal:
return FailureDisplay(message: PairingCopy.notWebTerminal, action: .retry)
case .originRejected(let hint):
// The probe already derived the complete actionable copy from
// endpoint.originHeader surface it VERBATIM, never re-derive.
return FailureDisplay(message: hint, action: .retry)
case .atsBlocked(let host):
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
case .tlsFailure:
return FailureDisplay(message: PairingCopy.tlsFailure, action: .retry)
case .timeout:
return FailureDisplay(message: PairingCopy.timeout, action: .retry)
}
}
// MARK: - §5.4 warning tiers
/// Decide the confirm-page warning from scheme + host class. Public hosts
/// block regardless of scheme (§5.4 table: https is included in the
/// public-host confirm warning); otherwise https clears every notice.
static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
let hostClass = classifyHost(endpoint.baseURL.host ?? "")
if hostClass == .publicHost {
return .publicHostBlocking
}
if endpoint.baseURL.scheme?.lowercased() == Self.httpsScheme {
return .none
}
switch hostClass {
case .loopback:
return .none
case .tailscale:
return .tailscaleEncrypted
case .privateLAN:
return .plaintextLAN
case .publicHost:
return .publicHostBlocking // unreachable; keeps the switch total
}
}
/// Address classes relevant to §5.4. NOTE: near-duplicate of APIClient's
/// internal `isPrivateOrLocalHost` (finer-grained here) T-iOS-38 dedup.
enum HostClass: Equatable, Sendable {
case loopback
case tailscale
case privateLAN
case publicHost
}
static func classifyHost(_ rawHost: String) -> HostClass {
let host = rawHost.lowercased()
.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) // IPv6 brackets
if host == Self.localhostName {
return .loopback
}
if host.hasSuffix(Self.tailscaleMagicDNSSuffix) {
return .tailscale
}
if host.hasSuffix(Self.mdnsSuffix) {
return .privateLAN
}
if let octets = ipv4Octets(host) {
return classifyIPv4(octets)
}
if host.contains(":") {
return classifyIPv6(host)
}
return .publicHost
}
private static func classifyIPv4(_ octets: [Int]) -> HostClass {
switch (octets[0], octets[1]) {
case (127, _):
return .loopback
case (10, _), (192, 168), (169, 254):
return .privateLAN // RFC1918 10/8, 192.168/16 · link-local 169.254/16
case (172, 16...31):
return .privateLAN // RFC1918 172.16/12
case (100, 64...127):
return .tailscale // CGNAT 100.64/10
default:
return .publicHost
}
}
private static func classifyIPv6(_ host: String) -> HostClass {
if host == Self.ipv6Loopback {
return .loopback
}
let isLinkLocal = host.hasPrefix(Self.ipv6LinkLocalPrefix)
let isULA = host.hasPrefix("fc") || host.hasPrefix("fd") // fc00::/7
return (isLinkLocal || isULA) ? .privateLAN : .publicHost
}
private static func ipv4Octets(_ host: String) -> [Int]? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == Self.ipv4OctetCount else { return nil }
let octets = parts.compactMap { Int($0) }
guard octets.count == Self.ipv4OctetCount,
octets.allSatisfy({ Self.ipv4OctetRange.contains($0) })
else { return nil }
return octets
}
// MARK: - Named constants (no magic values, plan §4)
private static let schemeSeparator = "://"
private static let defaultManualScheme = "http://"
private static let httpsScheme = "https"
private static let localhostName = "localhost"
private static let tailscaleMagicDNSSuffix = ".ts.net"
private static let mdnsSuffix = ".local"
private static let ipv6Loopback = "::1"
private static let ipv6LinkLocalPrefix = "fe80"
private static let ipv4OctetCount = 4
private static let ipv4OctetRange = 0...255
}
/// User-facing pairing copy (plan §3.4 taxonomy actionable wording; §5.2
/// Local-Network guidance including the iOS 18 restart caveat).
enum PairingCopy {
static let scanRejected =
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
static let manualRejected =
"无法解析这个地址。请输入完整 URL例如 http://192.168.1.5:3000"
static let storeFailed =
"主机已通过验证,但保存到本机失败,请重试。"
static let localNetworkDenied =
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
+ "iOS 18 存在需要重启手机才生效的已知问题)。"
static let notWebTerminal =
"对方在响应 HTTP但不是 web-terminal——端口对吗"
static let tlsFailure =
"TLS 连接失败:证书无效或不受信任。"
static let timeout =
"连接超时。请确认主机在线、与手机在同一网络后重试。"
static func hostUnreachable(_ underlying: String) -> String {
"无法连接主机:\(underlying)"
}
/// §3.4 wording for the ATS cleartext block.
static func atsBlocked(host: String) -> String {
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
+ "请改用 https / tailscale serve或反馈该网段。"
}
}

View File

@@ -0,0 +1,351 @@
import APIClient
import Foundation
import HostRegistry
import Observation
import WireProtocol
/// T-iOS-13 · Session list state (merged chooser + dashboard, plan §7):
/// one glance answers "do I need to step in?" status dot / pending badge,
/// telemetry chips with staleness, swipe-to-kill, and the navigation signal
/// into `TerminalScreen`.
///
/// Polling: while the screen is visible, `GET /live-sessions` every
/// `Tunables.listPollInterval` (mirrors public/launcher.ts `REFRESH_MS`),
/// paced on an injected `Clock` (FakeClock in tests zero real waits).
/// `disappeared()` cancels the poll task; the leak test asserts the parked
/// sleeper is reclaimed.
///
/// Documented decisions:
/// - **Pending is an overlay, not a list field**: `LiveSessionInfo`
/// (src/types.ts:246-256) carries NO `pending` that signal only exists on
/// the WS status side-channel (the server's pendingApprovals), exactly like
/// the web dashboard reads it from attached tabs (public/tabs.ts snapshot()).
/// The T-iOS-15 wiring forwards gate/status events into
/// `setPendingApproval`; this VM owns the merge and the badge PRIORITY.
/// - **Kill is optimistic via a hidden-id set**: rows derive from the last
/// fetch minus `hiddenKilledIds`, so rollback restores the exact original
/// position, and a poll racing the DELETE cannot resurrect the row. A 404
/// on DELETE means "already gone" and counts as success.
/// - **A failed poll never wipes the list**: stale rows + explicit copy beat
/// a blank screen on one dropped packet.
@MainActor
@Observable
final class SessionListViewModel {
// MARK: - Row model
/// What the row leads with: the held-approval badge outranks the dot.
enum StatusIndicator: Equatable, Sendable {
case pendingApproval
case status(ClaudeStatus)
/// Mirrors public/tabs.ts:74-80 `claudeIcon`; for a held approval.
var glyph: String {
switch self {
case .pendingApproval: return ""
case .status(.working): return ""
case .status(.waiting): return ""
case .status(.idle): return ""
case .status(.stuck): return ""
case .status(.unknown): return ""
}
}
}
/// Immutable render snapshot of one session (rebuilt wholesale per fetch).
struct SessionRow: Equatable, Identifiable, Sendable {
let info: LiveSessionInfo
let isPendingApproval: Bool
let telemetry: TelemetryChips.Model?
var id: UUID { info.id }
var indicator: StatusIndicator {
isPendingApproval ? .pendingApproval : .status(info.status)
}
}
/// Navigation signal consumed by the T-iOS-15 wiring. `sessionId == nil`
/// = "+ New session" (`attach(null)` downstream). A fresh `id` per request
/// means repeated taps re-fire `onChange` observers.
struct OpenRequest: Equatable, Sendable, Identifiable {
let id: UUID
let host: HostRegistry.Host
let sessionId: UUID?
}
enum EmptyState: Equatable {
/// No hosts in the store pairing is the only next step.
case notPaired
/// Paired + fetched successfully, but nothing is running.
case noSessions
}
// MARK: - Observable state
private(set) var hosts: [HostRegistry.Host] = []
private(set) var activeHost: HostRegistry.Host?
private(set) var rows: [SessionRow] = []
/// Last poll failed (stale rows stay visible). Cleared by the next success.
private(set) var fetchErrorMessage: String?
/// Last kill failed (row already rolled back).
private(set) var killErrorMessage: String?
/// Host store read failed explicit, never a silent empty list.
private(set) var hostsErrorMessage: String?
private(set) var openRequest: OpenRequest?
var emptyState: EmptyState? {
if hasAttemptedHostLoad && hosts.isEmpty { return .notPaired }
if hasLoadedOnce && rows.isEmpty && fetchErrorMessage == nil { return .noSessions }
return nil
}
// MARK: - Dependencies & internal state (not observed)
@ObservationIgnored private let hostStore: any HostStore
@ObservationIgnored private let http: any HTTPTransport
@ObservationIgnored private let clock: any Clock<Duration>
/// Injected time source for telemetry staleness (ms since epoch).
@ObservationIgnored private let nowMs: @Sendable () -> Int
@ObservationIgnored private var pollTask: Task<Void, Never>?
@ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
@ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
@ObservationIgnored private var pendingSessionIds: Set<UUID> = []
@ObservationIgnored private var hasAttemptedHostLoad = false
@ObservationIgnored private var hasLoadedOnce = false
private var apiClient: APIClient? {
activeHost.map { APIClient(endpoint: $0.endpoint, http: http) }
}
init(
hostStore: any HostStore,
http: any HTTPTransport,
clock: any Clock<Duration>,
nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs
) {
self.hostStore = hostStore
self.http = http
self.clock = clock
self.nowMs = nowMs
}
// MARK: - Visibility lifecycle (poll task owner)
/// Screen became visible: load hosts, then poll every `listPollInterval`.
/// Idempotent a second call while polling is a no-op.
func appeared() {
guard pollTask == nil else { return }
pollTask = Task { [weak self] in
await self?.runPollLoop()
}
}
/// Screen left: cancel the poll task (its parked sleep throws
/// `CancellationError` and the loop exits no leaked timer).
func disappeared() {
pollTask?.cancel()
pollTask = nil
releaseFetchWaiters()
}
private func runPollLoop() async {
await reloadHosts()
while !Task.isCancelled {
await refreshOnce()
do {
try await clock.sleep(for: Tunables.listPollInterval, tolerance: nil)
} catch {
return // cancelled while parked the only way sleep throws
}
}
}
// MARK: - Hosts (multi-host header hook)
/// (Re)read the paired hosts. Also called by the T-iOS-15 wiring after the
/// pairing sheet adds one. Keeps `activeHost` if it still exists (picking
/// up renames), else falls back to the first host.
func reloadHosts() async {
do {
hosts = try await hostStore.loadAll()
hostsErrorMessage = nil
} catch {
hosts = []
hostsErrorMessage = SessionListCopy.hostsLoadFailed
}
hasAttemptedHostLoad = true
activeHost = hosts.first(where: { $0.id == activeHost?.id }) ?? hosts.first
}
/// Switch the list to another paired host: reset per-host state and fetch
/// immediately (the poll loop keeps its own cadence).
func selectHost(id: UUID) async {
guard let host = hosts.first(where: { $0.id == id }),
host.id != activeHost?.id else { return }
activeHost = host
latestFetched = []
hiddenKilledIds = []
pendingSessionIds = []
hasLoadedOnce = false
fetchErrorMessage = nil
killErrorMessage = nil
rebuildRows()
await refreshOnce()
}
// MARK: - Fetch (poll tick + pull-to-refresh share one path)
/// Manual refresh (pull-to-refresh).
func refresh() async {
await refreshOnce()
}
private func refreshOnce() async {
defer {
completedFetchCount += 1
resumeFetchWaiters()
}
guard let client = apiClient else { return }
do {
let sessions = try await client.liveSessions()
let fetchedIds = Set(sessions.map(\.id))
latestFetched = sessions
// Prune overlays for sessions the server no longer reports.
hiddenKilledIds = hiddenKilledIds.intersection(fetchedIds)
pendingSessionIds = pendingSessionIds.intersection(fetchedIds)
fetchErrorMessage = nil
hasLoadedOnce = true
} catch {
// Keep the stale rows a blank list on one dropped poll is worse.
fetchErrorMessage = SessionListCopy.fetchFailed(Self.errorDetail(error))
}
rebuildRows()
}
// MARK: - Pending overlay (fed by the WS status side-channel, T-iOS-15)
func setPendingApproval(sessionId: UUID, pending: Bool) {
pendingSessionIds = pending
? pendingSessionIds.union([sessionId])
: pendingSessionIds.subtracting([sessionId])
rebuildRows()
}
// MARK: - Swipe-to-kill (optimistic + rollback)
func kill(sessionId: UUID) async {
guard let client = apiClient,
latestFetched.contains(where: { $0.id == sessionId }) else { return }
killErrorMessage = nil
hiddenKilledIds = hiddenKilledIds.union([sessionId]) // optimistic removal
rebuildRows()
do {
try await client.killSession(id: sessionId)
} catch APIClientError.sessionNotFound {
// Already gone (exited/reaped/killed elsewhere) removal stands.
} catch {
hiddenKilledIds = hiddenKilledIds.subtracting([sessionId]) // rollback
rebuildRows()
killErrorMessage = SessionListCopy.killFailed(Self.errorDetail(error))
}
}
// MARK: - Navigation signals
/// "+ New session" `sessionId: nil` (server spawns a fresh PTY).
func requestNewSession() {
guard let activeHost else { return }
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: nil)
}
/// Open a listed session. Unknown ids are refused at the boundary.
func openSession(id: UUID) {
guard let activeHost, rows.contains(where: { $0.id == id }) else { return }
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: id)
}
// MARK: - Row derivation (immutable rebuild, single owner)
private func rebuildRows() {
let visible = latestFetched.filter { !hiddenKilledIds.contains($0.id) }
let now = nowMs()
rows = Self.groupingExitedLast(visible).map { info in
SessionRow(
info: info,
isPendingApproval: pendingSessionIds.contains(info.id),
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now)
)
}
}
/// Keep the server's order (newest-first, src/session/manager.ts:194) but
/// group `exited:true` at the bottom order preserved inside each group.
static func groupingExitedLast(_ sessions: [LiveSessionInfo]) -> [LiveSessionInfo] {
sessions.filter { !$0.exited } + sessions.filter(\.exited)
}
// MARK: - Error copy helpers
/// `APIClientError` already carries user-facing copy; transport-level
/// errors fall back to their localized description.
private static func errorDetail(_ error: any Error) -> String {
(error as? APIClientError)?.message ?? error.localizedDescription
}
private nonisolated static let millisecondsPerSecond = 1_000.0
nonisolated static func currentTimeMs() -> Int {
Int(Date().timeIntervalSince1970 * millisecondsPerSecond)
}
// MARK: - Deterministic test barriers (same pattern as TerminalViewModel)
/// Completed fetch attempts (successful or not, including no-host ticks).
@ObservationIgnored private(set) var completedFetchCount = 0
private struct CountWaiter {
let target: Int
let continuation: CheckedContinuation<Void, Never>
}
@ObservationIgnored private var fetchWaiters: [CountWaiter] = []
/// Suspends until at least `count` fetches have completed.
func waitUntilFetches(count target: Int) async {
await withCheckedContinuation { continuation in
guard completedFetchCount < target else {
continuation.resume()
return
}
fetchWaiters = fetchWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
private func resumeFetchWaiters() {
let satisfied = fetchWaiters.filter { $0.target <= completedFetchCount }
fetchWaiters = fetchWaiters.filter { $0.target > completedFetchCount }
for waiter in satisfied {
waiter.continuation.resume()
}
}
private func releaseFetchWaiters() {
let all = fetchWaiters
fetchWaiters = []
for waiter in all {
waiter.continuation.resume()
}
}
}
/// User-facing session-list copy (plan §4: explicit errors, actionable ).
enum SessionListCopy {
static let hostsLoadFailed = "读取已配对主机失败,请关闭本页后重进。"
static func fetchFailed(_ detail: String) -> String {
"刷新会话列表失败:\(detail)"
}
static func killFailed(_ detail: String) -> String {
"结束会话失败:\(detail)"
}
}

View File

@@ -0,0 +1,301 @@
import Foundation
import Observation
import SessionCore
import WireProtocol
/// Terminal screen state (T-iOS-11, plan §3.5): consumes the engine's
/// `SessionEvent` stream on the MainActor and turns it into UI state output
/// forwarded to SwiftTerm, connection banner, non-retryable failure copy and
/// the read-only exit state. All outbound traffic (key bar, hardware key
/// commands, SwiftTerm delegate) funnels through here into ONE ordered queue.
///
/// Testability / stream-sharing decision (documented per task brief):
/// `engine.events` is a single-consumer `AsyncStream`, and GateViewModel
/// (T-iOS-14) must eventually observe `.gate`/`.digest` from the SAME stream.
/// So the events stream is injected SEPARATELY from the engine: today callers
/// pass `engine.events` verbatim (tests do exactly that, over
/// `TestSupport.FakeTransport`); the T-iOS-15 wiring may pass a fan-out branch
/// instead without touching this class.
///
/// Swift 6 strict concurrency: the class is `@MainActor`, the terminal sink is
/// `@MainActor`-typed `feed()` off the main actor cannot compile.
@MainActor
@Observable
final class TerminalViewModel {
// MARK: - UI state model
/// Connection banner state (mirrors the web client's status line:
/// public/terminal-session.ts `SessionStatus`).
enum ConnectionBanner: Equatable {
case none
case connecting
/// Retry `attempt` fires after `next` (ReconnectMachine ladder 1s30s).
case reconnecting(attempt: Int, next: Duration)
}
/// Which terminal the user is looking at: a live one, a dead-for-good one
/// (non-retryable failure), or a finished one (read-only).
enum TerminalPhase: Equatable {
case live
/// Non-retryable terminal failure actionable copy instead of a spinner.
case failed(message: String)
/// The shell exited; the terminal stays readable but accepts no input.
case exited(code: Int, reason: String?)
}
/// Actionable copy for `.failed(.replayTooLarge)` (plan §3.2 / §3.2.1
/// coupling warning): reconnecting would deterministically fail forever,
/// so the user must change a knob, not wait.
static let replayTooLargeMessage =
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
private(set) var banner: ConnectionBanner = .none
private(set) var phase: TerminalPhase = .live
/// 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?
/// Read-only = no input reaches the PTY (exit / terminal failure). Resize
/// is NOT gated here the engine owns terminal-state dropping.
var isReadOnly: Bool { phase != .live }
/// Single render input for `ReconnectBanner`: terminal phases win over
/// transient connection states.
var bannerModel: ReconnectBanner.Model? {
switch phase {
case .failed(let message):
return .failed(message: message)
case .exited(let code, let reason):
return .exited(code: code, reason: reason)
case .live:
break
}
switch banner {
case .none:
return nil
case .connecting:
return .connecting
case .reconnecting(let attempt, let next):
return .reconnecting(attempt: attempt, retryIn: next)
}
}
// MARK: - Dependencies & plumbing (not observed)
@ObservationIgnored private let engine: SessionEngine
@ObservationIgnored private let events: AsyncStream<SessionEvent>
@ObservationIgnored private var consumeTask: Task<Void, Never>?
/// Where output bytes go (SwiftTerm's `feed(text:)`). `@MainActor`-typed:
/// feeding off the main actor is a compile error.
@ObservationIgnored private var terminalSink: (@MainActor (String) -> Void)?
/// Output that arrived before the SwiftTerm view existed; flushed in order
/// the moment the sink attaches (replay must never be dropped).
@ObservationIgnored private var pendingOutput: [String] = []
/// Ordered outbound queue: UIKit callbacks are synchronous, `engine.send`
/// is async one pump task preserves submission order (two quick key taps
/// must never race each other onto the wire).
@ObservationIgnored private var sendQueue: [ClientMessage] = []
@ObservationIgnored private var isPumping = false
// MARK: - Test-visible diagnostics & deterministic barriers (internal)
/// Events applied so far `waitUntilProcessed` barrier counter.
@ObservationIgnored private(set) var processedEventCount = 0
/// Sends handed to the engine so far `waitUntilForwarded` barrier counter.
@ObservationIgnored private(set) var forwardedSendCount = 0
/// Input dropped because the terminal is read-only (exit/failed).
@ObservationIgnored private(set) var droppedReadOnlyInputCount = 0
/// Test tap, called after each event is applied (state already coherent).
@ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?
private struct CountWaiter {
let target: Int
let continuation: CheckedContinuation<Void, Never>
}
@ObservationIgnored private var eventWaiters: [CountWaiter] = []
@ObservationIgnored private var sendWaiters: [CountWaiter] = []
// MARK: - Lifecycle
/// - Parameters:
/// - engine: send-side dependency (`send` only `open`/`close` belong to
/// the T-iOS-15 wiring that constructs the engine).
/// - events: the event stream to consume; pass `engine.events` unless a
/// fan-out branch is needed (see type doc).
init(engine: SessionEngine, events: AsyncStream<SessionEvent>) {
self.engine = engine
self.events = events
}
/// Begin consuming events. Idempotent a second call is a no-op (the
/// stream has exactly one consumer).
func start() {
guard consumeTask == nil else { return }
consumeTask = Task { [weak self] in
guard let events = self?.events else { return }
for await event in events {
guard let self else { return }
self.apply(event)
}
self?.releaseAllWaiters() // stream over never leave a test hanging
}
}
/// Stop consuming (screen torn down). Does NOT close the engine detach
/// vs. keep-running is the T-iOS-15 lifecycle owner's call.
func stop() {
consumeTask?.cancel()
consumeTask = nil
releaseAllWaiters()
}
// MARK: - Terminal output sink
/// Attach the SwiftTerm feed target. Buffered output (anything that
/// arrived before the view existed) flushes immediately, in order.
func attachTerminalSink(_ sink: @escaping @MainActor (String) -> Void) {
terminalSink = sink
let buffered = pendingOutput
pendingOutput = []
for chunk in buffered {
sink(chunk)
}
}
// MARK: - Outbound (KeyBar / UIKeyCommand / SwiftTerm delegate)
/// Key-bar or hardware key press: bytes resolved through `KeyByteMap`
/// (the single source of truth plan §7 T-iOS-11).
func send(key: KeyByteMap.Key) {
sendInput(KeyByteMap.bytes(for: key))
}
/// Raw input bytes, verbatim (invariant #9 no content filtering).
/// Dropped (and counted) while the terminal is read-only.
func sendInput(_ data: String) {
guard !isReadOnly else {
droppedReadOnlyInputCount += 1
return
}
enqueueSend(.input(data: data))
}
/// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded
/// the engine validates bounds and owns terminal-state dropping.
func sendResize(cols: Int, rows: Int) {
enqueueSend(.resize(cols: cols, rows: rows))
}
// MARK: - Event application (single consumer, MainActor)
private func apply(_ event: SessionEvent) {
switch event {
case .connection(let state):
applyConnection(state)
case .adopted(let id):
sessionId = id // ALWAYS adopt the server-issued id
case .output(let data):
deliverOutput(data)
case .exited(let code, let reason):
phase = .exited(code: code, reason: reason)
case .gate, .telemetry, .digest:
break // GateViewModel's domain (T-iOS-14; wired in T-iOS-15)
}
processedEventCount += 1
onEventApplied?(event)
resumeEventWaiters()
}
private func applyConnection(_ state: ConnectionState) {
switch state {
case .connecting:
banner = .connecting
case .connected:
banner = .none
case .reconnecting(let attempt, let next):
banner = .reconnecting(attempt: attempt, next: next)
case .closed:
banner = .none // deliberate end; exit/failure phase (if any) stays
case .failed(.replayTooLarge):
banner = .none
phase = .failed(message: Self.replayTooLargeMessage)
}
}
private func deliverOutput(_ data: String) {
guard let terminalSink else {
pendingOutput = pendingOutput + [data]
return
}
terminalSink(data)
}
// MARK: - Ordered send pump
private func enqueueSend(_ message: ClientMessage) {
sendQueue = sendQueue + [message]
guard !isPumping else { return }
isPumping = true
Task { await self.pumpSendQueue() }
}
private func pumpSendQueue() async {
while let next = sendQueue.first {
sendQueue = Array(sendQueue.dropFirst())
await engine.send(next)
forwardedSendCount += 1
resumeSendWaiters()
}
isPumping = false
}
// MARK: - Deterministic test barriers (no polling, no real sleeps)
/// Suspends until at least `eventCount` events have been applied.
func waitUntilProcessed(eventCount target: Int) async {
await withCheckedContinuation { continuation in
guard processedEventCount < target else {
continuation.resume()
return
}
eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
/// Suspends until at least `sendCount` messages were handed to the engine.
func waitUntilForwarded(sendCount target: Int) async {
await withCheckedContinuation { continuation in
guard forwardedSendCount < target else {
continuation.resume()
return
}
sendWaiters = sendWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
private func resumeEventWaiters() {
let satisfied = eventWaiters.filter { $0.target <= processedEventCount }
eventWaiters = eventWaiters.filter { $0.target > processedEventCount }
for waiter in satisfied {
waiter.continuation.resume()
}
}
private func resumeSendWaiters() {
let satisfied = sendWaiters.filter { $0.target <= forwardedSendCount }
sendWaiters = sendWaiters.filter { $0.target > forwardedSendCount }
for waiter in satisfied {
waiter.continuation.resume()
}
}
private func releaseAllWaiters() {
let all = eventWaiters + sendWaiters
eventWaiters = []
sendWaiters = []
for waiter in all {
waiter.continuation.resume()
}
}
}

View File

@@ -0,0 +1,402 @@
import Foundation
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-14 · GateViewModel + gate/digest components (plan §7). All tests run
/// against a REAL `SessionEngine` over `TestSupport.FakeTransport` +
/// `FakeClock` (same testability choice as TerminalViewModelTests): the VM
/// consumes `engine.events` exactly as the T-iOS-15 wiring will zero real
/// waits, zero network, and the engine's `GateTracker.canDecide` second-line
/// guard stays live underneath the VM's first-line tap-epoch guard.
@MainActor
@Suite("GateViewModel")
struct GateViewModelTests {
// MARK: - Server-side frame fixtures (untrusted wire input, hand-built JSON)
private enum ServerFrames {
static func attached(_ id: UUID) -> String {
#"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
}
static func status(pending: Bool, gate: String? = nil, detail: String? = nil) -> String {
var fields = ["\"type\":\"status\"", "\"status\":\"working\"", "\"pending\":\(pending)"]
if let gate { fields.append("\"gate\":\"\(gate)\"") }
if let detail { fields.append("\"detail\":\"\(detail)\"") }
return "{\(fields.joined(separator: ","))}"
}
}
private struct TestStreamError: Error {}
// MARK: - Test doubles
/// Records haptic firings the "exactly once per gate epoch" probe.
@MainActor
private final class HapticRecorder: HapticSignaling {
private(set) var gateArrivalCount = 0
func gateDidArrive() { gateArrivalCount += 1 }
}
// MARK: - Harness
/// Engine over fakes + the VM under test, wired exactly like production.
@MainActor
private final class Harness {
let transport = FakeTransport()
let clock = FakeClock()
let haptics = HapticRecorder()
let engine: SessionEngine
let viewModel: GateViewModel
init(
eventsSource: @escaping @Sendable (UUID) async throws -> [TimelineEvent] = { _ in [] }
) 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: eventsSource
)
viewModel = GateViewModel(
engine: engine, events: engine.events, haptics: haptics, clock: clock
)
viewModel.start()
}
/// Standard preamble: open .connecting .connected server confirms
/// the attach. 3 events reach the VM.
func openAndAdopt(_ id: UUID = UUID()) async {
await engine.open(sessionId: nil, cwd: nil)
await transport.emit(frame: ServerFrames.attached(id))
await viewModel.waitUntilProcessed(eventCount: 3)
}
/// Frames the client sent on connection 0 (attach first, then decisions).
func wireFrames() async -> [String] {
let byConnection = await transport.sentFramesByConnection
return byConnection.first ?? []
}
/// Drop the wire, ride the 1s backoff rung, reconnect, re-adopt `id`
/// the away window that makes the engine emit exactly one digest.
/// Event count after: 7 (3 preamble + reconnecting + connected +
/// adopted + digest).
func reconnectForDigest(_ id: UUID) async {
await transport.emitError(TestStreamError())
await viewModel.waitUntilProcessed(eventCount: 4) // .reconnecting
await clock.waitForSleepers(count: 1) // backoff rung parked
clock.advance(by: .seconds(1))
await transport.emit(frame: ServerFrames.attached(id))
await viewModel.waitUntilProcessed(eventCount: 7)
}
}
// MARK: - Gate routing (tool banner, plan sheet)
@Test("tool gate → two-button banner state; plan gate → three-way sheet state; lifted → neither")
func gateRoutingToolVsPlan() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
// Act: tool gate rises (epoch 1).
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
// Assert: banner state only.
#expect(harness.viewModel.toolGate == GateState(kind: .tool, detail: "Bash", epoch: 1))
#expect(harness.viewModel.planGate == nil)
// Act: gate lifted.
await harness.transport.emit(frame: ServerFrames.status(pending: false))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
// Assert: neither surface renders.
#expect(harness.viewModel.toolGate == nil)
#expect(harness.viewModel.planGate == nil)
// Act: plan gate rises (epoch 2).
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 6)
// Assert: sheet state only.
#expect(harness.viewModel.planGate == GateState(kind: .plan, detail: nil, epoch: 2))
#expect(harness.viewModel.toolGate == nil)
}
// MARK: - Wire mapping (mirror of public/tabs.ts:345-347 / src/types.ts:84-86)
@Test("plan three-way maps EXACTLY: Approve+Auto→acceptEdits · Approve+Review→default · Keep Planning→reject — no allowAutoMode gate anywhere")
func planThreeWayMappingMirrorsWebClient() async throws {
// Arrange: a held plan gate (epoch 1). Note the VM has NO HTTP client
// and never consults /config/ui uiConfig.allowAutoMode is reserved
// for a future permission-mode picker (plan §3.1 note).
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
let epoch = try #require(harness.viewModel.planGate?.epoch)
// Act: all three choices against the held gate (web sends per click).
harness.viewModel.decide(.approveAuto, epoch: epoch)
harness.viewModel.decide(.approveReview, epoch: epoch)
harness.viewModel.decide(.keepPlanning, epoch: epoch)
await harness.viewModel.waitUntilForwarded(decisionCount: 3)
// Assert: exact wire frames, in order never raw `auto`.
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: .acceptEdits)),
MessageCodec.encode(.approve(mode: .default)),
MessageCodec.encode(.reject),
])
}
@Test("tool two-way maps: Approve→approve(mode:nil) · Reject→reject")
func toolTwoWayMapping() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
let epoch = try #require(harness.viewModel.toolGate?.epoch)
// Act
harness.viewModel.decide(.approve, epoch: epoch)
harness.viewModel.decide(.reject, epoch: epoch)
await harness.viewModel.waitUntilForwarded(decisionCount: 2)
// Assert: bare approve (no mode key) then reject.
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: nil)),
MessageCodec.encode(.reject),
])
}
// MARK: - Tap-epoch guard (first line; engine canDecide is the second)
@Test("a tap carrying a stale epoch is dropped and never sent — a slow tap must not approve the NEXT gate")
func staleEpochTapIsDroppedNotSent() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
// Act: tap before any gate ever rose dropped.
harness.viewModel.decide(.approve, epoch: 0)
#expect(harness.viewModel.droppedStaleDecisionCount == 1)
// Arrange: gate 1 rises, resolves, gate 2 rises (the dangerous window).
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
await harness.transport.emit(frame: ServerFrames.status(pending: false))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
await harness.viewModel.waitUntilProcessed(eventCount: 6)
// Act: a tap rendered against gate 1 lands now dropped, not sent.
harness.viewModel.decide(.approve, epoch: 1)
#expect(harness.viewModel.droppedStaleDecisionCount == 2)
#expect(harness.viewModel.forwardedDecisionCount == 0)
// Act: a fresh tap against the CURRENT gate (epoch 2) goes through.
harness.viewModel.decide(.approve, epoch: 2)
await harness.viewModel.waitUntilForwarded(decisionCount: 1)
// Assert: exactly one approve ever reached the wire.
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: nil)),
])
}
@Test("same-epoch kind morph (tool→plan sustained refresh): the tool tap is dropped, the plan choice goes through")
func kindMorphSameEpochDropsForeignAffordance() async throws {
// Arrange: tool gate epoch 1, then a sustained-pending refresh flips
// the kind to plan WITHOUT minting a new epoch (GateTracker semantics).
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
#expect(harness.viewModel.planGate?.epoch == 1)
// Act: the tap rendered on the old TOOL banner is no longer an offered
// affordance dropped even though the epoch still matches.
harness.viewModel.decide(.approve, epoch: 1)
#expect(harness.viewModel.droppedStaleDecisionCount == 1)
// Act: a choice from the CURRENT plan sheet goes through.
harness.viewModel.decide(.approveAuto, epoch: 1)
await harness.viewModel.waitUntilForwarded(decisionCount: 1)
// Assert
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: .acceptEdits)),
])
}
// MARK: - Haptics (exactly once per gate epoch)
@Test("haptic fires exactly once per gate epoch — sustained refreshes and lifts never re-buzz")
func hapticFiresExactlyOncePerGateEpoch() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
#expect(harness.haptics.gateArrivalCount == 0)
// Act: rising edge mints epoch 1 one buzz.
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
#expect(harness.haptics.gateArrivalCount == 1)
// Act: sustained-pending refresh (same epoch, new detail) NO re-buzz.
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Read"))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
#expect(harness.haptics.gateArrivalCount == 1)
// Act: gate lifted no buzz for a nil gate.
await harness.transport.emit(frame: ServerFrames.status(pending: false))
await harness.viewModel.waitUntilProcessed(eventCount: 6)
#expect(harness.haptics.gateArrivalCount == 1)
// Act: a NEW gate (epoch 2) second buzz.
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 7)
#expect(harness.haptics.gateArrivalCount == 2)
}
// MARK: - Away digest (render / fade / expand)
@Test("non-zero digest renders the summary state; the all-zero digest renders nothing and parks no fade timer")
func digestNonZeroRendersAndAllZeroDoesNot() async throws {
// Arrange: one away-window event (far-future `at` passes the `since`
// filter; the engine stamps the disconnect moment with the real Date).
let sessionId = UUID()
let awayEvent = TimelineEvent(
at: Int.max / 2, class: "tool", toolName: "Bash", label: "ran Bash")
let harness = try Harness(eventsSource: { _ in [awayEvent] })
await harness.openAndAdopt(sessionId)
// Act
await harness.reconnectForDigest(sessionId)
// Assert: summary state set, collapsed by default.
#expect(harness.viewModel.digest == AwayDigest(
toolRuns: 1, waitingCount: 0, sawDone: false, sawStuck: false,
recent: [awayEvent]))
#expect(!harness.viewModel.isDigestExpanded)
// Arrange/Act: an empty away timeline `.digest(.empty)`.
let empty = try Harness(eventsSource: { _ in [] })
await empty.openAndAdopt(sessionId)
await empty.reconnectForDigest(sessionId)
// Assert: nothing rendered, and no fade timer was ever scheduled.
#expect(empty.viewModel.digest == nil)
#expect(empty.clock.pendingSleeperCount == 0)
}
@Test("digest auto-fades after Tunables.digestFadeDelay on the injected clock")
func digestAutoFadesAfterDelay() async throws {
// Arrange: a visible digest.
let sessionId = UUID()
let awayEvent = TimelineEvent(
at: Int.max / 2, class: "waiting", toolName: nil, label: "requested approval")
let harness = try Harness(eventsSource: { _ in [awayEvent] })
await harness.openAndAdopt(sessionId)
await harness.reconnectForDigest(sessionId)
#expect(harness.viewModel.digest != nil)
// Act: the fade timer parks on the fake clock; fire it.
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: Tunables.digestFadeDelay)
await harness.viewModel.waitUntilFadeCompleted(count: 1)
// Assert
#expect(harness.viewModel.digest == nil)
}
@Test("manual expand shows recent entries and cancels the fade — an expanded digest never vanishes under the reader")
func expandedDigestNeverFadesAndShowsRecent() async throws {
// Arrange: a visible digest with its fade timer parked.
let sessionId = UUID()
let awayEvent = TimelineEvent(
at: Int.max / 2, class: "tool", toolName: "Edit", label: "edited 3 files")
let harness = try Harness(eventsSource: { _ in [awayEvent] })
await harness.openAndAdopt(sessionId)
await harness.reconnectForDigest(sessionId)
await harness.clock.waitForSleepers(count: 1)
// Act: the user expands the summary row.
harness.viewModel.expandDigest()
// Assert: expanded, recent entries available, fade timer CANCELLED.
#expect(harness.viewModel.isDigestExpanded)
#expect(harness.viewModel.digest?.recent == [awayEvent])
#expect(harness.clock.pendingSleeperCount == 0)
// Act: even way past the fade delay the digest stays.
harness.clock.advance(by: Tunables.digestFadeDelay * 10)
#expect(harness.viewModel.digest != nil)
// Act: explicit dismiss clears everything.
harness.viewModel.dismissDigest()
#expect(harness.viewModel.digest == nil)
#expect(!harness.viewModel.isDigestExpanded)
}
// MARK: - Component copy (mirror of public/tabs.ts:326-350)
@Test("banner/sheet copy and choice labels mirror the web client exactly")
func componentCopyMirrorsWebClient() {
// Tool banner: "Claude wants to use ${pendingTool ?? 'a tool'}".
let toolGate = GateState(kind: .tool, detail: "Bash", epoch: 1)
#expect(GateBanner.message(for: toolGate) == "Claude wants to use Bash")
#expect(GateBanner.message(for: GateState(kind: .tool, detail: nil, epoch: 1))
== "Claude wants to use a tool")
#expect(GateChoiceSpec.specs(for: toolGate).map(\.label)
== ["✓ Approve", "✗ Reject"])
#expect(GateChoiceSpec.specs(for: toolGate).map(\.affordance)
== [.approve, .reject])
// Plan sheet: three-way, exact labels and title.
let planGate = GateState(kind: .plan, detail: nil, epoch: 2)
#expect(GateChoiceSpec.specs(for: planGate).map(\.label)
== ["✓ Approve + Auto", "✓ Approve + Review", "✎ Keep Planning"])
#expect(GateChoiceSpec.specs(for: planGate).map(\.affordance)
== [.approveAuto, .approveReview, .keepPlanning])
#expect(PlanGateSheet.title == "Claude finished planning — how should it proceed?")
}
@Test("digest summary composes non-zero parts only; user-only activity falls back to a count")
func digestSummaryComposition() {
// Arrange / Act / Assert: all four signal parts.
let full = AwayDigest(
toolRuns: 3, waitingCount: 1, sawDone: true, sawStuck: false, recent: [])
#expect(AwayDigestView.summary(for: full)
== "离开期间:工具调用 3 次 · 等待审批 1 次 · 已完成")
// Stuck-only digest.
let stuck = AwayDigest(
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: true, recent: [])
#expect(AwayDigestView.summary(for: stuck) == "离开期间:曾卡住")
// Non-empty digest whose counted signals are all zero (e.g. only
// `user` events) still gets a rendered fallback line.
let userOnly = AwayDigest(
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false,
recent: [TimelineEvent(at: 1, class: "user", toolName: nil, label: "typed a prompt")])
#expect(AwayDigestView.summary(for: userOnly) == "离开期间:活动 1 条")
}
}

View File

@@ -0,0 +1,132 @@
import SessionCore
import Testing
import UIKit
@testable import WebTerm
/// T-iOS-11 · KeyBar component + hardware key commands (plan §7).
/// The bar's layout mirrors `public/keybar.ts` `KEYBAR_BUTTONS` (order, glyphs,
/// captions, primary flag) and EVERY labelbytes lookup goes through
/// `KeyByteMap` no byte literal exists in the App layer.
@MainActor
@Suite("KeyBar")
struct KeyBarTests {
@MainActor
private final class KeyRecorder {
private(set) var keys: [KeyByteMap.Key] = []
func record(_ key: KeyByteMap.Key) { keys = keys + [key] }
}
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
@Test("button order mirrors public/keybar.ts KEYBAR_BUTTONS exactly")
func layoutOrderMirrorsWebKeybarButtons() {
let expectedOrder: [KeyByteMap.Key] = [
.esc, .escEsc, .shiftTab, .arrowUp, .arrowDown, .enter, .ctrlC,
.ctrlR, .ctrlO, .ctrlL, .ctrlT, .ctrlB, .ctrlD, .tab,
.arrowLeft, .arrowRight, .slash,
]
#expect(KeyBarLayout.buttons.map(\.key) == expectedOrder)
}
@Test("glyph labels mirror the web bar; Esc is the only primary button")
func labelsAndPrimaryFlagMirrorWeb() {
let expectedLabels = [
"Esc", "Esc²", "⇧Tab", "", "", "", "^C", "^R", "^O", "^L",
"^T", "^B", "^D", "Tab", "", "", "/",
]
#expect(KeyBarLayout.buttons.map(\.label) == expectedLabels)
#expect(KeyBarLayout.buttons.filter(\.isPrimary).map(\.key) == [.esc])
}
@Test("every button spec resolves to real bytes through KeyByteMap (single source of truth)")
func everySpecResolvesThroughKeyByteMap() {
for spec in KeyBarLayout.buttons {
#expect(!KeyByteMap.bytes(for: spec.key).isEmpty,
"no bytes for \(spec.key.rawValue)")
}
}
// MARK: - KeyBarView (UIKit input accessory)
@Test("KeyBarView builds one button per spec with the web title as accessibility label")
func keyBarViewBuildsOneButtonPerSpec() {
// Arrange / Act
let bar = KeyBarView()
// Assert
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
#expect(bar.keyButtons.map(\.accessibilityLabel)
== KeyBarLayout.buttons.map(\.title))
}
@Test("tapping button i fires onKey with layout key i (bytes resolved downstream via KeyByteMap)")
func tappingButtonsFiresOnKeyInLayoutOrder() {
// Arrange
let bar = KeyBarView()
let recorder = KeyRecorder()
bar.onKey = { recorder.record($0) }
// Act: tap every button once, in order.
for button in bar.keyButtons {
button.sendActions(for: .touchUpInside)
}
// Assert
#expect(recorder.keys == KeyBarLayout.buttons.map(\.key))
}
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
@Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap")
func hardwareChordsCoverEscShiftTabAndCtrlChords() {
let expected: [(KeyByteMap.Key, String, UIKeyModifierFlags)] = [
(.esc, UIKeyCommand.inputEscape, []),
(.shiftTab, "\t", .shift),
(.ctrlC, "c", .control),
(.ctrlR, "r", .control),
(.ctrlO, "o", .control),
(.ctrlL, "l", .control),
(.ctrlT, "t", .control),
(.ctrlB, "b", .control),
(.ctrlD, "d", .control),
]
#expect(HardwareKeyCommands.chords.count == expected.count)
for (key, input, modifiers) in expected {
let chord = HardwareKeyCommands.chords.first { $0.key == key }
#expect(chord?.input == input, "input mismatch for \(key.rawValue)")
#expect(chord?.modifiers == modifiers, "modifiers mismatch for \(key.rawValue)")
#expect(!KeyByteMap.bytes(for: key).isEmpty)
}
}
@Test("build() emits one prioritized UIKeyCommand per chord and key(matching:) round-trips")
func buildEmitsPrioritizedCommandsAndRoundTrips() {
// Arrange / Act
let commands = HardwareKeyCommands.build(
action: #selector(UIResponder.becomeFirstResponder) // any selector; not invoked
)
// Assert
#expect(commands.count == HardwareKeyCommands.chords.count)
for command in commands {
#expect(command.wantsPriorityOverSystemBehavior)
let key = HardwareKeyCommands.key(matching: command)
#expect(key != nil, "no chord matches \(String(describing: command.input))")
}
#expect(Set(commands.compactMap { HardwareKeyCommands.key(matching: $0) }).count
== HardwareKeyCommands.chords.count)
}
@Test("hardware mapping never hijacks plain typing keys or arrows (SwiftTerm owns them — DECCKM)")
func hardwareMappingExcludesPlainKeysAndArrows() {
// Arrows must stay SwiftTerm-native: a fixed \u{1B}[A override would
// break application-cursor-keys mode (vim/htop send \u{1B}OA there).
// Enter/Tab/"/" are ordinary typing; Esc·Esc is two Esc presses.
let excluded: Set<KeyByteMap.Key> = [
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
.enter, .tab, .slash, .escEsc,
]
let chordKeys = Set(HardwareKeyCommands.chords.map(\.key))
#expect(chordKeys.isDisjoint(with: excluded))
}
}

View File

@@ -0,0 +1,433 @@
import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-12 · PairingViewModel (plan §7 / §5.4). Probe LOGIC is T-iOS-8's
/// domain these tests cover the state mapping around it:
/// scan/manual input confirm gate (ZERO network before the user says go)
/// probe Host into the store + navigate signal, error taxonomy copy +
/// action, and the §5.4 warning tiers.
///
/// Determinism: the probe is injected as a closure; scripted results come from
/// an actor-backed `ProbeScript` (also the non-invocation counter). The
/// end-to-end test runs the REAL `runPairingProbe` over `FakeHTTPTransport` +
/// `FakeTransport` zero real network, zero real waits.
@MainActor
@Suite("PairingViewModel")
struct PairingViewModelTests {
// MARK: - Probe script (scripted results + invocation recording)
private actor ProbeScript {
private(set) var calls: [HostEndpoint] = []
private var results: [Result<HostEndpoint, PairingError>]
/// Empty `results` = always succeed with the probed endpoint.
init(results: [Result<HostEndpoint, PairingError>] = []) {
self.results = results
}
func invoke(_ endpoint: HostEndpoint) -> Result<HostEndpoint, PairingError> {
calls = calls + [endpoint]
guard let next = results.first else { return .success(endpoint) }
results = Array(results.dropFirst())
return next
}
}
private func makeViewModel(
store: any HostStore = InMemoryHostStore(),
script: ProbeScript
) -> PairingViewModel {
PairingViewModel(store: store, probe: { await script.invoke($0) })
}
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { [] }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
throw StoreFailure()
}
func remove(id: UUID) async throws -> [HostRegistry.Host] { [] }
}
// MARK: - Scan confirm gate REAL two-step probe store + navigate
@Test("scan shows the HostEndpoint-parsed address, no network until confirm; then probe → Host into store + navigate signal")
func scanConfirmGateThenRealProbePairsHost() async throws {
// Arrange: REAL runPairingProbe over fakes the strongest proof that
// (a) nothing touches the wire before the user confirms and (b) the
// production closure wiring is exercised end to end.
let http = FakeHTTPTransport()
let ws = FakeTransport()
let store = InMemoryHostStore()
let viewModel = PairingViewModel(
store: store,
probe: { await runPairingProbe(endpoint: $0, http: http, ws: ws) }
)
let base = "http://192.168.1.5:3000"
let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
// Script the full happy path UP FRONT if the VM probed before
// confirm, recordedRequests would already be non-empty below.
await http.queueSuccess(
url: try #require(URL(string: "\(base)/live-sessions")),
body: Data("[]".utf8)
)
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(probeSessionId)"}"#)
await http.queueSuccess(
method: "DELETE",
url: try #require(URL(string: "\(base)/live-sessions/\(probeSessionId)")),
status: 204
)
// Act: scan the web UI QR payload (public/qr.ts encodes location.origin).
viewModel.handleScannedCode(base)
// Assert: confirm state shows the single-point-derived address and the
// scanned host has seen ZERO network traffic (probe would GET, probe
// would spawn a PTY on the target untrusted scan input, plan §5).
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(pending.displayAddress == base)
#expect(pending.endpoint.originHeader == base)
#expect(pending.warning == .plaintextLAN)
#expect(await http.recordedRequests.isEmpty)
#expect(await ws.connectAttempts.isEmpty)
#expect(viewModel.pairedHost == nil)
// Act: name the host, then confirm ONLY now may the probe run.
viewModel.hostName = "书房 Mac"
await viewModel.confirmConnect()
// Assert: paired + navigate signal; Host{id,name} constructed by the
// VM (§3.4 contract ruling) and upserted into the store.
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(host.name == "书房 Mac")
#expect(host.endpoint == pending.endpoint)
#expect(viewModel.pairedHost == host)
#expect(try await store.loadAll() == [host])
// Assert: exactly the two probe HTTP calls, in order, Origin stamped
// iff guarded (§3.4 ) and one WS attach round-trip, closed.
let requests = await http.recordedRequests
#expect(requests.map(\.httpMethod) == ["GET", "DELETE"])
#expect(requests[0].value(forHTTPHeaderField: "Origin") == nil)
#expect(requests[1].value(forHTTPHeaderField: "Origin") == base)
#expect(await ws.connectAttempts.count == 1)
#expect(await ws.closeCallCount == 1)
// Assert: a stray scan cannot preempt a finished pairing.
viewModel.handleScannedCode("http://10.0.0.9:3000")
#expect(viewModel.phase == .paired(host))
}
// MARK: - Input boundary: scan payloads are untrusted external input
@Test("non-http(s) scan payloads are rejected with copy and zero probe calls", arguments: [
"ftp://192.168.1.5:3000",
"ws://192.168.1.5:3000",
"javascript:alert(1)",
"WIFI:S:mynet;T:WPA;P:hunter2;;",
"",
])
func scanRejectsNonHTTPPayloads(payload: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.handleScannedCode(payload)
// Assert: stays idle, inline rejection copy, probe never invoked.
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == PairingCopy.scanRejected)
#expect(await script.calls.isEmpty)
}
// MARK: - Manual entry (documented decision: reuses the confirm state)
@Test("manual entry reuses the confirm state; a bare host:port gets the http:// convenience prefix", arguments: [
("http://192.168.1.5:3000", "http://192.168.1.5:3000"),
("192.168.1.5:3000", "http://192.168.1.5:3000"),
("https://mac.tail1234.ts.net", "https://mac.tail1234.ts.net"),
])
func manualEntryEntersConfirmState(input: String, expectedOrigin: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.submitManualURL(input)
// Assert: SAME confirm state as the scan path (uniform §5.4 warning
// surface documented T-iOS-12 decision), still zero probe calls.
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming for \(input), got \(viewModel.phase)")
return
}
#expect(pending.endpoint.originHeader == expectedOrigin)
#expect(await script.calls.isEmpty)
}
@Test("unparseable manual input is rejected with copy", arguments: [
"", " ", "://nope", "http://",
])
func manualEntryRejectsUnparseableInput(input: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.submitManualURL(input)
// Assert
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == PairingCopy.manualRejected)
#expect(await script.calls.isEmpty)
}
// MARK: - §5.4 warning tiers (shown on the confirm page)
@Test("warning tiers follow the §5.4 table", arguments: [
// public host strongest BLOCKING warning, http AND https alike
("http://203.0.113.7:3000", PairingViewModel.SecurityWarning.publicHostBlocking),
("https://example.com", PairingViewModel.SecurityWarning.publicHostBlocking),
// ws:// to RFC1918 / link-local / .local non-blocking plaintext notice
("http://192.168.1.5:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://10.1.2.3:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://172.20.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://169.254.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://mymac.local:3000", PairingViewModel.SecurityWarning.plaintextLAN),
// Tailscale (100.64/10 CGNAT or MagicDNS *.ts.net) no plaintext
// warning (WireGuard already encrypts); positive badge instead
("http://100.101.102.103:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
("http://mac.tail1234.ts.net:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
// loopback none; https to a private-class host none
("http://127.0.0.1:3000", PairingViewModel.SecurityWarning.none),
("http://localhost:3000", PairingViewModel.SecurityWarning.none),
("https://192.168.1.5:3000", PairingViewModel.SecurityWarning.none),
("https://mac.tail1234.ts.net", PairingViewModel.SecurityWarning.none),
])
func warningTiersFollowTable(url: String, expected: PairingViewModel.SecurityWarning) throws {
// Arrange
let baseURL = try #require(URL(string: url))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
// Act & Assert
#expect(PairingViewModel.warning(for: endpoint) == expected)
}
@Test("public-host blocking warning requires explicit acknowledgement before any probe")
func blockingWarningGatesTheProbe() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://203.0.113.7:3000")
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(pending.warning == .publicHostBlocking)
// Act: confirm WITHOUT acknowledging the risk.
await viewModel.confirmConnect()
// Assert: no probe, still confirming, the UI is told to demand the ack.
#expect(await script.calls.isEmpty)
#expect(viewModel.phase == .confirming(pending))
#expect(viewModel.needsPublicRiskAcknowledgement)
// Act: explicit acknowledgement, then confirm again.
viewModel.hasAcknowledgedPublicRisk = true
await viewModel.confirmConnect()
// Assert: probe ran exactly once and pairing completed.
#expect(await script.calls.count == 1)
guard case .paired = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
}
// MARK: - PairingError taxonomy inline copy + recovery action
@Test("every PairingError maps to actionable copy and the right recovery action", arguments: [
(PairingError.localNetworkDenied,
PairingViewModel.RecoveryAction.openLocalNetworkSettings,
["本地网络", "设置"]),
(PairingError.hostUnreachable(underlying: "Connection refused"),
PairingViewModel.RecoveryAction.retry,
["Connection refused"]),
(PairingError.httpOkButNotWebTerminal,
PairingViewModel.RecoveryAction.retry,
["端口"]),
(PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
PairingViewModel.RecoveryAction.retry,
["ALLOWED_ORIGINS=http://192.168.1.5:3000"]),
(PairingError.atsBlocked(host: "198.18.0.1"),
PairingViewModel.RecoveryAction.retry,
["ATS", "198.18.0.1", "tailscale serve", "例外"]),
(PairingError.tlsFailure,
PairingViewModel.RecoveryAction.retry,
["TLS"]),
(PairingError.timeout,
PairingViewModel.RecoveryAction.retry,
["超时"]),
])
func pairingErrorMapsToCopyAndAction(
error: PairingError,
expectedAction: PairingViewModel.RecoveryAction,
requiredFragments: [String]
) async throws {
// Arrange: private-class host so no blocking-warning gate interferes.
let script = ProbeScript(results: [.failure(error)])
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed for \(error), got \(viewModel.phase)")
return
}
#expect(failure.action == expectedAction)
#expect(!failure.message.isEmpty)
for fragment in requiredFragments {
#expect(failure.message.contains(fragment),
"copy for \(error) must contain \(fragment)")
}
}
@Test("originRejected surfaces the probe's hint VERBATIM as the whole message")
func originRejectedHintIsVerbatim() async throws {
// Arrange: the hint the probe derives from endpoint.originHeader is
// already the complete actionable copy never rewrap or re-derive it.
let hint = "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=http://192.168.1.5:3000"
+ "(与 App 连接的 URL 完全一致)后重启 web-terminal再重试配对。"
let script = ProbeScript(results: [.failure(.originRejected(hint: hint))])
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.message == hint)
}
// MARK: - Retry / cancel
@Test("retry re-runs the probe against the same endpoint and can succeed")
func retryRerunsProbeAfterFailure() async throws {
// Arrange: first probe times out, second succeeds.
let script = ProbeScript(results: [.failure(.timeout)])
let store = InMemoryHostStore()
let viewModel = makeViewModel(store: store, script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
await viewModel.confirmConnect()
guard case .failed = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
// Act
await viewModel.retry()
// Assert: two probe calls, same endpoint, pairing completed.
let calls = await script.calls
#expect(calls.count == 2)
#expect(calls.first == calls.last)
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(try await store.loadAll() == [host])
}
@Test("cancel returns to idle without ever probing")
func cancelReturnsToIdleWithoutProbe() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
viewModel.cancel()
// Assert
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == nil)
#expect(await script.calls.isEmpty)
}
// MARK: - Store failure is explicit, never silent
@Test("a store failure after a successful probe surfaces an explicit retryable error")
func storeFailureSurfacesExplicitError() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(store: ThrowingHostStore(), script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert: failed with the dedicated copy; no navigate signal.
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.message == PairingCopy.storeFailed)
#expect(failure.action == .retry)
#expect(viewModel.pairedHost == nil)
}
// MARK: - Host naming
@Test("host name defaults to the endpoint host and user names are trimmed")
func hostNameDefaultsAndTrims() async throws {
// Arrange & Act: untouched name default = endpoint host.
let script = ProbeScript()
let storeA = InMemoryHostStore()
let viewModelA = makeViewModel(store: storeA, script: script)
viewModelA.handleScannedCode("http://192.168.1.5:3000")
#expect(viewModelA.hostName == "192.168.1.5")
await viewModelA.confirmConnect()
// Assert
guard case .paired(let defaultNamed) = viewModelA.phase else {
Issue.record("expected .paired, got \(viewModelA.phase)")
return
}
#expect(defaultNamed.name == "192.168.1.5")
// Arrange & Act: user-typed name is trimmed before storing.
let storeB = InMemoryHostStore()
let viewModelB = makeViewModel(store: storeB, script: script)
viewModelB.handleScannedCode("http://192.168.1.5:3000")
viewModelB.hostName = " 书房 Mac "
await viewModelB.confirmConnect()
// Assert
guard case .paired(let userNamed) = viewModelB.phase else {
Issue.record("expected .paired, got \(viewModelB.phase)")
return
}
#expect(userNamed.name == "书房 Mac")
}
}

View File

@@ -0,0 +1,581 @@
import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-13 · SessionListViewModel (merged chooser + dashboard, plan §7).
///
/// Covers the task RED list end to end, deterministically (zero real waits):
/// - poll cadence on `FakeClock` (`waitForSleepers` barrier + cancellation
/// leak-check on leave),
/// - status dot mapping for all 5 states + the `pending:true` badge priority
/// (overlay `/live-sessions` carries NO pending field, src/types.ts:246-256),
/// - telemetry staleness at the exact `Tunables.telemetryStaleTtlMs` boundary
/// via an injected now-provider,
/// - swipe-to-kill: optimistic removal proven BEFORE the server replies (gated
/// transport), Origin stamped on the DELETE, rollback on failure,
/// - server order kept with `exited:true` grouped at the bottom,
/// - "+ New session" navigation signal carrying `sessionId: nil`.
///
/// All HTTP goes through the REAL `APIClient` over `FakeHTTPTransport`
/// (task brief: LiveSessionInfo decode exists exercise it, don't stub rows).
@MainActor
@Suite("SessionListViewModel")
struct SessionListViewModelTests {
private nonisolated static let base = "http://192.168.1.5:3000"
/// Fixed "now" injected into the VM staleness must be deterministic.
private nonisolated static let nowMs = 1_700_000_100_000
// MARK: - Fixtures
private func makeHost(
_ urlString: String = SessionListViewModelTests.base,
name: String = "书房 Mac"
) throws -> HostRegistry.Host {
let url = try #require(URL(string: urlString))
let endpoint = try #require(HostEndpoint(baseURL: url))
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
}
private func listURL(_ base: String = SessionListViewModelTests.base) throws -> URL {
try #require(URL(string: "\(base)/live-sessions"))
}
private func deleteURL(
id: UUID, base: String = SessionListViewModelTests.base
) throws -> URL {
try #require(URL(string: "\(base)/live-sessions/\(id.uuidString.lowercased())"))
}
/// One `/live-sessions` entry in the server's exact JSON shape.
private func sessionJSON(
id: UUID,
createdAt: Int = 1_700_000_000_000,
clientCount: Int = 1,
status: String = "working",
exited: Bool = false,
cwd: String? = "/Users/dev/proj",
cols: Int = 80,
rows: Int = 24,
telemetry: String? = nil
) -> String {
let cwdJSON = cwd.map { "\"\($0)\"" } ?? "null"
let telemetryJSON = telemetry.map { ",\"telemetry\":\($0)" } ?? ""
return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":\(createdAt)"
+ ",\"clientCount\":\(clientCount),\"status\":\"\(status)\",\"exited\":\(exited)"
+ ",\"cwd\":\(cwdJSON),\"cols\":\(cols),\"rows\":\(rows)\(telemetryJSON)}"
}
private func listBody(_ entries: [String]) -> Data {
Data("[\(entries.joined(separator: ","))]".utf8)
}
private func makeViewModel(
hosts: [HostRegistry.Host],
http: any HTTPTransport,
clock: any Clock<Duration> = FakeClock(),
nowMs: @escaping @Sendable () -> Int = { SessionListViewModelTests.nowMs }
) -> SessionListViewModel {
SessionListViewModel(
hostStore: InMemoryHostStore(hosts: hosts),
http: http,
clock: clock,
nowMs: nowMs
)
}
/// Loads hosts + performs one manual fetch the no-polling arrangement
/// used by every test that does not exercise the poll loop itself.
private func loadOnce(_ viewModel: SessionListViewModel) async {
await viewModel.reloadHosts()
await viewModel.refresh()
}
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] { [] }
func remove(id: UUID) async throws -> [HostRegistry.Host] { [] }
}
/// HTTPTransport wrapper that parks requests of one method until the test
/// opens the gate the deterministic way to observe the OPTIMISTIC state
/// while the DELETE is still in flight.
private actor GatedHTTPTransport: HTTPTransport {
private let inner: FakeHTTPTransport
private let gatedMethod: String
private var isOpen = false
private var gatedArrivalCount = 0
private var gateOpeners: [CheckedContinuation<Void, Never>] = []
private var arrivalWaiters: [CheckedContinuation<Void, Never>] = []
init(inner: FakeHTTPTransport, gatedMethod: String) {
self.inner = inner
self.gatedMethod = gatedMethod.uppercased()
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
if (request.httpMethod ?? "GET").uppercased() == gatedMethod {
gatedArrivalCount += 1
for waiter in arrivalWaiters { waiter.resume() }
arrivalWaiters = []
if !isOpen {
await withCheckedContinuation { gateOpeners.append($0) }
}
}
return try await inner.send(request)
}
/// Suspends until at least one gated request has arrived (and parked).
func waitForGatedArrival() async {
guard gatedArrivalCount == 0 else { return }
await withCheckedContinuation { arrivalWaiters.append($0) }
}
func openGate() {
isOpen = true
for opener in gateOpeners { opener.resume() }
gateOpeners = []
}
}
// MARK: - Poll cadence + leave stops cleanly (task RED item 1)
@Test("visible: polls /live-sessions every listPollInterval; leaving cancels the parked sleeper — no leaked task, zero further traffic")
func pollingCadenceAndLeaveStopsCleanly() async throws {
// Arrange: 3 queued list responses one per expected poll tick.
let clock = FakeClock()
let http = FakeHTTPTransport()
let host = try makeHost()
let url = try listURL()
let sessionId = UUID()
for _ in 0..<3 {
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)]))
}
let viewModel = makeViewModel(hosts: [host], http: http, clock: clock)
// Act: appear (twice idempotence: no second poll loop may spawn).
viewModel.appeared()
viewModel.appeared()
await viewModel.waitUntilFetches(count: 1)
// Assert: first fetch landed and rows are decoded LiveSessionInfo.
#expect(viewModel.rows.map(\.id) == [sessionId])
#expect(viewModel.emptyState == nil)
// Act + Assert: each interval elapsing triggers exactly one more fetch.
await clock.waitForSleepers(count: 1)
clock.advance(by: Tunables.listPollInterval)
await viewModel.waitUntilFetches(count: 2)
await clock.waitForSleepers(count: 1)
clock.advance(by: Tunables.listPollInterval)
await viewModel.waitUntilFetches(count: 3)
// Act: leave the screen while the poll task is parked in sleep.
await clock.waitForSleepers(count: 1)
viewModel.disappeared()
// Assert: cancellation reclaimed the sleeper synchronously (no leaked
// timer) and a long stretch of fake time produces zero new requests.
#expect(clock.pendingSleeperCount == 0)
clock.advance(by: Tunables.listPollInterval * 10)
let requestCount = await http.recordedRequests.count
#expect(requestCount == 3)
}
// MARK: - Empty states
@Test("no paired hosts → .notPaired empty state and ZERO network traffic")
func notPairedEmptyStateWithoutTraffic() async throws {
// Arrange
let http = FakeHTTPTransport()
let viewModel = makeViewModel(hosts: [], http: http)
// Act
viewModel.appeared()
await viewModel.waitUntilFetches(count: 1)
// Assert
#expect(viewModel.emptyState == .notPaired)
#expect(await http.recordedRequests.isEmpty)
viewModel.disappeared()
}
@Test("empty list from the host → .noSessions; a later non-empty fetch clears it")
func noSessionsEmptyStateThenPopulated() async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let url = try listURL()
await http.queueSuccess(url: url, body: listBody([]))
let viewModel = makeViewModel(hosts: [host], http: http)
// Act & Assert: loaded + empty .noSessions (distinct from not-paired).
await loadOnce(viewModel)
#expect(viewModel.emptyState == .noSessions)
// Act & Assert: pull-to-refresh path fetches on demand and clears it.
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: UUID())]))
await viewModel.refresh()
#expect(viewModel.rows.count == 1)
#expect(viewModel.emptyState == nil)
}
@Test("host store failure surfaces explicit copy — never a silent empty list")
func hostStoreFailureIsExplicit() async throws {
// Arrange
let viewModel = SessionListViewModel(
hostStore: ThrowingHostStore(),
http: FakeHTTPTransport(),
clock: FakeClock(),
nowMs: { Self.nowMs }
)
// Act
await viewModel.reloadHosts()
// Assert
#expect(viewModel.hosts.isEmpty)
#expect(viewModel.hostsErrorMessage == SessionListCopy.hostsLoadFailed)
}
// MARK: - Status dots (5 states) + pending badge priority
@Test("status dot maps every ClaudeStatus state from the wire", arguments: [
("working", ClaudeStatus.working),
("waiting", ClaudeStatus.waiting),
("idle", ClaudeStatus.idle),
("unknown", ClaudeStatus.unknown),
("stuck", ClaudeStatus.stuck),
])
func statusDotMapsFiveStates(raw: String, expected: ClaudeStatus) async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let sessionId = UUID()
await http.queueSuccess(
url: try listURL(), body: listBody([sessionJSON(id: sessionId, status: raw)])
)
let viewModel = makeViewModel(hosts: [host], http: http)
// Act
await loadOnce(viewModel)
// Assert
#expect(viewModel.rows.first?.indicator == .status(expected))
}
@Test("pending overlay wins over the status dot with a ⚠ badge, and clears back")
func pendingBadgeTakesPriority() async throws {
// Arrange: a WORKING session the strongest contrast to the badge.
let http = FakeHTTPTransport()
let host = try makeHost()
let sessionId = UUID()
await http.queueSuccess(
url: try listURL(),
body: listBody([sessionJSON(id: sessionId, status: "working")])
)
let viewModel = makeViewModel(hosts: [host], http: http)
await loadOnce(viewModel)
#expect(viewModel.rows.first?.indicator == .status(.working))
// Act: gate arrives (status frame pending:true T-iOS-15 wiring calls this).
viewModel.setPendingApproval(sessionId: sessionId, pending: true)
// Assert: badge PRIORITY status stays working underneath.
#expect(viewModel.rows.first?.indicator == .pendingApproval)
#expect(viewModel.rows.first?.info.status == .working)
// Act & Assert: gate resolved dot returns.
viewModel.setPendingApproval(sessionId: sessionId, pending: false)
#expect(viewModel.rows.first?.indicator == .status(.working))
}
@Test("indicator glyphs mirror public/tabs.ts claudeIcon, ⚠ for pending")
func indicatorGlyphsMirrorWeb() {
#expect(SessionListViewModel.StatusIndicator.status(.working).glyph == "")
#expect(SessionListViewModel.StatusIndicator.status(.waiting).glyph == "")
#expect(SessionListViewModel.StatusIndicator.status(.idle).glyph == "")
#expect(SessionListViewModel.StatusIndicator.status(.stuck).glyph == "")
#expect(SessionListViewModel.StatusIndicator.status(.unknown).glyph == "")
#expect(SessionListViewModel.StatusIndicator.pendingApproval.glyph == "")
}
// MARK: - Telemetry staleness (injected now-provider, exact boundary)
@Test("telemetry chips grey strictly past telemetryStaleTtlMs; at the boundary they stay live; no telemetry → no chips")
func telemetryStalenessBoundary() async throws {
// Arrange: `at` exactly TTL old (NOT stale web rule is strict `>`,
// public/preview-grid.ts), one 1 ms older (stale), one without telemetry.
let http = FakeHTTPTransport()
let host = try makeHost()
let boundaryId = UUID()
let staleId = UUID()
let bareId = UUID()
let boundaryAt = Self.nowMs - Tunables.telemetryStaleTtlMs
let staleAt = Self.nowMs - Tunables.telemetryStaleTtlMs - 1
await http.queueSuccess(url: try listURL(), body: listBody([
sessionJSON(id: boundaryId, telemetry: "{\"at\":\(boundaryAt),\"costUsd\":0.5}"),
sessionJSON(id: staleId, telemetry: "{\"at\":\(staleAt),\"costUsd\":0.5}"),
sessionJSON(id: bareId),
]))
let viewModel = makeViewModel(hosts: [host], http: http)
// Act
await loadOnce(viewModel)
// Assert
let rowsById = Dictionary(uniqueKeysWithValues: viewModel.rows.map { ($0.id, $0) })
#expect(rowsById[boundaryId]?.telemetry?.isStale == false)
#expect(rowsById[staleId]?.telemetry?.isStale == true)
#expect(rowsById[bareId]?.telemetry == nil)
}
@Test("chip model mirrors the web gauge: $ cost to 4 places, ctx warn > 80, PR link https-only")
func telemetryChipModelContent() throws {
// Arrange: http:// PR URL the SEC-L5 mirror must refuse to link it.
let telemetry = StatusTelemetry(
contextUsedPct: 91.4,
costUsd: 0.1234,
model: "opus",
pr: PrInfo(number: 7, url: "http://github.com/x/pull/7", reviewState: "approved"),
at: Self.nowMs
)
// Act
let model = try #require(TelemetryChips.Model(telemetry: telemetry, nowMs: Self.nowMs))
// Assert
#expect(model.costText == "$0.1234")
#expect(model.contextText == "ctx 91%")
#expect(model.isContextWarning)
#expect(model.modelName == "opus")
#expect(model.prText == "PR #7")
#expect(model.prReviewState == "approved")
#expect(model.prURL == nil) // http never becomes a tappable link
#expect(!model.isStale)
// Act & Assert: https URL IS linkable; low ctx carries no warning.
let httpsTelemetry = StatusTelemetry(
contextUsedPct: 40,
pr: PrInfo(number: 7, url: "https://github.com/x/pull/7"),
at: Self.nowMs
)
let httpsModel = try #require(
TelemetryChips.Model(telemetry: httpsTelemetry, nowMs: Self.nowMs)
)
#expect(httpsModel.prURL != nil)
#expect(!httpsModel.isContextWarning)
#expect(TelemetryChips.Model(telemetry: nil, nowMs: Self.nowMs) == nil)
}
// MARK: - Ordering: server order kept, exited grouped at the bottom
@Test("rows keep the server's order with exited sessions grouped last, order preserved inside each group")
func orderingGroupsExitedAtBottom() async throws {
// Arrange: server (newest-first) order interleaves exited sessions.
let http = FakeHTTPTransport()
let host = try makeHost()
let liveA = UUID()
let exitedB = UUID()
let liveC = UUID()
let exitedD = UUID()
await http.queueSuccess(url: try listURL(), body: listBody([
sessionJSON(id: liveA),
sessionJSON(id: exitedB, exited: true),
sessionJSON(id: liveC),
sessionJSON(id: exitedD, exited: true),
]))
let viewModel = makeViewModel(hosts: [host], http: http)
// Act
await loadOnce(viewModel)
// Assert
#expect(viewModel.rows.map(\.id) == [liveA, liveC, exitedB, exitedD])
}
// MARK: - Swipe-to-kill: optimistic + Origin + rollback
@Test("kill removes the row optimistically BEFORE the server replies, stamps Origin on the DELETE, stays removed on 204")
func killOptimisticRemovalThenConfirmed() async throws {
// Arrange: DELETE parks at a gate so the in-flight state is observable.
let inner = FakeHTTPTransport()
let gated = GatedHTTPTransport(inner: inner, gatedMethod: "DELETE")
let host = try makeHost()
let targetId = UUID()
let otherId = UUID()
await inner.queueSuccess(
url: try listURL(),
body: listBody([sessionJSON(id: targetId), sessionJSON(id: otherId)])
)
await inner.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 204)
let viewModel = makeViewModel(hosts: [host], http: gated)
await loadOnce(viewModel)
#expect(viewModel.rows.map(\.id) == [targetId, otherId])
// Act: swipe-to-kill; hold the DELETE at the gate.
let killTask = Task { await viewModel.kill(sessionId: targetId) }
await gated.waitForGatedArrival()
// Assert: the row is ALREADY gone while the server has not answered.
#expect(viewModel.rows.map(\.id) == [otherId])
#expect(viewModel.killErrorMessage == nil)
// Act: let the 204 through.
await gated.openGate()
await killTask.value
// Assert: still removed; the DELETE carried the exact Origin (G ).
#expect(viewModel.rows.map(\.id) == [otherId])
let deleteRequest = await inner.recordedRequests.first { $0.httpMethod == "DELETE" }
#expect(deleteRequest?.url == (try deleteURL(id: targetId)))
#expect(deleteRequest?.value(forHTTPHeaderField: "Origin") == Self.base)
}
@Test("kill failure rolls the row back at its original position with explicit copy")
func killFailureRollsBack() async throws {
// Arrange: the Origin guard rejects the DELETE (403).
let http = FakeHTTPTransport()
let host = try makeHost()
let targetId = UUID()
let otherId = UUID()
await http.queueSuccess(
url: try listURL(),
body: listBody([sessionJSON(id: targetId), sessionJSON(id: otherId)])
)
await http.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 403)
let viewModel = makeViewModel(hosts: [host], http: http)
await loadOnce(viewModel)
// Act
await viewModel.kill(sessionId: targetId)
// Assert: rollback restored the ORIGINAL position, copy is actionable.
#expect(viewModel.rows.map(\.id) == [targetId, otherId])
#expect(viewModel.killErrorMessage
== SessionListCopy.killFailed(APIClientError.forbidden.message))
}
@Test("kill on an already-gone session (404) keeps it removed without an error")
func killAlreadyGoneStaysRemoved() async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let targetId = UUID()
await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: targetId)]))
await http.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 404)
let viewModel = makeViewModel(hosts: [host], http: http)
await loadOnce(viewModel)
// Act
await viewModel.kill(sessionId: targetId)
// Assert: 404 = session already dead the optimistic removal was right.
#expect(viewModel.rows.isEmpty)
#expect(viewModel.killErrorMessage == nil)
}
// MARK: - Navigation signals
@Test("+ New session emits an OpenRequest with sessionId nil; row taps carry the id; repeats re-fire")
func navigationSignals() 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 = makeViewModel(hosts: [host], http: http)
await loadOnce(viewModel)
#expect(viewModel.openRequest == nil)
// Act & Assert: "+ New session" sessionId nil (attach(null) downstream).
viewModel.requestNewSession()
let newRequest = try #require(viewModel.openRequest)
#expect(newRequest.sessionId == nil)
#expect(newRequest.host == host)
// Act & Assert: opening a listed session carries its id.
viewModel.openSession(id: sessionId)
let openRequest = try #require(viewModel.openRequest)
#expect(openRequest.sessionId == sessionId)
#expect(openRequest.host == host)
// Act & Assert: the SAME tap again is a fresh signal (unique id).
viewModel.openSession(id: sessionId)
let repeated = try #require(viewModel.openRequest)
#expect(repeated.sessionId == sessionId)
#expect(repeated.id != openRequest.id)
// Act & Assert: unknown ids are refused at the boundary.
viewModel.openSession(id: UUID())
#expect(viewModel.openRequest?.id == repeated.id)
}
// MARK: - Host switching (multi-host header hook)
@Test("switching hosts refetches from the new endpoint and resets the list")
func hostSwitchRefetches() async throws {
// Arrange: two paired hosts with distinct session lists.
let baseB = "http://10.0.0.7:3000"
let hostA = try makeHost()
let hostB = try makeHost(baseB, name: "工作室 Mac")
let http = FakeHTTPTransport()
let sessionA = UUID()
let sessionB = UUID()
await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionA)]))
await http.queueSuccess(
url: try listURL(baseB), body: listBody([sessionJSON(id: sessionB)])
)
let viewModel = makeViewModel(hosts: [hostA, hostB], http: http)
await loadOnce(viewModel)
#expect(viewModel.activeHost == hostA)
#expect(viewModel.rows.map(\.id) == [sessionA])
// Act
await viewModel.selectHost(id: hostB.id)
// Assert: list now comes from host B; navigation carries host B.
#expect(viewModel.activeHost == hostB)
#expect(viewModel.rows.map(\.id) == [sessionB])
viewModel.requestNewSession()
#expect(viewModel.openRequest?.host == hostB)
let lastURL = await http.recordedRequests.last?.url
#expect(lastURL == (try listURL(baseB)))
}
// MARK: - Fetch failure keeps the stale list (explicit error, no wipe)
@Test("a failed poll keeps the last good rows and surfaces copy; the next success clears it")
func fetchFailureKeepsStaleRows() async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let url = try listURL()
let sessionId = UUID()
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)]))
await http.queueSuccess(url: url, status: 500)
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)]))
let viewModel = makeViewModel(hosts: [host], http: http)
await loadOnce(viewModel)
#expect(viewModel.rows.map(\.id) == [sessionId])
// Act: the 500 poll.
await viewModel.refresh()
// Assert: stale rows survive, error copy is explicit (includes status).
#expect(viewModel.rows.map(\.id) == [sessionId])
let message = try #require(viewModel.fetchErrorMessage)
#expect(message.contains("500"))
#expect(viewModel.emptyState == nil)
// Act & Assert: recovery clears the error.
await viewModel.refresh()
#expect(viewModel.fetchErrorMessage == nil)
}
}

View File

@@ -0,0 +1,280 @@
import Foundation
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-11 · TerminalViewModel (plan §7). All tests run against a REAL
/// `SessionEngine` over `TestSupport.FakeTransport` + `FakeClock` (the task's
/// documented testability choice): the VM consumes `engine.events` exactly as
/// production will, with zero real waits and zero network.
///
/// Determinism: the VM's internal `waitUntilProcessed(eventCount:)` /
/// `waitUntilForwarded(sendCount:)` barriers and the `onEventApplied` tap are
/// the "event reached the VM" signals never polling, never sleeping.
@MainActor
@Suite("TerminalViewModel")
struct TerminalViewModelTests {
// MARK: - Server-side frame fixtures (untrusted wire input, hand-built JSON)
private enum ServerFrames {
static func attached(_ id: UUID) -> String {
#"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
}
static func output(_ data: String) -> String {
#"{"type":"output","data":"\#(data)"}"#
}
static func exit(code: Int, reason: String) -> String {
#"{"type":"exit","code":\#(code),"reason":"\#(reason)"}"#
}
}
private struct TestStreamError: Error {}
// MARK: - Harness
/// Engine over fakes + the VM under test, wired exactly like production
/// (T-iOS-15 passes `engine.events`; a fan-out branch arrives only when
/// GateViewModel shares the stream).
@MainActor
private final class Harness {
let transport = FakeTransport()
let clock = FakeClock()
let engine: SessionEngine
let viewModel: TerminalViewModel
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 [] }
)
viewModel = TerminalViewModel(engine: engine, events: engine.events)
viewModel.start()
}
/// Standard preamble: open .connecting .connected server confirms
/// the attach. 3 events reach the VM.
func openAndAdopt(_ id: UUID) async {
await engine.open(sessionId: nil, cwd: nil)
await transport.emit(frame: ServerFrames.attached(id))
await viewModel.waitUntilProcessed(eventCount: 3)
}
}
/// Records `(banner, phase)` after every applied event the deterministic
/// way to observe transient states (`.connecting` is often already
/// superseded by the time a barrier-based await resumes).
@MainActor
private final class StateRecorder {
private(set) var banners: [TerminalViewModel.ConnectionBanner] = []
func attach(to viewModel: TerminalViewModel) {
viewModel.onEventApplied = { [weak self, weak viewModel] _ in
guard let self, let viewModel else { return }
self.banners = self.banners + [viewModel.banner]
}
}
}
// MARK: - Output forwarding (feed order, MainActor by construction)
@Test("output forwards to the terminal sink in arrival order; a late sink flushes the buffer first")
func outputForwardsInOrderAndLateSinkFlushesBuffer() async throws {
// Arrange
let harness = try Harness()
let sessionId = UUID()
await harness.openAndAdopt(sessionId)
// Act: two chunks arrive BEFORE any sink exists (screen not built yet).
await harness.transport.emit(frame: ServerFrames.output("one"))
await harness.transport.emit(frame: ServerFrames.output("two"))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
// The sink attaches late (SwiftTerm view just got created): buffered
// chunks must flush immediately, in order. The sink closure is
// @MainActor-typed feed-on-main is compiler-enforced (Swift 6).
let received = Recorder()
harness.viewModel.attachTerminalSink { received.append($0) }
// Assert: buffered flush preserved order.
#expect(received.chunks == ["one", "two"])
// Act: live output after the sink is attached appends behind it.
await harness.transport.emit(frame: ServerFrames.output("three"))
await harness.viewModel.waitUntilProcessed(eventCount: 6)
// Assert
#expect(received.chunks == ["one", "two", "three"])
#expect(harness.viewModel.sessionId == sessionId)
}
@MainActor
private final class Recorder {
private(set) var chunks: [String] = []
func append(_ chunk: String) { chunks = chunks + [chunk] }
}
@Test("start() is idempotent — a second start never double-delivers output")
func startIsIdempotent() async throws {
// Arrange
let harness = try Harness()
harness.viewModel.start() // second call must be a no-op
await harness.openAndAdopt(UUID())
let received = Recorder()
harness.viewModel.attachTerminalSink { received.append($0) }
// Act
await harness.transport.emit(frame: ServerFrames.output("once"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
// Assert
#expect(received.chunks == ["once"])
}
// MARK: - Reconnect banner
@Test("banner: connecting → hidden on connect → reconnecting(attempt) on drop → hidden again after recovery")
func bannerFollowsConnectionLifecycle() async throws {
// Arrange
let harness = try Harness()
let recorder = StateRecorder()
recorder.attach(to: harness.viewModel)
await harness.openAndAdopt(UUID())
// Act: the transport drops engine enters backoff (1s first rung).
await harness.transport.emitError(TestStreamError())
await harness.viewModel.waitUntilProcessed(eventCount: 4)
// Assert: the reconnect banner is up, carrying attempt + retry delay.
#expect(harness.viewModel.banner
== .reconnecting(attempt: 1, next: .seconds(1)))
#expect(harness.viewModel.bannerModel
== .reconnecting(attempt: 1, retryIn: .seconds(1)))
// Act: fire the backoff timer the engine reconnects and re-attaches.
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: .seconds(1))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
// Assert: banner hidden again; full observed banner timeline is exact
// (events: connecting, connected, adopted, reconnecting, connected).
#expect(harness.viewModel.banner == .none)
#expect(harness.viewModel.bannerModel == nil)
#expect(recorder.banners == [
.connecting,
.none,
.none,
.reconnecting(attempt: 1, next: .seconds(1)),
.none,
])
}
// MARK: - Non-retryable failure (replayTooLarge)
@Test("replayTooLarge → non-retrying failed state with the actionable SCROLLBACK_BYTES copy; no reconnect attempt")
func replayTooLargeBecomesNonRetryingErrorWithActionableCopy() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt(UUID())
// Act: the transport fails the way an oversized single-frame replay
// does (EMSGSIZE typed error, T-iOS-9).
await harness.transport.emitError(TermTransportError.replayTooLarge)
await harness.viewModel.waitUntilProcessed(eventCount: 4)
// Assert: explicit error state with the plan §3.2 actionable copy
// NOT a spinner, NOT a reconnecting banner.
#expect(harness.viewModel.phase
== .failed(message: TerminalViewModel.replayTooLargeMessage))
#expect(TerminalViewModel.replayTooLargeMessage
== "服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限")
#expect(harness.viewModel.bannerModel
== .failed(message: TerminalViewModel.replayTooLargeMessage))
#expect(harness.viewModel.isReadOnly)
// Assert: deterministic failure never enters the backoff loop the
// one original connect stays the only one, and no retry timer sleeps.
#expect(await harness.transport.connectAttempts.count == 1)
#expect(harness.clock.pendingSleeperCount == 0)
}
// MARK: - Exit read-only
@Test("exited → read-only terminal + exit banner; input is dropped, never sent")
func exitedBecomesReadOnlyAndDropsInput() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt(UUID())
// Act
await harness.transport.emit(frame: ServerFrames.exit(code: 0, reason: "bye"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
// Assert: read-only exit state, surfaced as the exit banner.
#expect(harness.viewModel.phase == .exited(code: 0, reason: "bye"))
#expect(harness.viewModel.isReadOnly)
#expect(harness.viewModel.bannerModel == .exited(code: 0, reason: "bye"))
// Act: typing after exit both key-bar and delegate paths.
harness.viewModel.send(key: .esc)
harness.viewModel.sendInput("x")
// Assert: dropped at the VM boundary (counted, not forwarded).
#expect(harness.viewModel.droppedReadOnlyInputCount == 2)
#expect(harness.viewModel.forwardedSendCount == 0)
let frames = await harness.transport.sentFramesByConnection[0]
#expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
}
// MARK: - Outbound sends (single ordered pipeline through KeyByteMap)
@Test("key + text + resize forward to the engine in submission order; key bytes come from KeyByteMap")
func keyAndTextSendsForwardInOrderThroughKeyByteMap() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt(UUID())
// Act: rapid mixed submissions (two taps around typed text + a fit).
harness.viewModel.send(key: .esc)
harness.viewModel.sendInput("abc")
harness.viewModel.send(key: .enter)
harness.viewModel.sendResize(cols: 120, rows: 40)
await harness.viewModel.waitUntilForwarded(sendCount: 4)
// Assert: exact wire frames, in order, bytes resolved via KeyByteMap
// (Esc = 0x1B, Enter = \r never \n).
let frames = await harness.transport.sentFramesByConnection[0]
#expect(frames == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.input(data: KeyByteMap.bytes(for: .esc))),
MessageCodec.encode(.input(data: "abc")),
MessageCodec.encode(.input(data: KeyByteMap.bytes(for: .enter))),
MessageCodec.encode(.resize(cols: 120, rows: 40)),
])
}
@Test("resize is NOT gated by read-only (engine owns terminal-state dropping)")
func resizeStillForwardsAfterExit() async throws {
// Arrange: exited session (read-only for INPUT only).
let harness = try Harness()
await harness.openAndAdopt(UUID())
await harness.transport.emit(frame: ServerFrames.exit(code: 1, reason: "done"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
// Act: a layout pass after exit still reports dims; the VM forwards and
// the ENGINE (already terminal) is the layer that drops it.
harness.viewModel.sendResize(cols: 80, rows: 24)
await harness.viewModel.waitUntilForwarded(sendCount: 1)
// Assert: VM forwarded (no read-only drop); no wire frame appeared
// because the engine discarded it post-exit.
#expect(harness.viewModel.droppedReadOnlyInputCount == 0)
let frames = await harness.transport.sentFramesByConnection[0]
#expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
}
}