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:
111
ios/App/WebTerm/Components/AwayDigestView.swift
Normal file
111
ios/App/WebTerm/Components/AwayDigestView.swift
Normal 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, oldest→newest. 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)
|
||||
}
|
||||
}
|
||||
86
ios/App/WebTerm/Components/GateBanner.swift
Normal file
86
ios/App/WebTerm/Components/GateBanner.swift
Normal 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 affordance→ClientMessage 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
|
||||
}
|
||||
}
|
||||
}
|
||||
227
ios/App/WebTerm/Components/KeyBar.swift
Normal file
227
ios/App/WebTerm/Components/KeyBar.swift
Normal 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 label→bytes 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
|
||||
}
|
||||
}
|
||||
73
ios/App/WebTerm/Components/PlanGateSheet.swift
Normal file
73
ios/App/WebTerm/Components/PlanGateSheet.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
96
ios/App/WebTerm/Components/ReconnectBanner.swift
Normal file
96
ios/App/WebTerm/Components/ReconnectBanner.swift
Normal 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) · 终端已只读"
|
||||
}
|
||||
}
|
||||
127
ios/App/WebTerm/Components/TelemetryChips.swift
Normal file
127
ios/App/WebTerm/Components/TelemetryChips.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
350
ios/App/WebTerm/Screens/PairingScreen.swift
Normal file
350
ios/App/WebTerm/Screens/PairingScreen.swift
Normal 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 serve(wss)。"
|
||||
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)。可改用手输地址。"
|
||||
}
|
||||
}
|
||||
235
ios/App/WebTerm/Screens/SessionListScreen.swift
Normal file
235
ios/App/WebTerm/Screens/SessionListScreen.swift
Normal 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) 台设备在看"
|
||||
}
|
||||
}
|
||||
145
ios/App/WebTerm/Screens/TerminalScreen.swift
Normal file
145
ios/App/WebTerm/Screens/TerminalScreen.swift
Normal 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 label→bytes
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
319
ios/App/WebTerm/ViewModels/GateViewModel.swift
Normal file
319
ios/App/WebTerm/ViewModels/GateViewModel.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
398
ios/App/WebTerm/ViewModels/PairingViewModel.swift
Normal file
398
ios/App/WebTerm/ViewModels/PairingViewModel.swift
Normal 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 ws→loopback: nothing to warn about.
|
||||
case none
|
||||
/// ws→100.64/10 or `*.ts.net`: WireGuard already encrypts — no
|
||||
/// plaintext warning, an optional positive badge instead.
|
||||
case tailscaleEncrypted
|
||||
/// ws→RFC1918 / 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,或反馈该网段。"
|
||||
}
|
||||
}
|
||||
351
ios/App/WebTerm/ViewModels/SessionListViewModel.swift
Normal file
351
ios/App/WebTerm/ViewModels/SessionListViewModel.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
301
ios/App/WebTerm/ViewModels/TerminalViewModel.swift
Normal file
301
ios/App/WebTerm/ViewModels/TerminalViewModel.swift
Normal 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 1s→30s).
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user