feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
This commit is contained in:
@@ -54,9 +54,26 @@ struct KeyBarButtonSpec: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Push-to-talk outlets for the 🎤 key (T-iOS-31). Supplied as a PAIR — a mic
|
||||
/// that can start but not stop would leave the microphone hot.
|
||||
struct KeyBarVoiceHandlers {
|
||||
let onDown: @MainActor () -> Void
|
||||
let onUp: @MainActor () -> Void
|
||||
}
|
||||
|
||||
/// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS`
|
||||
/// (most-used Claude Code keys first). The 🎤 voice button is P2 (T-iOS-31).
|
||||
/// (most-used Claude Code keys first), plus the 🎤 push-to-talk key
|
||||
/// (T-iOS-31) which mirrors `keybar.ts buildMicButton` — appended at the END,
|
||||
/// only when voice handlers are supplied, and deliberately OUTSIDE
|
||||
/// `buttons`/`KeyByteMap`: it maps to no bytes at all.
|
||||
enum KeyBarLayout {
|
||||
/// 🎤 glyph + caption, transcribed from `keybar.ts buildMicButton`.
|
||||
static let voiceLabel = "🎤"
|
||||
static let voiceCaption = "语音"
|
||||
/// Accessibility title. Unlike the web (Chrome ships audio to Google), iOS
|
||||
/// prefers on-device recognition — the copy says what actually happens.
|
||||
static let voiceTitle = "按住说话 — 转成文字后要你确认才送进终端"
|
||||
|
||||
static let buttons: [KeyBarButtonSpec] = [
|
||||
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
|
||||
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
|
||||
@@ -117,10 +134,19 @@ private enum KeyBarMetrics {
|
||||
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).
|
||||
/// Built buttons in layout order (test-visible). The 🎤 key is NOT in here —
|
||||
/// it sends no bytes, so it stays out of the KeyByteMap-backed list.
|
||||
private(set) var keyButtons: [UIButton] = []
|
||||
/// The 🎤 push-to-talk key, nil unless voice handlers were supplied.
|
||||
private(set) var voiceButton: UIButton?
|
||||
|
||||
init() {
|
||||
private let voice: KeyBarVoiceHandlers?
|
||||
|
||||
/// - Parameter voice: press/release outlets for the 🎤 key (T-iOS-31).
|
||||
/// nil ⇒ no mic key at all (mirrors the web bar, which only renders it
|
||||
/// when speech is supported AND a trigger is supplied).
|
||||
init(voice: KeyBarVoiceHandlers? = nil) {
|
||||
self.voice = voice
|
||||
super.init(
|
||||
frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight),
|
||||
inputViewStyle: .keyboard
|
||||
@@ -150,6 +176,11 @@ final class KeyBarView: UIInputView {
|
||||
keyButtons = keyButtons + [button]
|
||||
stack.addArrangedSubview(button)
|
||||
}
|
||||
if let voice {
|
||||
let mic = makeVoiceButton(voice)
|
||||
voiceButton = mic
|
||||
stack.addArrangedSubview(mic)
|
||||
}
|
||||
|
||||
let scroll = UIScrollView()
|
||||
scroll.showsHorizontalScrollIndicator = false
|
||||
@@ -177,41 +208,67 @@ final class KeyBarView: UIInputView {
|
||||
}
|
||||
|
||||
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
|
||||
// Keycap look: primary (Esc) wears the accent tint, the rest a subtle
|
||||
// gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is the
|
||||
// UIKit twin of DS.Palette.textSecondary — the DS UIColor surface only
|
||||
// vends the accent, so native system labels stand in at this boundary.)
|
||||
var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .gray()
|
||||
config.cornerStyle = .fixed
|
||||
config.background.cornerRadius = DS.Radius.sm8
|
||||
var label = AttributedString(spec.label)
|
||||
label.font = UIFont.monospacedSystemFont(
|
||||
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
|
||||
weight: .semibold
|
||||
let button = makeKeycap(
|
||||
label: spec.label, caption: spec.caption,
|
||||
title: spec.title, isPrimary: spec.isPrimary
|
||||
)
|
||||
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)
|
||||
if spec.isPrimary {
|
||||
button.tintColor = DS.Palette.accentUIColor()
|
||||
}
|
||||
button.accessibilityLabel = spec.title
|
||||
// HIG minimum touch target regardless of glyph width.
|
||||
button.heightAnchor
|
||||
.constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget)
|
||||
.isActive = true
|
||||
button.addAction(
|
||||
UIAction { [weak self] _ in self?.onKey?(spec.key) },
|
||||
for: .touchUpInside
|
||||
)
|
||||
return button
|
||||
}
|
||||
|
||||
/// The 🎤 key: same keycap look, but **press/release** semantics instead of a
|
||||
/// tap, and it never goes through `onKey` (it maps to no bytes at all).
|
||||
/// `.touchUpOutside`/`.touchCancel` also release, so sliding a finger off the
|
||||
/// key can never leave the microphone open.
|
||||
private func makeVoiceButton(_ voice: KeyBarVoiceHandlers) -> UIButton {
|
||||
let button = makeKeycap(
|
||||
label: KeyBarLayout.voiceLabel, caption: KeyBarLayout.voiceCaption,
|
||||
title: KeyBarLayout.voiceTitle, isPrimary: false
|
||||
)
|
||||
button.addAction(UIAction { _ in voice.onDown() }, for: .touchDown)
|
||||
for event in [UIControl.Event.touchUpInside, .touchUpOutside, .touchCancel] {
|
||||
button.addAction(UIAction { _ in voice.onUp() }, for: event)
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
/// Shared keycap chrome: primary (Esc) wears the accent tint, the rest a
|
||||
/// subtle gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is
|
||||
/// the UIKit twin of DS.Palette.textSecondary — the DS UIColor surface only
|
||||
/// vends the accent, so native system labels stand in at this boundary.)
|
||||
private func makeKeycap(
|
||||
label: String, caption: String, title: String, isPrimary: Bool
|
||||
) -> UIButton {
|
||||
var config: UIButton.Configuration = isPrimary ? .tinted() : .gray()
|
||||
config.cornerStyle = .fixed
|
||||
config.background.cornerRadius = DS.Radius.sm8
|
||||
var attributedLabel = AttributedString(label)
|
||||
attributedLabel.font = UIFont.monospacedSystemFont(
|
||||
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
|
||||
weight: .semibold
|
||||
)
|
||||
config.attributedTitle = attributedLabel
|
||||
var attributedCaption = AttributedString(caption)
|
||||
attributedCaption.font = UIFont.preferredFont(forTextStyle: .caption2)
|
||||
attributedCaption.foregroundColor = .secondaryLabel
|
||||
config.attributedSubtitle = attributedCaption
|
||||
config.titleAlignment = .center
|
||||
config.contentInsets = KeyBarMetrics.buttonInsets
|
||||
|
||||
let button = UIButton(configuration: config)
|
||||
if isPrimary {
|
||||
button.tintColor = DS.Palette.accentUIColor()
|
||||
}
|
||||
button.accessibilityLabel = title
|
||||
// HIG minimum touch target regardless of glyph width.
|
||||
button.heightAnchor
|
||||
.constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget)
|
||||
.isActive = true
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
|
||||
|
||||
152
ios/App/WebTerm/Components/SpeechDictation.swift
Normal file
152
ios/App/WebTerm/Components/SpeechDictation.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
/// T-iOS-31 · `VoiceDictating` 的真机实现(Speech + AVAudioEngine)。协议、状态机
|
||||
/// 与匹配器在 `VoicePTT.swift`;从那里拆出是为了守住 800 行硬上限(plan §4)。
|
||||
|
||||
/// 真机识别器。**隐私**:支持时强制设备端识别(`requiresOnDeviceRecognition`),
|
||||
/// 音频不落盘、不出设备;不支持设备端识别的机型才走 Apple 的服务端识别(等价
|
||||
/// web 的 SEC-L2 披露)。转写只在内存里,注入前还要过 `VoiceTranscript.sanitize`。
|
||||
///
|
||||
/// PTT 语义:松手即取**当前最佳假设**(不等最终结果),否则确认要多等 1–2 秒。
|
||||
@MainActor
|
||||
final class SpeechDictation: VoiceDictating {
|
||||
enum DictationError: LocalizedError {
|
||||
case recognizerUnavailable
|
||||
case microphoneUnavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .recognizerUnavailable: "设备上的语音识别当前不可用。"
|
||||
case .microphoneUnavailable: "拿不到麦克风输入。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 音频 tap 的缓冲帧数(Apple 示例值)。
|
||||
private static let tapBufferSize: AVAudioFrameCount = 1024
|
||||
|
||||
private lazy var audioEngine = AVAudioEngine()
|
||||
private lazy var recognizer = SFSpeechRecognizer(locale: .current) ?? SFSpeechRecognizer()
|
||||
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
private var onPartial: (@MainActor (String) -> Void)?
|
||||
private var isTapInstalled = false
|
||||
private var latest = VoiceDictationResult(transcript: "", confidence: nil)
|
||||
|
||||
func requestAuthorization() async -> VoiceAuthorization {
|
||||
let speech = await Self.requestSpeechAuthorization()
|
||||
switch speech {
|
||||
case .authorized: break
|
||||
case .restricted: return .restricted
|
||||
case .denied, .notDetermined: return .deniedSpeech
|
||||
@unknown default: return .deniedSpeech
|
||||
}
|
||||
return await Self.requestMicrophoneAuthorization() ? .granted : .deniedMicrophone
|
||||
}
|
||||
|
||||
func start(onPartial: @escaping @MainActor (String) -> Void) async throws {
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
throw DictationError.recognizerUnavailable
|
||||
}
|
||||
self.onPartial = onPartial
|
||||
latest = VoiceDictationResult(transcript: "", confidence: nil)
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.shouldReportPartialResults = true
|
||||
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
|
||||
self.request = request
|
||||
|
||||
let input = audioEngine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
// 没有可用输入时 installTap 会抛 ObjC 异常(直接崩)—— 先自己拦住。
|
||||
guard format.sampleRate > 0, format.channelCount > 0 else {
|
||||
teardown()
|
||||
throw DictationError.microphoneUnavailable
|
||||
}
|
||||
input.installTap(onBus: 0, bufferSize: Self.tapBufferSize, format: format) { buffer, _ in
|
||||
request.append(buffer)
|
||||
}
|
||||
isTapInstalled = true
|
||||
audioEngine.prepare()
|
||||
try audioEngine.start()
|
||||
|
||||
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
// 非 Sendable 的 result 绝不跨隔离域 —— 先抽出标量再回主 actor。
|
||||
let transcript = result?.bestTranscription.formattedString
|
||||
let confidence = result.flatMap { Self.averageConfidence(of: $0.bestTranscription) }
|
||||
let isFinal = result?.isFinal ?? false
|
||||
let didFail = error != nil
|
||||
Task { @MainActor in
|
||||
self?.ingest(
|
||||
transcript: transcript, confidence: confidence,
|
||||
isFinal: isFinal, didFail: didFail
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async -> VoiceDictationResult {
|
||||
teardown()
|
||||
return latest
|
||||
}
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
private func ingest(
|
||||
transcript: String?, confidence: Double?, isFinal: Bool, didFail: Bool
|
||||
) {
|
||||
if let transcript, !transcript.isEmpty {
|
||||
latest = VoiceDictationResult(transcript: transcript, confidence: confidence)
|
||||
onPartial?(transcript)
|
||||
}
|
||||
guard isFinal || didFail else { return }
|
||||
teardown()
|
||||
}
|
||||
|
||||
/// 幂等收尾:结束音频、撤 tap、停引擎、放掉音频会话。
|
||||
private func teardown() {
|
||||
request?.endAudio()
|
||||
task?.cancel()
|
||||
task = nil
|
||||
request = nil
|
||||
onPartial = nil
|
||||
if isTapInstalled {
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
isTapInstalled = false
|
||||
}
|
||||
if audioEngine.isRunning {
|
||||
audioEngine.stop()
|
||||
}
|
||||
try? AVAudioSession.sharedInstance()
|
||||
.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private static func averageConfidence(of transcription: SFTranscription) -> Double? {
|
||||
let values = transcription.segments.map { Double($0.confidence) }.filter { $0 > 0 }
|
||||
guard !values.isEmpty else { return nil }
|
||||
return values.reduce(0, +) / Double(values.count)
|
||||
}
|
||||
|
||||
private static func requestSpeechAuthorization() async
|
||||
-> SFSpeechRecognizerAuthorizationStatus {
|
||||
await withCheckedContinuation { continuation in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestMicrophoneAuthorization() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
AVAudioApplication.requestRecordPermission { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
200
ios/App/WebTerm/Components/TerminalSearchBar.swift
Normal file
200
ios/App/WebTerm/Components/TerminalSearchBar.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-33 · 终端内搜索(scrollback find bar)。
|
||||
///
|
||||
/// 分层与 web 完全对齐:`public/search.ts` 只管一个输入框 + ↑/↓/× 三个键,真正
|
||||
/// 的搜索交给 xterm 的 SearchAddon(`terminal-session.ts:547-553`)。iOS 侧同理
|
||||
/// —— 本文件只有**纯归约**(提交策略 + 命中态)与**一层协议缝**,检索算法用
|
||||
/// SwiftTerm 1.13.0 自带的 `findNext`/`findPrevious`/`clearSearch`
|
||||
/// (`SearchOptions` 默认 caseSensitive:false / regex:false,与 xterm 一致)。
|
||||
///
|
||||
/// 铁律:搜索是**纯读** —— 它只动 SwiftTerm 的选区/滚动位置,绝不产生一个 PTY
|
||||
/// 字节。终端已退出(read-only)时依然可用。
|
||||
|
||||
// MARK: - Engine seam
|
||||
|
||||
/// 搜索引擎缝:生产实现是 `KeyCommandTerminalView`(SwiftTerm),测试注入替身。
|
||||
/// `AnyObject` 约束让 `TerminalSearchModel` 能弱持有视图(视图归 UIKit 所有)。
|
||||
@MainActor
|
||||
protocol TerminalSearching: AnyObject {
|
||||
/// 向下一处匹配,命中返回 true(并把选区移到命中处 = 高亮)。
|
||||
@discardableResult func searchNext(term: String) -> Bool
|
||||
/// 向上一处匹配,命中返回 true。
|
||||
@discardableResult func searchPrevious(term: String) -> Bool
|
||||
/// 清掉搜索状态与高亮选区。
|
||||
func searchClear()
|
||||
}
|
||||
|
||||
/// 搜索方向(web:Enter = next,Shift+Enter = prev)。
|
||||
enum TerminalSearchDirection: String, Equatable, Sendable {
|
||||
case next
|
||||
case previous
|
||||
}
|
||||
|
||||
/// 上一次搜索的结果态。`.idle` = 还没搜 / 词已改(旧结论作废)。
|
||||
enum TerminalSearchOutcome: Equatable, Sendable {
|
||||
case idle
|
||||
case found
|
||||
case notFound
|
||||
}
|
||||
|
||||
/// 提交策略(纯函数)。**只在空串时拦**:镜像 web 的 `hooks.find(input.value, …)`
|
||||
/// —— 查询词逐字符原样下传,绝不 trim、绝不改大小写(单个空格是合法搜索词)。
|
||||
enum TerminalSearchQuery {
|
||||
static func isSubmittable(_ raw: String) -> Bool {
|
||||
!raw.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model
|
||||
|
||||
/// 搜索面板状态。命中态是**派生值**:只要 `query` 变了,上一次结论自动作废回
|
||||
/// `.idle`(不需要 didSet,也不会把「无匹配」挂在新词上)。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TerminalSearchModel {
|
||||
/// 用户可见文案(named constants)。
|
||||
enum Copy {
|
||||
static let title = "搜索回滚缓冲"
|
||||
static let placeholder = "搜索终端输出…"
|
||||
static let noMatch = "无匹配"
|
||||
static let previous = "上一处匹配"
|
||||
static let next = "下一处匹配"
|
||||
static let close = "关闭搜索"
|
||||
static let open = "搜索"
|
||||
}
|
||||
|
||||
/// 一次搜索的结论,连同它对应的词 —— 词一改,`outcome` 立即回 `.idle`。
|
||||
private struct Attempt: Equatable {
|
||||
let term: String
|
||||
let didHit: Bool
|
||||
}
|
||||
|
||||
var query: String = ""
|
||||
private(set) var isPresented = false
|
||||
private var lastAttempt: Attempt?
|
||||
|
||||
/// 弱持有:SwiftTerm 视图归 UIKit 所有,面板绝不延长它的生命周期。
|
||||
@ObservationIgnored private weak var searcher: (any TerminalSearching)?
|
||||
|
||||
/// 派生命中态。
|
||||
var outcome: TerminalSearchOutcome {
|
||||
guard let lastAttempt, lastAttempt.term == query else { return .idle }
|
||||
return lastAttempt.didHit ? .found : .notFound
|
||||
}
|
||||
|
||||
/// 状态行文案 —— 只有「无匹配」需要说话(命中时选区高亮本身就是反馈)。
|
||||
var statusText: String? {
|
||||
outcome == .notFound ? Copy.noMatch : nil
|
||||
}
|
||||
|
||||
/// 绑定 SwiftTerm 视图(`makeUIView` 时调用一次)。
|
||||
func attach(_ searcher: any TerminalSearching) {
|
||||
self.searcher = searcher
|
||||
}
|
||||
|
||||
func present() {
|
||||
isPresented = true
|
||||
}
|
||||
|
||||
/// 关闭面板 —— 镜像 web `hide()`:清高亮(`hooks.clear()`)+ 清输入框。
|
||||
func dismiss() {
|
||||
isPresented = false
|
||||
query = ""
|
||||
lastAttempt = nil
|
||||
searcher?.searchClear()
|
||||
}
|
||||
|
||||
/// 搜一次。空词零引擎调用;未绑定引擎(预览/测试)安全无操作。
|
||||
func find(_ direction: TerminalSearchDirection) {
|
||||
let term = query
|
||||
guard TerminalSearchQuery.isSubmittable(term), let searcher else { return }
|
||||
let didHit: Bool = switch direction {
|
||||
case .next: searcher.searchNext(term: term)
|
||||
case .previous: searcher.searchPrevious(term: term)
|
||||
}
|
||||
lastAttempt = Attempt(term: term, didHit: didHit)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
/// 顶部右侧浮出的搜索条(位置对齐 web `#searchbox`:`position:fixed; top; right`)。
|
||||
/// 输入框 submit = 下一处;↑/↓ 手动翻;× 关闭并清高亮。
|
||||
struct TerminalSearchBar: View {
|
||||
let model: TerminalSearchModel
|
||||
/// 输入框首次出现即取焦(web `open()` 里的 `input.focus()`)。
|
||||
@FocusState private var isFieldFocused: Bool
|
||||
|
||||
private enum Metrics {
|
||||
static let fieldMinWidth: CGFloat = 140
|
||||
static let fieldMaxWidth: CGFloat = 220
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.xs4) {
|
||||
searchField
|
||||
if let status = model.statusText {
|
||||
Text(status)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.accessibilityIdentifier("terminal.search.status")
|
||||
}
|
||||
iconButton(
|
||||
systemImage: "chevron.up",
|
||||
title: TerminalSearchModel.Copy.previous,
|
||||
identifier: "terminal.search.previousButton"
|
||||
) { model.find(.previous) }
|
||||
iconButton(
|
||||
systemImage: "chevron.down",
|
||||
title: TerminalSearchModel.Copy.next,
|
||||
identifier: "terminal.search.nextButton"
|
||||
) { model.find(.next) }
|
||||
iconButton(
|
||||
systemImage: "xmark",
|
||||
title: TerminalSearchModel.Copy.close,
|
||||
identifier: "terminal.search.closeButton"
|
||||
) { model.dismiss() }
|
||||
}
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.vertical, DS.Space.xs4)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
.onAppear { isFieldFocused = true }
|
||||
.accessibilityLabel(TerminalSearchModel.Copy.title)
|
||||
}
|
||||
|
||||
private var searchField: some View {
|
||||
TextField(
|
||||
TerminalSearchModel.Copy.placeholder,
|
||||
text: Binding(get: { model.query }, set: { model.query = $0 })
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.font(DS.Typography.callout)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.submitLabel(.search)
|
||||
.focused($isFieldFocused)
|
||||
.frame(minWidth: Metrics.fieldMinWidth, maxWidth: Metrics.fieldMaxWidth)
|
||||
.onSubmit { model.find(.next) }
|
||||
.accessibilityIdentifier("terminal.search.field")
|
||||
}
|
||||
|
||||
private func iconButton(
|
||||
systemImage: String,
|
||||
title: String,
|
||||
identifier: String,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemImage)
|
||||
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityIdentifier(identifier)
|
||||
}
|
||||
}
|
||||
579
ios/App/WebTerm/Components/VoicePTT.swift
Normal file
579
ios/App/WebTerm/Components/VoicePTT.swift
Normal file
@@ -0,0 +1,579 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// T-iOS-31 · 语音 PTT(按住说话)+ 显式确认 + 1.5s 撤销 + epoch 防误发。
|
||||
///
|
||||
/// 本文件 = 缝 + 纯归约 + 状态机。同任务的另两块因 800 行硬上限拆出为兄弟文件:
|
||||
/// `VoicePTTBanner.swift`(SwiftUI 状态卡)与 `SpeechDictation.swift`(真机识别器)。
|
||||
///
|
||||
/// 三件套逐条移植 web(plan §7「端口匹配器 / 1.5s 撤销 / epoch 防误发」= port
|
||||
/// `public/voice-commands.ts` 的匹配器):
|
||||
/// - `public/voice.ts`:PTT 生命周期,且 `autoSend` 默认 false ⇒ **口述绝不自己
|
||||
/// 回车**(误听不得直接执行命令);
|
||||
/// - `public/voice-commands.ts`:**整句精确**命令匹配器 + 否定守卫 + 0.6 置信度门;
|
||||
/// - `public/voice-confirm.ts`:`DEFAULT_WINDOW_MS = 1500` 的可撤销窗口。
|
||||
///
|
||||
/// 安全边界(本文件是全部收口点):
|
||||
/// 1. 转写来自系统识别器 = **不可信输入**:`VoiceTranscript.sanitize` 剥掉全部
|
||||
/// C0/C1 控制字符(含 `\r`/ESC),折成单行、限长,然后才可能进 PTY;
|
||||
/// 2. **确认前零注入**:`.confirming` 态不发一个字节,用户点「发送」才 arm;
|
||||
/// 3. **1.5s 撤销窗口**:arm 后仍不发,到期才发 —— 字节一旦进 PTY 就撤不回来,
|
||||
/// 所以撤销只能靠延迟发送(这是唯一可实现的语义);
|
||||
/// 4. **epoch 两道闸**:口述开始时抓一次 epoch,`confirm()` 与窗口到期时各校验
|
||||
/// 一次;不等就丢弃 —— 会话切换/重连之间的口述绝不可能注入到别的会话。
|
||||
///
|
||||
/// 识别器经 `VoiceDictating` 协议注入,故匹配器/窗口/epoch 全部可纯单测;真机
|
||||
/// 口述走 `SpeechDictation`(需硬件麦克风,端到端验证属 DEFERRED)。
|
||||
|
||||
// MARK: - Recognizer seam
|
||||
|
||||
/// 授权结果(麦克风与语音识别是两个独立的 TCC 开关,文案必须分开)。
|
||||
enum VoiceAuthorization: Equatable, Sendable {
|
||||
case granted
|
||||
case deniedMicrophone
|
||||
case deniedSpeech
|
||||
case restricted
|
||||
|
||||
var denialReason: VoiceDenialReason? {
|
||||
switch self {
|
||||
case .granted: nil
|
||||
case .deniedMicrophone: .microphone
|
||||
case .deniedSpeech: .speech
|
||||
case .restricted: .restricted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 被拒原因 → 用户能照做的指引文案。
|
||||
enum VoiceDenialReason: Equatable, Sendable {
|
||||
case microphone
|
||||
case speech
|
||||
case restricted
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .microphone:
|
||||
"麦克风权限被拒绝。到 设置 → 隐私与安全性 → 麦克风 里打开 WebTerm 才能按住说话。"
|
||||
case .speech:
|
||||
"语音识别权限被拒绝。到 设置 → 隐私与安全性 → 语音识别 里打开 WebTerm。"
|
||||
case .restricted:
|
||||
"本设备的语音识别被限制(如「屏幕使用时间」策略),无法使用语音输入。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次口述的结果。`confidence` 只有识别器报了才有(供匹配器的置信度门用)。
|
||||
struct VoiceDictationResult: Equatable, Sendable {
|
||||
let transcript: String
|
||||
let confidence: Double?
|
||||
}
|
||||
|
||||
/// 识别器缝。生产实现 `SpeechDictation`(Speech + AVAudioEngine),测试注入替身。
|
||||
@MainActor
|
||||
protocol VoiceDictating: AnyObject {
|
||||
/// 申请麦克风 + 语音识别授权(两个 TCC 开关)。
|
||||
func requestAuthorization() async -> VoiceAuthorization
|
||||
/// 开始流式识别;中途假设经 `onPartial` 回来。
|
||||
func start(onPartial: @escaping @MainActor (String) -> Void) async throws
|
||||
/// 停止采集并给出当前最佳转写。
|
||||
func stop() async -> VoiceDictationResult
|
||||
}
|
||||
|
||||
// MARK: - Transcript sanitisation (security boundary)
|
||||
|
||||
/// 转写清洗:识别器输出是**不可信输入**,绝不原样进 PTY。
|
||||
enum VoiceTranscript {
|
||||
/// 单次口述允许注入的最大字符数。
|
||||
static let maxLength = 2000
|
||||
|
||||
/// 制表/换行类空白(折成空格),其余 C0/C1 一律剥除。
|
||||
private static let whitespaceControls: Set<UInt32> = [0x09, 0x0A, 0x0B, 0x0C, 0x0D]
|
||||
|
||||
private static func isControl(_ scalar: Unicode.Scalar) -> Bool {
|
||||
scalar.value < 0x20 || scalar.value == 0x7F || (0x80...0x9F).contains(scalar.value)
|
||||
}
|
||||
|
||||
/// 剥控制字符 → 折单行 → 合并空白 → 限长。
|
||||
///
|
||||
/// `\r`(0x0D)被剥掉是**刻意的**:口述若能自带回车,一次误听就能直接执行
|
||||
/// 命令(等价 web 的 `autoSend: false` 默认)。用户自己按 ⏎ 才算执行。
|
||||
static func sanitize(_ raw: String) -> String {
|
||||
let scalars = raw.unicodeScalars.compactMap { scalar -> Unicode.Scalar? in
|
||||
if whitespaceControls.contains(scalar.value) { return " " }
|
||||
return isControl(scalar) ? nil : scalar
|
||||
}
|
||||
let collapsed = String(String.UnicodeScalarView(scalars))
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.joined(separator: " ")
|
||||
return String(collapsed.prefix(maxLength))
|
||||
}
|
||||
|
||||
/// 清洗后还有内容才值得进确认态。
|
||||
static func isInjectable(_ sanitized: String) -> Bool {
|
||||
!sanitized.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command matcher (port of public/voice-commands.ts)
|
||||
|
||||
/// 匹配器判定。`.text` = 普通口述(绝大多数情况)。
|
||||
enum VoiceAction: String, Equatable, Sendable {
|
||||
case approve
|
||||
case reject
|
||||
case text
|
||||
}
|
||||
|
||||
/// 待批 gate 的种类 —— v1 只对 tool gate 生效(与 web 一致)。
|
||||
enum VoiceGateKind: String, Equatable, Sendable {
|
||||
case tool
|
||||
case plan
|
||||
}
|
||||
|
||||
/// 匹配上下文快照(不含置信度 —— 那个来自 ASR 结果)。
|
||||
struct VoiceGateSnapshot: Equatable, Sendable {
|
||||
let pendingApproval: Bool
|
||||
let gate: VoiceGateKind?
|
||||
}
|
||||
|
||||
/// 匹配上下文(web `VoiceMatchContext` 的等价物)。
|
||||
struct VoiceMatchContext: Equatable, Sendable {
|
||||
let pendingApproval: Bool
|
||||
let gate: VoiceGateKind?
|
||||
let confidence: Double?
|
||||
}
|
||||
|
||||
/// **整句精确**、拒绝优先、否定守卫的命令匹配器 —— 逐条移植
|
||||
/// `public/voice-commands.ts`。故意不做子串/分词匹配:顺口说出的一句话绝不能把
|
||||
/// 一个能执行 shell 的权限门给批了。
|
||||
enum VoiceCommandMatcher {
|
||||
/// `.approve` 需要的最低 ASR 置信度;`.reject` 不受此门约束(拒绝是安全方向)。
|
||||
static let minApproveConfidence = 0.6
|
||||
|
||||
static let approvePhrases: Set<String> = [
|
||||
"确认", "批准", "同意", "通过", "允许", "可以", "好", "好的", "好吧", "好啊",
|
||||
"是", "是的", "对", "行", "继续", "回车",
|
||||
"yes", "yeah", "yep", "ok", "okay", "confirm", "approve", "accept",
|
||||
"proceed", "go ahead",
|
||||
]
|
||||
|
||||
static let rejectPhrases: Set<String> = [
|
||||
"拒绝", "取消", "不行", "不要", "不用", "不同意", "不批准", "不可以", "不允许", "不通过",
|
||||
"中断", "停止", "算了", "别", "不",
|
||||
"no", "nope", "reject", "deny", "cancel", "abort", "decline", "stop",
|
||||
]
|
||||
|
||||
static let negationPrefixes: [String] = [
|
||||
"不", "别", "没", "未", "勿",
|
||||
"no", "not", "dont", "cannot", "wont", "never",
|
||||
]
|
||||
|
||||
/// web `PUNCTUATION_RE` 的字符集等价物(ASCII + CJK 标点与各种引号)。
|
||||
private static let punctuation = CharacterSet(
|
||||
charactersIn: "'’.,!?;:,。!?;:、「」『』“”‘’()()[]{}【】〈〉《》…—-_/\\|\"`~@#$%^&*+=<>"
|
||||
)
|
||||
|
||||
private static func isASCIIWord(_ scalar: Unicode.Scalar) -> Bool {
|
||||
("a"..."z").contains(scalar) || ("0"..."9").contains(scalar)
|
||||
}
|
||||
|
||||
/// `trim → 小写 → 去标点(don't→dont)→ 合并空白 → trim`(逐条对齐 web)。
|
||||
static func normalize(_ raw: String) -> String {
|
||||
let lowered = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let stripped = String(String.UnicodeScalarView(
|
||||
lowered.unicodeScalars.filter { !punctuation.contains($0) }
|
||||
))
|
||||
return stripped.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||
}
|
||||
|
||||
/// 归一化后的话语是否以否定词起头(ASCII 否定词要求词边界,故 `now` 不算否定)。
|
||||
static func startsWithNegation(_ normalized: String) -> Bool {
|
||||
negationPrefixes.contains { prefix in
|
||||
guard normalized.hasPrefix(prefix) else { return false }
|
||||
guard let head = prefix.unicodeScalars.first, isASCIIWord(head) else { return true }
|
||||
guard let next = normalized.dropFirst(prefix.count).unicodeScalars.first else {
|
||||
return true
|
||||
}
|
||||
return !isASCIIWord(next)
|
||||
}
|
||||
}
|
||||
|
||||
/// 匹配一次。任何非「整句精确 + gate 合格 + 置信度足够」的输入都落 `.text`。
|
||||
static func match(transcript: String, context: VoiceMatchContext) -> VoiceAction {
|
||||
let normalized = normalize(transcript)
|
||||
guard context.pendingApproval, !normalized.isEmpty else { return .text }
|
||||
guard context.gate == .tool else { return .text }
|
||||
if startsWithNegation(normalized) {
|
||||
return rejectPhrases.contains(normalized) ? .reject : .text
|
||||
}
|
||||
if rejectPhrases.contains(normalized) { return .reject }
|
||||
guard approvePhrases.contains(normalized) else { return .text }
|
||||
if let confidence = context.confidence, confidence < minApproveConfidence {
|
||||
return .text
|
||||
}
|
||||
return .approve
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Epoch (mis-send guard)
|
||||
|
||||
/// 口述绑定的会话世代。`sessionId` 抓的是服务器采纳的 id,`generation` 每次**连接
|
||||
/// 丢失**递增 —— 两者任一变化都作废在飞的口述。
|
||||
struct VoiceEpoch: Equatable, Sendable {
|
||||
let sessionId: UUID?
|
||||
let generation: Int
|
||||
}
|
||||
|
||||
/// epoch 作废策略(纯谓词,单一判定点)。
|
||||
enum VoiceEpochPolicy {
|
||||
/// 该连接状态是否作废在飞的口述。
|
||||
///
|
||||
/// `.reconnecting` ⇒ 作废:连接掉过之后服务器**可能**给的是一条新 PTY(旧会话
|
||||
/// 被 IDLE_TTL 回收时就会),而客户端在收到新的 `attached` 之前分辨不了。
|
||||
/// 误丢弃只是重说一遍,误注入是把命令打进别的会话 —— 走安全方向。
|
||||
static func invalidates(_ banner: TerminalViewModel.ConnectionBanner) -> Bool {
|
||||
switch banner {
|
||||
case .reconnecting: true
|
||||
case .none, .connecting: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// epoch 源:由 `TerminalScreen` 喂入 `TerminalViewModel` 的 sessionId / banner 变化,
|
||||
/// 供 PTT 在口述开始与注入时刻各取一次快照。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class VoiceEpochSource {
|
||||
private(set) var generation = 0
|
||||
private(set) var sessionId: UUID?
|
||||
|
||||
var epoch: VoiceEpoch {
|
||||
VoiceEpoch(sessionId: sessionId, generation: generation)
|
||||
}
|
||||
|
||||
func noteSession(_ id: UUID?) {
|
||||
sessionId = id
|
||||
}
|
||||
|
||||
func noteBanner(_ banner: TerminalViewModel.ConnectionBanner) {
|
||||
guard VoiceEpochPolicy.invalidates(banner) else { return }
|
||||
generation += 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pending action / resolution
|
||||
|
||||
/// 用户口述被判成什么动作。
|
||||
enum VoiceIntent: Equatable, Sendable {
|
||||
/// 普通口述文本(清洗后)。
|
||||
case text(String)
|
||||
/// 命令短语 —— 对已 held 的 tool gate 做决定。
|
||||
case decision(VoiceDecision)
|
||||
}
|
||||
|
||||
/// gate 决定(approve/reject);映射到既有 gate 通道由 wiring 负责。
|
||||
enum VoiceDecision: String, Equatable, Sendable {
|
||||
case approve
|
||||
case reject
|
||||
}
|
||||
|
||||
/// 待确认动作 —— 连同它**当时**的 epoch 一起冻结(防误发的核心)。
|
||||
struct VoicePendingAction: Equatable, Sendable {
|
||||
let intent: VoiceIntent
|
||||
let epoch: VoiceEpoch
|
||||
/// 给用户看的确认文案(清洗后的转写)。
|
||||
let preview: String
|
||||
}
|
||||
|
||||
/// 一次口述最终怎么了 —— UI 的提示文案与测试的断言点。
|
||||
enum VoiceResolution: Equatable, Sendable {
|
||||
case committed(VoiceIntent)
|
||||
case undone
|
||||
case discarded
|
||||
case staleEpoch
|
||||
case empty
|
||||
case readOnly
|
||||
}
|
||||
|
||||
/// gate 桥:上下文快照 + 决定出口**成对**注入。nil ⇒ 没有 gate 通道,匹配器恒
|
||||
/// 退化为普通口述(与 web「没有 held gate 就一律 dictation」同义)。
|
||||
struct VoiceGateBridge {
|
||||
let snapshot: @MainActor () -> VoiceGateSnapshot
|
||||
let decide: @MainActor (VoiceDecision) -> Void
|
||||
}
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
/// PTT 状态机。全部时间靠注入的 `Clock` 推进(测试零真睡)。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class VoicePTTViewModel {
|
||||
/// 可撤销窗口时长 —— 对齐 web `voice-confirm.ts` 的 `DEFAULT_WINDOW_MS = 1500`。
|
||||
static let undoWindow: Duration = .milliseconds(1500)
|
||||
|
||||
enum Phase: Equatable {
|
||||
case idle
|
||||
case requestingPermission
|
||||
case listening(partial: String)
|
||||
case confirming(VoicePendingAction)
|
||||
case undoWindow(VoicePendingAction)
|
||||
case denied(VoiceDenialReason)
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
/// 用户可见文案(named constants)。
|
||||
enum Copy {
|
||||
static let hold = "按住说话"
|
||||
static let requesting = "正在请求麦克风权限…"
|
||||
static let listening = "正在听…松手结束"
|
||||
static let listeningHint = "松手后要你确认才会发送"
|
||||
static let confirmTitle = "确认发送到终端"
|
||||
static let approveTitle = "确认批准这次工具调用"
|
||||
static let rejectTitle = "确认拒绝这次工具调用"
|
||||
static let send = "发送"
|
||||
static let cancel = "取消"
|
||||
static let undo = "撤销"
|
||||
static let sending = "1.5 秒后发送 —— 可撤销"
|
||||
static let deciding = "1.5 秒后提交决定 —— 可撤销"
|
||||
static let recognizerFailed = "语音识别没能启动"
|
||||
static let noticeUndone = "已撤销,什么都没发送。"
|
||||
static let noticeDiscarded = "已丢弃这段口述。"
|
||||
static let noticeStaleEpoch = "会话已切换或重连过,这段口述没有发送(避免打进别的会话)。"
|
||||
static let noticeEmpty = "没听到内容,什么都没发送。"
|
||||
static let noticeReadOnly = "这个会话已结束,不能再输入。"
|
||||
static let dismissNotice = "关闭提示"
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .idle
|
||||
private(set) var lastResolution: VoiceResolution?
|
||||
|
||||
@ObservationIgnored private let dictation: any VoiceDictating
|
||||
@ObservationIgnored private let clock: any Clock<Duration>
|
||||
@ObservationIgnored private let epoch: @MainActor () -> VoiceEpoch
|
||||
@ObservationIgnored private let isReadOnly: @MainActor () -> Bool
|
||||
@ObservationIgnored private let inject: @MainActor (String) -> Void
|
||||
@ObservationIgnored private let gate: VoiceGateBridge?
|
||||
|
||||
/// 手指是否还按着 —— PTT 竞态(还没起来就松手)的唯一判据。
|
||||
@ObservationIgnored private var isPressed = false
|
||||
/// `dictation.start` 正在飞 —— 此时松手交给 start 的收尾分支处理。
|
||||
@ObservationIgnored private var isStarting = false
|
||||
/// 口述开始那一刻的 epoch,冻结进 `VoicePendingAction`。
|
||||
@ObservationIgnored private var dictationEpoch = VoiceEpoch(sessionId: nil, generation: 0)
|
||||
@ObservationIgnored private var windowTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
dictation: any VoiceDictating,
|
||||
clock: any Clock<Duration>,
|
||||
epoch: @escaping @MainActor () -> VoiceEpoch,
|
||||
isReadOnly: @escaping @MainActor () -> Bool,
|
||||
inject: @escaping @MainActor (String) -> Void,
|
||||
gate: VoiceGateBridge? = nil
|
||||
) {
|
||||
self.dictation = dictation
|
||||
self.clock = clock
|
||||
self.epoch = epoch
|
||||
self.isReadOnly = isReadOnly
|
||||
self.inject = inject
|
||||
self.gate = gate
|
||||
}
|
||||
|
||||
// MARK: Derived UI state
|
||||
|
||||
var isUndoWindowOpen: Bool {
|
||||
if case .undoWindow = phase { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var isListening: Bool {
|
||||
if case .listening = phase { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// 待确认动作(nil = 现在没有要确认的东西)。
|
||||
var pendingAction: VoicePendingAction? {
|
||||
switch phase {
|
||||
case .confirming(let pending), .undoWindow(let pending): pending
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 结果提示文案(`.committed` 不提示 —— 文字已经出现在终端里)。
|
||||
var noticeText: String? {
|
||||
switch lastResolution {
|
||||
case .none, .committed: nil
|
||||
case .undone: Copy.noticeUndone
|
||||
case .discarded: Copy.noticeDiscarded
|
||||
case .staleEpoch: Copy.noticeStaleEpoch
|
||||
case .empty: Copy.noticeEmpty
|
||||
case .readOnly: Copy.noticeReadOnly
|
||||
}
|
||||
}
|
||||
|
||||
func clearNotice() {
|
||||
lastResolution = nil
|
||||
}
|
||||
|
||||
// MARK: PTT
|
||||
|
||||
/// 按下麦克风键。已有待确认内容时不抢(先处理完那一条)。
|
||||
func pressDown() async {
|
||||
guard pendingAction == nil else { return }
|
||||
isPressed = true
|
||||
lastResolution = nil
|
||||
guard !isReadOnly() else {
|
||||
isPressed = false
|
||||
phase = .idle
|
||||
lastResolution = .readOnly
|
||||
return
|
||||
}
|
||||
dictationEpoch = epoch()
|
||||
phase = .requestingPermission
|
||||
|
||||
let authorization = await dictation.requestAuthorization()
|
||||
guard authorization == .granted else {
|
||||
isPressed = false
|
||||
phase = .denied(authorization.denialReason ?? .restricted)
|
||||
return
|
||||
}
|
||||
// 授权期间就松手了 —— 麦克风一次都不开。
|
||||
guard isPressed else {
|
||||
phase = .idle
|
||||
return
|
||||
}
|
||||
await beginListening()
|
||||
}
|
||||
|
||||
/// 松手。录音还在启动中时只落下标记,由 `beginListening` 收尾。
|
||||
func pressUp() async {
|
||||
isPressed = false
|
||||
guard !isStarting, isListening else { return }
|
||||
await finishListening()
|
||||
}
|
||||
|
||||
private func beginListening() async {
|
||||
phase = .listening(partial: "")
|
||||
isStarting = true
|
||||
do {
|
||||
try await dictation.start { [weak self] partial in
|
||||
guard let self, self.isListening else { return }
|
||||
self.phase = .listening(partial: partial)
|
||||
}
|
||||
} catch {
|
||||
isStarting = false
|
||||
isPressed = false
|
||||
phase = .failed(message: Self.failureMessage(error))
|
||||
return
|
||||
}
|
||||
isStarting = false
|
||||
// 启动期间松手了 → 立刻收尾(麦克风绝不留在开着的状态)。
|
||||
guard isPressed else {
|
||||
await finishListening()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func finishListening() async {
|
||||
let result = await dictation.stop()
|
||||
let text = VoiceTranscript.sanitize(result.transcript)
|
||||
guard VoiceTranscript.isInjectable(text) else {
|
||||
phase = .idle
|
||||
lastResolution = .empty
|
||||
return
|
||||
}
|
||||
let snapshot = gate?.snapshot() ?? VoiceGateSnapshot(pendingApproval: false, gate: nil)
|
||||
let context = VoiceMatchContext(
|
||||
pendingApproval: snapshot.pendingApproval,
|
||||
gate: snapshot.gate,
|
||||
confidence: result.confidence
|
||||
)
|
||||
// 匹配器吃**原始**转写(它自己归一化,与 web 完全一致)。
|
||||
let intent: VoiceIntent = switch VoiceCommandMatcher.match(
|
||||
transcript: result.transcript, context: context
|
||||
) {
|
||||
case .text: .text(text)
|
||||
case .approve: .decision(.approve)
|
||||
case .reject: .decision(.reject)
|
||||
}
|
||||
phase = .confirming(
|
||||
VoicePendingAction(intent: intent, epoch: dictationEpoch, preview: text)
|
||||
)
|
||||
}
|
||||
|
||||
private static func failureMessage(_ error: any Error) -> String {
|
||||
guard let described = (error as? any LocalizedError)?.errorDescription,
|
||||
!described.isEmpty
|
||||
else { return Copy.recognizerFailed }
|
||||
return "\(Copy.recognizerFailed):\(described)"
|
||||
}
|
||||
|
||||
// MARK: Confirm / undo window
|
||||
|
||||
/// 显式确认。第一道 epoch 闸在这里 —— 不等就直接丢弃,绝不 arm。
|
||||
func confirm() {
|
||||
guard case .confirming(let pending) = phase else { return }
|
||||
guard epoch() == pending.epoch else {
|
||||
discard(.staleEpoch)
|
||||
return
|
||||
}
|
||||
phase = .undoWindow(pending)
|
||||
windowTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await clock.sleep(for: Self.undoWindow, tolerance: nil)
|
||||
} catch {
|
||||
return // 被 undo 取消 —— 什么都不做
|
||||
}
|
||||
commit()
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认前取消。
|
||||
func cancel() {
|
||||
guard case .confirming = phase else { return }
|
||||
phase = .idle
|
||||
lastResolution = .discarded
|
||||
}
|
||||
|
||||
/// 撤销窗口内撤销 —— 字节从未离开 App。
|
||||
func undo() {
|
||||
guard case .undoWindow = phase else { return }
|
||||
windowTask?.cancel()
|
||||
phase = .idle
|
||||
lastResolution = .undone
|
||||
}
|
||||
|
||||
/// 窗口到期。第二道 epoch 闸在这里(confirm 之后仍可能切了会话)。
|
||||
private func commit() {
|
||||
guard case .undoWindow(let pending) = phase else { return }
|
||||
guard epoch() == pending.epoch else {
|
||||
discard(.staleEpoch)
|
||||
return
|
||||
}
|
||||
switch pending.intent {
|
||||
case .text(let text):
|
||||
inject(text)
|
||||
case .decision(let decision):
|
||||
guard let gate else {
|
||||
discard(.discarded) // 无 gate 通道 —— 绝不静默丢,给出可见结果
|
||||
return
|
||||
}
|
||||
gate.decide(decision)
|
||||
}
|
||||
phase = .idle
|
||||
lastResolution = .committed(pending.intent)
|
||||
}
|
||||
|
||||
private func discard(_ resolution: VoiceResolution) {
|
||||
windowTask?.cancel()
|
||||
phase = .idle
|
||||
lastResolution = resolution
|
||||
}
|
||||
|
||||
// MARK: Deterministic test barrier
|
||||
|
||||
/// 等撤销窗口任务收尾(零轮询、零真睡)。
|
||||
func waitUntilWindowSettled() async {
|
||||
await windowTask?.value
|
||||
}
|
||||
}
|
||||
|
||||
143
ios/App/WebTerm/Components/VoicePTTBanner.swift
Normal file
143
ios/App/WebTerm/Components/VoicePTTBanner.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-31 · 语音 PTT 的 SwiftUI 状态卡。逻辑全在 `VoicePTT.swift` 的
|
||||
/// `VoicePTTViewModel` 里 —— 本文件只渲染,从 `VoicePTT.swift` 拆出是为了守住
|
||||
/// 800 行硬上限(plan §4)。
|
||||
|
||||
/// PTT 状态卡:听写中 / 待确认 / 撤销窗口 / 被拒 / 失败 / 结果提示。
|
||||
/// 底部对齐(贴着麦克风键所在的键栏);nil 状态不渲染任何东西。
|
||||
struct VoicePTTBanner: View {
|
||||
let model: VoicePTTViewModel
|
||||
|
||||
/// `.idle` 且没有结果提示 ⇒ 整张卡不渲染(终端全屏不被占)。
|
||||
private var hasCard: Bool {
|
||||
if case .idle = model.phase { return model.noticeText != nil }
|
||||
return true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if hasCard {
|
||||
card
|
||||
.padding(DS.Space.md12)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
.accessibilityIdentifier("terminal.voice.card")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var card: some View {
|
||||
switch model.phase {
|
||||
case .requestingPermission:
|
||||
label(VoicePTTViewModel.Copy.requesting, systemImage: "mic.badge.plus")
|
||||
case .listening(let partial):
|
||||
listening(partial: partial)
|
||||
case .confirming(let pending):
|
||||
confirming(pending)
|
||||
case .undoWindow(let pending):
|
||||
undoWindow(pending)
|
||||
case .denied(let reason):
|
||||
notice(reason.message, systemImage: "mic.slash")
|
||||
case .failed(let message):
|
||||
notice(message, systemImage: "exclamationmark.triangle")
|
||||
case .idle:
|
||||
if let text = model.noticeText {
|
||||
notice(text, systemImage: "info.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func label(_ text: String, systemImage: String) -> some View {
|
||||
Label(text, systemImage: systemImage)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
|
||||
private func listening(partial: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
label(VoicePTTViewModel.Copy.listening, systemImage: "waveform")
|
||||
if !partial.isEmpty {
|
||||
Text(partial)
|
||||
.font(DS.Typography.mono(.callout))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.accessibilityIdentifier("terminal.voice.partial")
|
||||
}
|
||||
Text(VoicePTTViewModel.Copy.listeningHint)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private func confirming(_ pending: VoicePendingAction) -> some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||||
Text(title(for: pending.intent))
|
||||
.font(DS.Typography.callout.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(pending.preview)
|
||||
.font(DS.Typography.mono(.callout))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.accessibilityIdentifier("terminal.voice.preview")
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
Button(VoicePTTViewModel.Copy.cancel) { model.cancel() }
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("terminal.voice.cancelButton")
|
||||
Button(VoicePTTViewModel.Copy.send) { model.confirm() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.accessibilityIdentifier("terminal.voice.confirmButton")
|
||||
}
|
||||
.frame(minHeight: DS.Layout.minHitTarget)
|
||||
}
|
||||
}
|
||||
|
||||
private func undoWindow(_ pending: VoicePendingAction) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
Text(pending.intent.isDecision
|
||||
? VoicePTTViewModel.Copy.deciding
|
||||
: VoicePTTViewModel.Copy.sending)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
Text(pending.preview)
|
||||
.font(DS.Typography.mono(.callout))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
Button(VoicePTTViewModel.Copy.undo) { model.undo() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(minHeight: DS.Layout.minHitTarget)
|
||||
.accessibilityIdentifier("terminal.voice.undoButton")
|
||||
}
|
||||
}
|
||||
|
||||
private func notice(_ text: String, systemImage: String) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
label(text, systemImage: systemImage)
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
Button {
|
||||
model.clearNotice()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(VoicePTTViewModel.Copy.dismissNotice)
|
||||
}
|
||||
}
|
||||
|
||||
private func title(for intent: VoiceIntent) -> String {
|
||||
switch intent {
|
||||
case .text: VoicePTTViewModel.Copy.confirmTitle
|
||||
case .decision(.approve): VoicePTTViewModel.Copy.approveTitle
|
||||
case .decision(.reject): VoicePTTViewModel.Copy.rejectTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VoiceIntent {
|
||||
var isDecision: Bool {
|
||||
if case .decision = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import Observation
|
||||
import OSLog
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-22 · Deep-link routing.
|
||||
/// T-iOS-22 / T-iOS-35 · Deep-link routing.
|
||||
///
|
||||
/// Two external entry points share ONE validation surface:
|
||||
/// Three external entry points share ONE validation surface:
|
||||
/// - `webterminal://open?host=<uuid>&join=<uuid>` (scheme registered in
|
||||
/// project.yml `CFBundleURLTypes`; web 分享 QR 互通的 `?join=` 解析是
|
||||
/// T-iOS-35 的增量), and
|
||||
/// project.yml `CFBundleURLTypes`),
|
||||
/// - **the web share link `http(s)://<host>[:<port>]/?join=<uuid>`**
|
||||
/// (T-iOS-35 — the exact shape `public/share.ts:55` puts in the 🔗 QR, i.e.
|
||||
/// `${location.origin}/?join=${sessionId}`), and
|
||||
/// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses
|
||||
/// `route(from:)` — same UUID rules, no second parser).
|
||||
///
|
||||
@@ -25,6 +27,11 @@ enum DeepLinkRouter {
|
||||
enum Route: Equatable, Sendable {
|
||||
/// `webterminal://open` with both ids syntactically valid.
|
||||
case openSession(hostId: UUID, sessionId: UUID)
|
||||
/// Web share link (`<origin>/?join=<uuid>`). Carries the NORMALISED
|
||||
/// origin (`HostEndpoint.originHeader`) instead of a host id — the web
|
||||
/// UI has no idea what UUID this device filed that host under, so
|
||||
/// resolution is "which paired host serves this origin" (`DeepLinkHandler`).
|
||||
case joinShared(origin: String, sessionId: UUID)
|
||||
/// Push payload with a valid `sessionId` (no host id in the payload —
|
||||
/// T-iOS-21's notification handler owns the resolution strategy).
|
||||
case gateSession(sessionId: UUID)
|
||||
@@ -40,6 +47,19 @@ enum DeepLinkRouter {
|
||||
static let emptyPaths: Set<String> = ["", "/"]
|
||||
}
|
||||
|
||||
/// Whitelisted web-share shape (`http(s)://<host>[:<port>]/?join=<uuid>`).
|
||||
///
|
||||
/// Deliberately STRICTER than the custom-scheme branch: `webterminal://` can
|
||||
/// only come from something that knows our private scheme, while an http(s)
|
||||
/// URL is whatever a QR code or another app decided to hand us. So the query
|
||||
/// must be EXACTLY one `join` key (no "unknown keys are ignored" leniency
|
||||
/// here), the path must be empty/`/`, and userinfo/fragment are rejected
|
||||
/// outright (`http://user:pass@real-host@evil/…` is a classic QR phish).
|
||||
private enum WebShape {
|
||||
static let schemes: Set<String> = ["http", "https"]
|
||||
static let queryItemCount = 1
|
||||
}
|
||||
|
||||
/// Whitelisted query keys (exact match; unknown keys are ignored).
|
||||
private enum QueryKey {
|
||||
static let host = "host"
|
||||
@@ -55,8 +75,21 @@ enum DeepLinkRouter {
|
||||
|
||||
static func route(url: URL) -> Route {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
components.scheme?.lowercased() == LinkShape.scheme,
|
||||
components.host?.lowercased() == LinkShape.action,
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
// Credentials/fragment are never part of either whitelisted shape.
|
||||
components.user == nil, components.password == nil,
|
||||
components.fragment == nil
|
||||
else { return .ignore }
|
||||
|
||||
if scheme == LinkShape.scheme { return customSchemeRoute(components) }
|
||||
if WebShape.schemes.contains(scheme) { return webShareRoute(url: url, components: components) }
|
||||
return .ignore
|
||||
}
|
||||
|
||||
/// `webterminal://open?host=<uuid>&join=<uuid>` — unchanged semantics
|
||||
/// (unknown extra query keys stay tolerated; both ids required).
|
||||
private static func customSchemeRoute(_ components: URLComponents) -> Route {
|
||||
guard components.host?.lowercased() == LinkShape.action,
|
||||
LinkShape.emptyPaths.contains(components.path),
|
||||
let hostId = uniqueValidatedId(in: components, key: QueryKey.host),
|
||||
let sessionId = uniqueValidatedId(in: components, key: QueryKey.join)
|
||||
@@ -64,6 +97,23 @@ enum DeepLinkRouter {
|
||||
return .openSession(hostId: hostId, sessionId: sessionId)
|
||||
}
|
||||
|
||||
/// `http(s)://<host>[:<port>]/?join=<uuid>` — the web 🔗 share QR.
|
||||
///
|
||||
/// The origin is derived by `HostEndpoint` (the FROZEN single derivation
|
||||
/// point, plan §3.1) rather than assembled here: it also does the http(s) +
|
||||
/// non-empty-host validation and the default-port/lowercasing normalisation
|
||||
/// the store's own origins were built with, so the comparison in
|
||||
/// `DeepLinkHandler` is apples to apples.
|
||||
private static func webShareRoute(url: URL, components: URLComponents) -> Route {
|
||||
guard LinkShape.emptyPaths.contains(components.path),
|
||||
let items = components.queryItems, items.count == WebShape.queryItemCount,
|
||||
let item = items.first, item.name == QueryKey.join,
|
||||
let raw = item.value, let sessionId = validatedId(raw),
|
||||
let endpoint = HostEndpoint(baseURL: url)
|
||||
else { return .ignore }
|
||||
return .joinShared(origin: endpoint.originHeader, sessionId: sessionId)
|
||||
}
|
||||
|
||||
// MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21)
|
||||
|
||||
static func route(from payload: [AnyHashable: Any]) -> Route {
|
||||
@@ -175,12 +225,33 @@ final class DeepLinkHandler {
|
||||
// MARK: - Apply (host id resolves ONLY through the store)
|
||||
|
||||
private func apply(_ route: DeepLinkRouter.Route) async {
|
||||
// `.gateSession` never reaches here in P1: it only exists for the
|
||||
// push path, whose handling (host resolution incl.) is T-iOS-21.
|
||||
guard case let .openSession(hostId, sessionId) = route else { return }
|
||||
switch route {
|
||||
case let .openSession(hostId, sessionId):
|
||||
// Custom scheme: the link carries OUR host id.
|
||||
await openSession(sessionId, onHostMatching: { $0.id == hostId })
|
||||
case let .joinShared(origin, sessionId):
|
||||
// T-iOS-35 · web share QR: match on the normalised origin. An
|
||||
// unpaired origin must NEVER be auto-added — a QR code is untrusted
|
||||
// input, so it falls through to the pairing flow (where the user
|
||||
// sees and confirms the target) exactly like an unknown host id.
|
||||
await openSession(sessionId, onHostMatching: { $0.endpoint.originHeader == origin })
|
||||
case .gateSession, .ignore:
|
||||
// `.gateSession` never reaches here in P1: it only exists for the
|
||||
// push path, whose handling (host resolution incl.) is T-iOS-21.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// The ONE host-resolution path (both link shapes funnel through it): store
|
||||
/// lookup → open, or unknown → pairing hint. The hint copy is deliberately
|
||||
/// generic — it never echoes the link's origin or session id (a link is
|
||||
/// untrusted text; quoting it back is a phishing/log-injection surface).
|
||||
private func openSession(
|
||||
_ sessionId: UUID, onHostMatching matches: (HostRegistry.Host) -> Bool
|
||||
) async {
|
||||
do {
|
||||
let hosts = try await loadHosts()
|
||||
guard let host = hosts.first(where: { $0.id == hostId }) else {
|
||||
guard let host = hosts.first(where: matches) else {
|
||||
hintMessage = DeepLinkCopy.unknownHostHint
|
||||
actions.showPairing()
|
||||
return
|
||||
|
||||
125
ios/App/WebTerm/DesignSystem/AppTheme.swift
Normal file
125
ios/App/WebTerm/DesignSystem/AppTheme.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-34 · 主题设置(跟随系统 / 深色 / 浅色)。
|
||||
///
|
||||
/// 历史:`RootView` 曾**硬锁** `.preferredColorScheme(.dark)`,理由写在注释里
|
||||
/// ——「琥珀金强调色是为暖深色背景设计的,浅底上金字对比不足」。那个理由对
|
||||
/// **当时的 token 取值**成立,但结论下错了:正确做法不是禁掉浅色,而是给每个
|
||||
/// 语义色一个能看清的浅色值(见 `Tokens.swift` 的 light 分支与
|
||||
/// `AppThemeTests` 的 WCAG 断言)。本类型就是解锁后的那个选择。
|
||||
///
|
||||
/// 默认值 = `.dark`,与桌面 web 的 `DEFAULT_SETTINGS.theme = 'dark'`
|
||||
/// (`public/settings.ts:33`)以及此前的硬锁行为一致 —— 老用户升级后观感零变化。
|
||||
enum AppTheme: String, CaseIterable, Equatable, Sendable {
|
||||
/// 跟随系统外观(iOS 设置里的浅色/深色/自动)。
|
||||
case system
|
||||
/// 强制深色(历史默认)。
|
||||
case dark
|
||||
/// 强制浅色。
|
||||
case light
|
||||
|
||||
/// 注入给 SwiftUI 的 `preferredColorScheme` 值。`nil` = 不表态,交给系统
|
||||
/// ——这正是「跟随系统」的实现方式,不能用当前系统值去"快照",否则用户在
|
||||
/// 系统里切换外观时 App 不跟。
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .dark: return .dark
|
||||
case .light: return .light
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择器行文案(中文,与全 App 一致)。
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: return "跟随系统"
|
||||
case .dark: return "深色"
|
||||
case .light: return "浅色"
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择器行图标。三档互不相同 —— 颜色之外还有形状(DS 的一贯纪律:
|
||||
/// 语义绝不只靠颜色传达)。
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .system: return "circle.lefthalf.filled"
|
||||
case .dark: return "moon.fill"
|
||||
case .light: return "sun.max.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// 本设置在给定系统外观下的**有效**配色。终端画布等需要"现在到底是深还是浅"
|
||||
/// 的地方用它(`.system` 时答案来自环境,不是常量)。
|
||||
func resolvedScheme(system: ColorScheme) -> ColorScheme {
|
||||
colorScheme ?? system
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 持久化编解码(纯函数)
|
||||
|
||||
/// 存/读的唯一编解码点。读侧**永不抛错**:磁盘上的值是可能被改坏/被旧版本写坏的
|
||||
/// 外部输入,任何不在白名单里的串都退回 `fallback`(而不是崩、也不是悄悄落到
|
||||
/// 「跟随系统」——那会让用户的显式选择变成另一档)。
|
||||
enum AppThemeCodec {
|
||||
/// 未设置 / 脏值时的落点。等于历史硬锁的深色 ⇒ 零回归。
|
||||
static let fallback = AppTheme.dark
|
||||
|
||||
static func decode(_ raw: String?) -> AppTheme {
|
||||
raw.flatMap(AppTheme.init(rawValue:)) ?? fallback
|
||||
}
|
||||
|
||||
static func encode(_ theme: AppTheme) -> String {
|
||||
theme.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 存储接缝
|
||||
|
||||
/// `UserDefaults` 的可测接缝(同 `SecItemShim` 的房规:live 实现零逻辑,
|
||||
/// 逻辑全在可单测的一侧)。**只存主题标识**——这里绝不放任何密级材料
|
||||
/// (令牌/证书只进 Keychain,§1.1)。
|
||||
protocol ThemeDefaults {
|
||||
func themeRaw(forKey key: String) -> String?
|
||||
func setThemeRaw(_ value: String, forKey key: String)
|
||||
}
|
||||
|
||||
/// 直通 `UserDefaults.standard`。无存储属性 ⇒ 无并发状态。
|
||||
struct LiveThemeDefaults: ThemeDefaults {
|
||||
func themeRaw(forKey key: String) -> String? {
|
||||
UserDefaults.standard.string(forKey: key)
|
||||
}
|
||||
|
||||
func setThemeRaw(_ value: String, forKey key: String) {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ThemeStore
|
||||
|
||||
/// 主题的单一真值。`RootView` 持有它、注入环境;设置页读写它。
|
||||
///
|
||||
/// 写策略:`select` 对**同值**是 no-op(不产生多余写);读只在 init 发生一次,
|
||||
/// 之后内存里的 `theme` 就是真值(`@Observable` 驱动 UI)。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ThemeStore {
|
||||
/// `UserDefaults` 键。带 App 反查前缀,避免与任何库的键撞车。
|
||||
static let storageKey = "webterm.appearance.theme"
|
||||
|
||||
private(set) var theme: AppTheme
|
||||
|
||||
@ObservationIgnored private let defaults: any ThemeDefaults
|
||||
|
||||
init(defaults: any ThemeDefaults = LiveThemeDefaults()) {
|
||||
self.defaults = defaults
|
||||
self.theme = AppThemeCodec.decode(defaults.themeRaw(forKey: Self.storageKey))
|
||||
}
|
||||
|
||||
/// 选一档。同值直接返回 —— 既省一次写,也避免 `@Observable` 无谓触发重绘。
|
||||
func select(_ next: AppTheme) {
|
||||
guard next != theme else { return }
|
||||
theme = next
|
||||
defaults.setThemeRaw(AppThemeCodec.encode(next), forKey: Self.storageKey)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,9 @@ struct TelemetryChip: View {
|
||||
.background(.quaternary, in: Capsule())
|
||||
.opacity(isStale ? DS.Opacity.stale : 1)
|
||||
.grayscale(isStale ? 1 : 0)
|
||||
// T-iOS-34 · a `lineLimit(1)` tabular pill cannot wrap, so past a point
|
||||
// extra size only truncates it. Same ceiling as `dsMetaText`.
|
||||
.dynamicTypeSize(...DS.Typography.numericClamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +125,12 @@ struct DSButtonStyle: ButtonStyle {
|
||||
enum Kind { case primary, secondary, destructive }
|
||||
var kind: Kind = .primary
|
||||
|
||||
/// T-iOS-34 · how far a label may shrink before it would rather overflow.
|
||||
/// A gate's two half-width buttons at AX5 hold a word that cannot hyphenate
|
||||
/// ("Approve"); a small scale-down plus wrapping keeps it inside the pill
|
||||
/// instead of bleeding past the rounded edge.
|
||||
fileprivate static let labelMinScale: CGFloat = 0.75
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
DSButtonBody(kind: kind, configuration: configuration)
|
||||
}
|
||||
@@ -137,6 +146,12 @@ struct DSButtonStyle: ButtonStyle {
|
||||
var body: some View {
|
||||
configuration.label
|
||||
.font(DS.Typography.body.weight(.semibold))
|
||||
// Dynamic Type: wrap + center + a bounded shrink, then let the
|
||||
// pill grow taller. `minHeight` (not `height`) is what keeps a
|
||||
// wrapped AX5 label from being clipped to 44pt.
|
||||
.multilineTextAlignment(.center)
|
||||
.minimumScaleFactor(DSButtonStyle.labelMinScale)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
|
||||
.foregroundStyle(foreground)
|
||||
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
|
||||
98
ios/App/WebTerm/DesignSystem/TerminalPalette.swift
Normal file
98
ios/App/WebTerm/DesignSystem/TerminalPalette.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// T-iOS-34 · 终端画布配色 —— **跟随主题**的那一半。
|
||||
///
|
||||
/// 此前终端是"固定暖深色,不随外观变"(对齐桌面)。浅色主题落地后这条不成立:
|
||||
/// 用户选了浅色,却有一整块纯黑画布占满屏,是最刺眼的破绽。所以画布按有效外观
|
||||
/// 取两套值:
|
||||
/// - 深色 = `#100F0D` / `#ECE9E3`(桌面 web `--bg` / `--text`,**逐字节不变**);
|
||||
/// - 浅色 = `#F6F7F9` / `#1A1D24`(镜像 web `public/settings.ts` 的
|
||||
/// `THEMES.light`,iOS 与桌面浅色档观感一致)。
|
||||
///
|
||||
/// caret / selection 用**该档**的 accent 解析值(深色金 `#E3A64A` / 浅色深金
|
||||
/// `#C9892F`),不写死深色档的金。
|
||||
///
|
||||
/// 说明(重要):`SwiftTerm.TerminalView` 在 `nativeBackgroundColor` 的 setter 里
|
||||
/// 就把 UIColor 压成了内部 `terminal.backgroundColor`(`iOSTerminalView.swift:1207`),
|
||||
/// 且没有 `traitCollectionDidChange` 重取 —— 因此**动态 UIColor 只能保证"构建时
|
||||
/// 取对档"**,主题在会话进行中切换必须由视图侧重新 `apply` 一次。本文件提供两种
|
||||
/// 形态供调用点选择:`colors(for:)`(已解析,重套用)与 `dynamic*`(动态 UIColor,
|
||||
/// 构建时取当前 trait)。
|
||||
enum TerminalPalette {
|
||||
|
||||
/// 一档终端画布的四个颜色。全部是**已解析**的具体色(不是动态色),
|
||||
/// 便于直接交给 SwiftTerm,也便于测试逐字节断言。
|
||||
struct Colors: Equatable {
|
||||
let background: UIColor
|
||||
let foreground: UIColor
|
||||
let caret: UIColor
|
||||
let selection: UIColor
|
||||
}
|
||||
|
||||
/// 深色档(桌面对齐,历史值)。
|
||||
private enum Dark {
|
||||
static let background: UInt32 = 0x100F_0D
|
||||
static let foreground: UInt32 = 0xECE9_E3
|
||||
}
|
||||
|
||||
/// 浅色档(镜像 web `THEMES.light`)。
|
||||
private enum Light {
|
||||
static let background: UInt32 = 0xF6F7_F9
|
||||
static let foreground: UInt32 = 0x1A1D_24
|
||||
}
|
||||
|
||||
static func colors(for scheme: ColorScheme) -> Colors {
|
||||
let style = scheme.userInterfaceStyle
|
||||
let accent = DS.Palette.accentUIColor()
|
||||
.resolvedColor(with: UITraitCollection(userInterfaceStyle: style))
|
||||
let isDark = style == .dark
|
||||
return Colors(
|
||||
background: UIColor(hex: isDark ? Dark.background : Light.background),
|
||||
foreground: UIColor(hex: isDark ? Dark.foreground : Light.foreground),
|
||||
caret: accent,
|
||||
selection: accent
|
||||
)
|
||||
}
|
||||
|
||||
/// 动态背景色 —— 取值时机决定档位(SwiftUI/UIKit 的 trait 解析)。
|
||||
static func dynamicBackground() -> UIColor {
|
||||
UIColor { trait in colors(for: trait.colorScheme).background }
|
||||
}
|
||||
|
||||
/// 动态前景色。
|
||||
static func dynamicForeground() -> UIColor {
|
||||
UIColor { trait in colors(for: trait.colorScheme).foreground }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 外观桥(ColorScheme ↔ UIUserInterfaceStyle)
|
||||
|
||||
extension ColorScheme {
|
||||
/// SwiftUI → UIKit。`ColorScheme` 只有 light/dark 两个 case,故是全函数。
|
||||
var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
self == .dark ? .dark : .light
|
||||
}
|
||||
}
|
||||
|
||||
extension UITraitCollection {
|
||||
/// UIKit → SwiftUI。`.unspecified` 按 iOS 的默认外观算作浅色。
|
||||
var colorScheme: ColorScheme {
|
||||
userInterfaceStyle == .dark ? .dark : .light
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hex
|
||||
|
||||
extension UIColor {
|
||||
/// 从 `0xRRGGBB` 构建不透明 sRGB 色。设计 token 的十六进制值在文档/CSS 里
|
||||
/// 就是这个写法,直接用它可以逐字节对照,不必手算 0–1 分量。
|
||||
convenience init(hex: UInt32, alpha: CGFloat = 1) {
|
||||
self.init(
|
||||
red: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(hex & 0xFF) / 255,
|
||||
alpha: alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,16 @@ import UIKit
|
||||
/// (refined native, Apple HIG), dark-mode-first, amber-gold accent (desktop-matched)
|
||||
/// continuing the web selection color.
|
||||
///
|
||||
/// T-iOS-34 · dark-mode-first no longer means dark-mode-only: the app has a real
|
||||
/// theme setting (`AppTheme` / `ThemeStore`, injected once by `RootView`), so
|
||||
/// EVERY color token must resolve sensibly in both schemes. Adaptive tokens are
|
||||
/// built with the private `adaptive(dark:light:)` helper below and their light
|
||||
/// values are contrast-audited in `AppThemeTests` (WCAG 1.4.11, 3:1 floor for
|
||||
/// non-text UI). Known accepted gap: `accent` at its frozen light value #C9892F
|
||||
/// is 2.88:1 on white — fine as a FILL (dark ink on top is 6.2:1) but marginal
|
||||
/// as bare tinted text; the value is pinned by `DesignSystemTests` and left
|
||||
/// unchanged here (reported to the orchestrator instead of silently re-toned).
|
||||
///
|
||||
/// Vocabulary (all under `DS.`):
|
||||
/// - `Palette` — adaptive `accent` (amber gold, matches desktop) · semantic `status*` colors
|
||||
/// (color is NEVER the only status signal — pair with a symbol,
|
||||
@@ -59,21 +69,36 @@ enum DS {
|
||||
/// ink for contrast — mirrors web --on-accent #1A1305).
|
||||
static let onAccent = rgb(26, 19, 5)
|
||||
/// Faint accent wash (selection/soft highlight) — web --accent-soft.
|
||||
static let accentSoft = Color(red: 0xE3 / 255.0, green: 0xA6 / 255.0, blue: 0x4A / 255.0, opacity: 0.15)
|
||||
/// Adaptive (T-iOS-34): the dark wash is the web value verbatim; on a
|
||||
/// light background a 15% wash of the BRIGHT gold reads as nothing, so
|
||||
/// light uses the deeper `--accent-2` gold at a slightly higher alpha.
|
||||
/// Still a wash in both (alpha < 0.3 — never a solid fill).
|
||||
static let accentSoft = adaptive(
|
||||
dark: UIColor(hex: 0xE3A6_4A, alpha: 0.15),
|
||||
light: UIColor(hex: 0xC989_2F, alpha: 0.18)
|
||||
)
|
||||
|
||||
// ── Semantic status colors (match the desktop/web status palette) ───
|
||||
// These are the ONLY status colors. `StatusStyle` pairs each with a
|
||||
// distinct SF Symbol so status is never conveyed by color alone. Hex
|
||||
// mirrors the web's warm status set (public/style.css:18-20) so iOS and
|
||||
// desktop read the same. `waiting` amber #F5B14C stays distinct from the
|
||||
// gold accent #E3A64A (brighter/less brown), and is only ever a small
|
||||
// badge fill, never a large accent surface.
|
||||
/// working — #46D07F (web --green).
|
||||
static let statusWorking = rgb(70, 208, 127)
|
||||
/// waiting / needs-me — #F5B14C (web --amber).
|
||||
static let statusWaiting = rgb(245, 177, 76)
|
||||
/// stuck — #FF6B6B (web --red).
|
||||
static let statusStuck = rgb(255, 107, 107)
|
||||
// distinct SF Symbol so status is never conveyed by color alone. The
|
||||
// DARK values mirror the web's warm status set (public/style.css:18-20)
|
||||
// byte for byte so iOS and desktop read the same; `waiting` amber
|
||||
// #F5B14C stays distinct from the gold accent #E3A64A (brighter/less
|
||||
// brown), and is only ever a small badge fill, never a large surface.
|
||||
//
|
||||
// T-iOS-34 · the LIGHT values are new. The web chrome is dark-only, so
|
||||
// there was nothing to mirror: each light value is the same hue darkened
|
||||
// until it clears WCAG 1.4.11 (3:1 for non-text UI) against a white
|
||||
// background — the bright dark-mode values sit at 1.96:1 (#46D07F),
|
||||
// 1.60:1 (#F5B14C) and 2.74:1 (#FF6B6B) on white, i.e. unreadable.
|
||||
// `AppThemeTests` pins BOTH the dark bytes and the light contrast floor.
|
||||
/// working — dark #46D07F (web --green) · light #1F8F52 (4.0:1 on white).
|
||||
static let statusWorking = adaptive(dark: 0x46D0_7F, light: 0x1F8F_52)
|
||||
/// waiting / needs-me — dark #F5B14C (web --amber) · light #C25E00
|
||||
/// (4.2:1; burnt orange keeps it distinct from the light gold accent).
|
||||
static let statusWaiting = adaptive(dark: 0xF5B1_4C, light: 0xC25E_00)
|
||||
/// stuck — dark #FF6B6B (web --red) · light #D22B2B (5.1:1).
|
||||
static let statusStuck = adaptive(dark: 0xFF6B_6B, light: 0xD22B_2B)
|
||||
/// idle — quiet secondary gray (distinguished from `unknown` by shape).
|
||||
static let statusIdle = Color.secondary
|
||||
/// unknown / no signal yet — gray.
|
||||
@@ -86,10 +111,12 @@ enum DS {
|
||||
// and `stuck` reuse the status tokens above (same meaning); `done`
|
||||
// reuses `statusWorking` (a completed run). `tool`/`user` get their own
|
||||
// tokens so nothing in the app hardcodes a raw SwiftUI color.
|
||||
/// tool run — indigo, tied to the app accent family (#5E9EFF-ish).
|
||||
static let timelineTool = rgb(94, 158, 255)
|
||||
/// user message — violet (#AF7BFF), distinct from accent & tool.
|
||||
static let timelineUser = rgb(175, 123, 255)
|
||||
/// tool run — dark #5E9EFF · light #2C6ED6 (4.8:1 on white; the bright
|
||||
/// blue is 2.63:1 there).
|
||||
static let timelineTool = adaptive(dark: 0x5E9E_FF, light: 0x2C6E_D6)
|
||||
/// user message — dark #AF7BFF · light #7A3BD6 (6.1:1; bright violet is
|
||||
/// 2.81:1). Distinct from accent & tool in both schemes.
|
||||
static let timelineUser = adaptive(dark: 0xAF7B_FF, light: 0x7A3B_D6)
|
||||
|
||||
// ── Surfaces & text ────────────────────────────────────────────────
|
||||
|
||||
@@ -106,18 +133,48 @@ enum DS {
|
||||
/// Tertiary label color (de-emphasized detail).
|
||||
static let textTertiary = Color(uiColor: .tertiaryLabel)
|
||||
|
||||
// ── Terminal canvas (fixed warm-dark, matches the desktop terminal) ──
|
||||
// A terminal reads as dark regardless of app appearance (like the
|
||||
// desktop). Values mirror the web chrome: --bg #100F0D / --text #ECE9E3.
|
||||
/// Terminal background — warm near-black #100F0D (web --bg).
|
||||
static let terminalBackground = rgb(16, 15, 13)
|
||||
/// Terminal foreground — warm off-white #ECE9E3 (web --text).
|
||||
static let terminalForeground = rgb(236, 233, 227)
|
||||
// ── Terminal canvas (theme-following as of T-iOS-34) ────────────────
|
||||
// Was a FIXED warm-dark canvas ("a terminal reads as dark regardless of
|
||||
// app appearance"). With a real light theme that stops being true — a
|
||||
// full-screen near-black slab is the loudest thing on a light UI. The
|
||||
// two schemes now live in `TerminalPalette` (which also vends the
|
||||
// already-resolved set SwiftTerm needs); these stay as the SwiftUI-side
|
||||
// tokens so existing call sites keep compiling and become adaptive.
|
||||
/// Terminal background — dark #100F0D (web --bg) · light #F6F7F9.
|
||||
static let terminalBackground = Color(uiColor: terminalBackgroundUIColor())
|
||||
/// Terminal foreground — dark #ECE9E3 (web --text) · light #1A1D24.
|
||||
static let terminalForeground = Color(uiColor: terminalForegroundUIColor())
|
||||
|
||||
/// Dynamic terminal background. Exposed as `UIColor` because SwiftTerm's
|
||||
/// `nativeBackgroundColor` takes UIKit colors AND flattens them on set
|
||||
/// (see `TerminalPalette`) — the bridge must not go through SwiftUI.
|
||||
static func terminalBackgroundUIColor() -> UIColor {
|
||||
TerminalPalette.dynamicBackground()
|
||||
}
|
||||
|
||||
/// Dynamic terminal foreground (same rationale as the background).
|
||||
static func terminalForegroundUIColor() -> UIColor {
|
||||
TerminalPalette.dynamicForeground()
|
||||
}
|
||||
|
||||
/// Build an opaque sRGB color from 0–255 components.
|
||||
private static func rgb(_ r: Double, _ g: Double, _ b: Double) -> Color {
|
||||
Color(.sRGB, red: r / 255, green: g / 255, blue: b / 255, opacity: 1)
|
||||
}
|
||||
|
||||
/// One scheme-adaptive color from two `0xRRGGBB` values. THE way to add
|
||||
/// an adaptive token — hand-rolling a `UIColor { trait in }` per token
|
||||
/// is how a scheme branch gets forgotten.
|
||||
private static func adaptive(dark: UInt32, light: UInt32) -> Color {
|
||||
adaptive(dark: UIColor(hex: dark), light: UIColor(hex: light))
|
||||
}
|
||||
|
||||
/// Same, for values that need a non-opaque alpha (washes).
|
||||
private static func adaptive(dark: UIColor, light: UIColor) -> Color {
|
||||
Color(uiColor: UIColor { trait in
|
||||
trait.userInterfaceStyle == .dark ? dark : light
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Space
|
||||
|
||||
@@ -37,6 +37,23 @@ extension DS {
|
||||
|
||||
/// The canonical meta-number font: caption-sized mono + tabular.
|
||||
static let metaMono = mono(.caption)
|
||||
|
||||
// ── Dynamic Type clamp for dense numerics (T-iOS-34) ────────────────
|
||||
|
||||
/// Upper bound applied to **numeric / meta** text only (telemetry chips,
|
||||
/// `dsMetaText` rows: `ctx 92% · $0.1234 · 161×50`).
|
||||
///
|
||||
/// Prose scales all the way to `.accessibility5` — that is the point of
|
||||
/// Dynamic Type and nothing here caps it. Tabular numerics are different:
|
||||
/// they live in one-line rows next to a status badge, and at AX4/AX5 a
|
||||
/// single chip row becomes three wrapped lines that push the row's real
|
||||
/// content off screen. Capping them at `.accessibility2` (already ~2×
|
||||
/// the default size) keeps the meta line legible AND keeps the row's
|
||||
/// primary text — the session name — visible.
|
||||
///
|
||||
/// Pinned in `DynamicTypeLayoutTests` both as a policy value and
|
||||
/// behaviorally (AX2 and AX5 must measure the same height).
|
||||
static let numericClamp: DynamicTypeSize = .accessibility2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +61,15 @@ extension DS {
|
||||
|
||||
/// Secondary + monospaced-tabular styling for a row's numeric meta line
|
||||
/// ("N 台设备在看 · 161×50"). Apply via `.dsMetaText()`.
|
||||
///
|
||||
/// T-iOS-34 · carries the `numericClamp` so every meta line in the app gets the
|
||||
/// same Dynamic Type ceiling from ONE place (per-screen clamps would drift).
|
||||
struct MetaText: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.dynamicTypeSize(...DS.Typography.numericClamp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,10 +191,18 @@ final class PushRegistrar {
|
||||
logger.error("remote notification registration failed: \(error)")
|
||||
}
|
||||
|
||||
/// 主机移除时注销该主机上的 device token(**additive hook**:当前 App
|
||||
/// 层尚无移除主机的 UI 路径——`HostStore.remove(id:)` 无消费者;未来的
|
||||
/// 移除路径应调用本方法。失败仅记日志:服务器侧对失效 token 也会经
|
||||
/// APNs 410 自行清理)。
|
||||
/// 主机移除时注销该主机上的 device token。
|
||||
///
|
||||
/// C1 · 这个钩子曾经**没有调用方**(App 层没有移除主机的 UI,
|
||||
/// `HostStore.remove(id:)` 无消费者),于是 APNs token 永远留在被移除的主机
|
||||
/// 上。现在接线是:配对页「已配对主机」→ `PairingViewModel.removeHost(id:)`
|
||||
/// → `Probe.unregisterPush` → `PushHostDeregistration.run(for:)`(在
|
||||
/// `AppEnvironment` 里解析到 `PushAppDelegate` 持有的**活**实例——device
|
||||
/// token 只在内存里,只有它知道)→ 本方法 → 之后才写存储删除主机。
|
||||
///
|
||||
/// 顺序是有意的:请求先发出,此时主机记录(以及它的访问令牌,令牌门后的
|
||||
/// 主机需要它才能通过 401)还在。失败仅记日志:用户要的是移除,且服务器侧
|
||||
/// 对失效 token 也会经 APNs 410 自行清理。
|
||||
func handleHostRemoved(_ host: HostRegistry.Host) async {
|
||||
registeredHostIds.remove(host.id)
|
||||
guard let token = currentTokenHex else { return }
|
||||
|
||||
268
ios/App/WebTerm/Screens/GitPanelScreen.swift
Normal file
268
ios/App/WebTerm/Screens/GitPanelScreen.swift
Normal file
@@ -0,0 +1,268 @@
|
||||
import APIClient
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// C2 · 项目 git 面板(web v0.6 `docs/plans/w6-project-git-panel.md` 的移动端对齐)。
|
||||
///
|
||||
/// 手机上不复制 web 的四列同步带 —— 那个布局在 720px 以下就会散架。语义完全一致,
|
||||
/// 只把"带"改成竖排卡片:唯一的绿色仍然只有「已同步」可以拿到。
|
||||
///
|
||||
/// 安全:分支/上游/文件路径/提交主题/PR 标题**全是不可信服务器字节** ——
|
||||
/// 一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测);PR 链接
|
||||
/// 只在通过 `PrLink` 的 https+github.com 白名单后才可点。
|
||||
struct GitPanelScreen: View {
|
||||
@State private var viewModel: GitPanelViewModel
|
||||
|
||||
private enum Metrics {
|
||||
/// 提交信息输入框的行数区间(内容度量,非视觉 token)。
|
||||
static let commitEditorLines = 2...5
|
||||
/// 短 hash 展示长度(`git log --format=%h` 的常见宽度)。
|
||||
static let shortHashLength = 7
|
||||
}
|
||||
|
||||
/// 整宽 DS 按钮的行内距(与 ProjectDetailScreen 的按钮行一致)。
|
||||
private static let buttonRowInsets = EdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.lg16,
|
||||
bottom: DS.Space.xs4, trailing: DS.Space.lg16
|
||||
)
|
||||
|
||||
init(viewModel: GitPanelViewModel) {
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
}
|
||||
|
||||
/// 生产入口:详情屏只需转手 endpoint/http/path。
|
||||
init(endpoint: HostEndpoint, http: any HTTPTransport, path: String) {
|
||||
self.init(viewModel: .forProject(endpoint: endpoint, http: http, path: path))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var bindable = viewModel
|
||||
return List {
|
||||
stateSection
|
||||
feedbackSection
|
||||
changesSection
|
||||
commitSection(message: $bindable.commitMessage)
|
||||
pullRequestSection
|
||||
logSection
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(GitPanelCopy.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.load()
|
||||
await viewModel.loadPullRequest() // gh 走外网,单独一条,不阻塞首屏
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
await viewModel.loadPullRequest()
|
||||
}
|
||||
.overlay {
|
||||
if !viewModel.isLoaded {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 同步状态
|
||||
|
||||
@ViewBuilder private var stateSection: some View {
|
||||
Section(GitPanelCopy.stateSection) {
|
||||
if let band = viewModel.band {
|
||||
GitSyncBandView(band: band, branch: viewModel.branch)
|
||||
Button {
|
||||
Task { await viewModel.fetchRemote() }
|
||||
} label: {
|
||||
Label(GitPanelCopy.fetchButton, systemImage: "arrow.down.circle")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(!viewModel.canFetch)
|
||||
.listRowInsets(Self.buttonRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
.accessibilityHint(GitPanelCopy.fetchHint)
|
||||
}
|
||||
if let message = viewModel.stateErrorMessage {
|
||||
InlineMessage(text: message, tone: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写操作的成功/失败回执。服务器安全文案原样显示(`Text(verbatim:)`)。
|
||||
@ViewBuilder private var feedbackSection: some View {
|
||||
if viewModel.errorMessage != nil || viewModel.noticeMessage != nil {
|
||||
Section {
|
||||
if let message = viewModel.errorMessage {
|
||||
InlineMessage(text: message, tone: .error)
|
||||
}
|
||||
if let message = viewModel.noticeMessage {
|
||||
InlineMessage(text: message, tone: .success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 改动(stage / unstage)
|
||||
|
||||
@ViewBuilder private var changesSection: some View {
|
||||
Section(GitPanelCopy.changesSection) {
|
||||
if let message = viewModel.changesErrorMessage {
|
||||
InlineMessage(text: message, tone: .error)
|
||||
}
|
||||
if viewModel.staged.isEmpty && viewModel.unstaged.isEmpty
|
||||
&& viewModel.changesErrorMessage == nil {
|
||||
Text(GitPanelCopy.noChanges)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
ForEach(viewModel.staged) { file in
|
||||
changedFileRow(file, isStaged: true)
|
||||
}
|
||||
ForEach(viewModel.unstaged) { file in
|
||||
changedFileRow(file, isStaged: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func changedFileRow(
|
||||
_ file: GitPanelViewModel.ChangedFile, isStaged: Bool
|
||||
) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
// 路径是服务器字节 → verbatim + 等宽中段截断。
|
||||
Text(verbatim: file.displayPath)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text(GitChangeCopy.summary(file))
|
||||
.dsMetaText()
|
||||
}
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
Button(isStaged ? GitPanelCopy.unstageButton : GitPanelCopy.stageButton) {
|
||||
Task { await viewModel.setStaged(file, staged: !isStaged) }
|
||||
}
|
||||
.font(DS.Typography.caption.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
|
||||
.disabled(viewModel.busy != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 提交 / 推送
|
||||
|
||||
@ViewBuilder private func commitSection(message: Binding<String>) -> some View {
|
||||
Section(GitPanelCopy.commitSection) {
|
||||
TextField(GitPanelCopy.commitPlaceholder, text: message, axis: .vertical)
|
||||
.lineLimit(Metrics.commitEditorLines)
|
||||
.font(DS.Typography.body)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button {
|
||||
Task { await viewModel.commit() }
|
||||
} label: {
|
||||
Label(GitPanelCopy.commitButton, systemImage: "checkmark.seal")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(!viewModel.canCommit)
|
||||
.listRowInsets(Self.buttonRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
Button {
|
||||
Task { await viewModel.push() }
|
||||
} label: {
|
||||
Label(GitPanelCopy.pushButton, systemImage: "arrow.up.circle")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(viewModel.busy != nil)
|
||||
.listRowInsets(Self.buttonRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PR / CI
|
||||
|
||||
@ViewBuilder private var pullRequestSection: some View {
|
||||
if let pr = viewModel.pr {
|
||||
Section(GitPanelCopy.prSection) {
|
||||
PrStatusRow(status: pr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 最近提交
|
||||
|
||||
@ViewBuilder private var logSection: some View {
|
||||
Section(GitPanelCopy.logSection) {
|
||||
if let message = viewModel.logErrorMessage {
|
||||
InlineMessage(text: message, tone: .error)
|
||||
}
|
||||
if let log = viewModel.log {
|
||||
if log.commits.isEmpty {
|
||||
Text(GitPanelCopy.noCommits)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
commitRows(log)
|
||||
if log.truncated {
|
||||
Text(GitPanelCopy.truncatedLog)
|
||||
.dsMetaText()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func commitRows(_ log: GitLogResult) -> some View {
|
||||
let boundary = GitLogBoundary.index(commits: log.commits, upstream: log.upstream)
|
||||
ForEach(Array(log.commits.enumerated()), id: \.element.hash) { index, commit in
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
commitRow(commit)
|
||||
// 未推送边界只画一次,且只在最后一个 unpushed 之后(w6/G4)。
|
||||
if index == boundary, let upstream = log.upstream {
|
||||
UnpushedBoundary(upstream: upstream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func commitRow(_ commit: CommitLogEntry) -> some View {
|
||||
HStack(alignment: .top, spacing: DS.Space.sm8) {
|
||||
Text(verbatim: String(commit.hash.prefix(Metrics.shortHashLength)))
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
// 提交主题是服务器字节 → verbatim,两行上限。
|
||||
Text(verbatim: commit.subject)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(2)
|
||||
Text(GitTimeFormat.relative(fromMs: Double(commit.at), nowMs: Date().timeIntervalSince1970 * 1_000))
|
||||
.dsMetaText()
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if commit.unpushed == true {
|
||||
Image(systemName: "arrow.up.circle")
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
.accessibilityLabel(GitChangeCopy.unpushedLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 文案(改动行摘要)
|
||||
|
||||
enum GitChangeCopy {
|
||||
static let unpushedLabel = "未推送"
|
||||
|
||||
static func summary(_ file: GitPanelViewModel.ChangedFile) -> String {
|
||||
"\(statusLabel(file.status)) · +\(file.added) −\(file.removed)"
|
||||
}
|
||||
|
||||
static func statusLabel(_ status: DiffFileStatus) -> String {
|
||||
switch status {
|
||||
case .modified: return "修改"
|
||||
case .added: return "新增"
|
||||
case .deleted: return "删除"
|
||||
case .renamed: return "重命名"
|
||||
case .binary: return "二进制"
|
||||
case .untracked: return "未跟踪"
|
||||
}
|
||||
}
|
||||
}
|
||||
359
ios/App/WebTerm/Screens/GitPanelViews.swift
Normal file
359
ios/App/WebTerm/Screens/GitPanelViews.swift
Normal file
@@ -0,0 +1,359 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// C2 · git 面板的可复用子视图 —— 全部只消费 `DS` token(零硬编码颜色/间距),
|
||||
/// 服务器字节一律 `Text(verbatim:)`,触达目标 ≥ `DS.Layout.minHitTarget`。
|
||||
|
||||
// MARK: - 同步带(w6/G3 的手机竖排版本)
|
||||
|
||||
/// 竖排三行的同步状态:HEAD/上游 · 待推送 · 待拉取(+ 脏度)。
|
||||
///
|
||||
/// 视觉语义就是那条设计铁律:**只有「已同步」可以用绿色**(`statusWorking`),
|
||||
/// 「未核实/无上游/分离 HEAD」用琥珀(`statusWaiting`),其余中性。数字本身从不
|
||||
/// 被染成警告色 —— 落后是信息不是错误,怀疑由芯片和脚注承担(镜像 web 的修正)。
|
||||
struct GitSyncBandView: View {
|
||||
let band: GitSyncBand
|
||||
/// 当前分支(服务器字节)。
|
||||
let branch: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||||
headRow
|
||||
if case .tracking(_, let ahead, let behind) = band.head {
|
||||
countsRow(ahead: ahead, behind: behind)
|
||||
}
|
||||
if let footnote {
|
||||
Text(footnote)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(
|
||||
band.isInSync ? DS.Palette.textSecondary : DS.Palette.statusWaiting
|
||||
)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
dirtyRow
|
||||
}
|
||||
.padding(.vertical, DS.Space.xs4)
|
||||
}
|
||||
|
||||
@ViewBuilder private var headRow: some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
if let branch {
|
||||
Label {
|
||||
Text(verbatim: branch).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
}
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
switch band.head {
|
||||
case .detached:
|
||||
SyncChip(text: GitPanelCopy.detachedHead, tone: .warning)
|
||||
case .noUpstream:
|
||||
SyncChip(text: GitPanelCopy.noUpstream, tone: .warning)
|
||||
case .tracking(let upstream, _, _):
|
||||
// 上游名是服务器字节。
|
||||
Text(verbatim: upstream)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if band.isInSync {
|
||||
SyncChip(text: GitPanelCopy.inSync, tone: .good)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func countsRow(ahead: Int?, behind: Int?) -> some View {
|
||||
HStack(spacing: DS.Space.md12) {
|
||||
countCell(caption: GitPanelCopy.toPushCaption, symbol: "arrow.up", value: ahead)
|
||||
countCell(caption: GitPanelCopy.toPullCaption, symbol: "arrow.down", value: behind)
|
||||
if !band.isBehindVerified {
|
||||
SyncChip(text: GitPanelCopy.unverified, tone: .warning)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// nil 计数渲染成 `—`:那是"算不出来",把它当 0 正是本类型要防的谎。
|
||||
private func countCell(caption: String, symbol: String, value: Int?) -> some View {
|
||||
HStack(spacing: DS.Space.xs4) {
|
||||
Image(systemName: symbol)
|
||||
.imageScale(.small)
|
||||
Text(value.map(String.init) ?? GitPanelCopy.unknownCount)
|
||||
.font(DS.Typography.mono(.callout))
|
||||
Text(caption)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
@ViewBuilder private var dirtyRow: some View {
|
||||
switch band.dirty {
|
||||
case .unknown:
|
||||
Text(GitPanelCopy.dirtyUnknown)
|
||||
.dsMetaText()
|
||||
case .clean:
|
||||
SyncChip(text: GitPanelCopy.clean, tone: .good)
|
||||
case .changed(let count):
|
||||
SyncChip(text: GitPanelCopy.dirtyCount(count), tone: .dirty)
|
||||
}
|
||||
}
|
||||
|
||||
/// 为什么这个数字不可信 —— 缺了这句话,读者最容易把"没有数字"读成"没事"。
|
||||
private var footnote: String? {
|
||||
switch band.head {
|
||||
case .detached:
|
||||
return GitPanelCopy.detachedDetail
|
||||
case .noUpstream:
|
||||
return GitPanelCopy.noUpstreamDetail
|
||||
case .tracking:
|
||||
guard !band.isBehindVerified else { return nil }
|
||||
return GitPanelCopy.staleFetch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目详情**头部**的极简版同步态:一行胶囊,回答"有没有没推的提交"。
|
||||
///
|
||||
/// 与面板版共享同一份 `GitSyncBand` 判断,所以两处永不打架:绿色只可能是
|
||||
/// 「已同步」,`nil` 计数显示 `—` 而不是 0,过期的 `↓` 一定带「未核实」。
|
||||
struct SyncSummaryChips: View {
|
||||
let band: GitSyncBand
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.xs4) {
|
||||
switch band.head {
|
||||
case .detached:
|
||||
SyncChip(text: GitPanelCopy.detachedHead, tone: .warning)
|
||||
case .noUpstream:
|
||||
SyncChip(text: GitPanelCopy.noUpstream, tone: .warning)
|
||||
case .tracking(_, let ahead, let behind):
|
||||
if band.isInSync {
|
||||
SyncChip(text: GitPanelCopy.inSync, tone: .good)
|
||||
} else {
|
||||
trackingChips(ahead: ahead, behind: behind)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func trackingChips(ahead: Int?, behind: Int?) -> some View {
|
||||
if let ahead, ahead > 0 {
|
||||
SyncChip(text: "↑ \(ahead)", tone: .neutral)
|
||||
}
|
||||
if let behind, behind > 0 {
|
||||
SyncChip(text: "↓ \(behind)", tone: .neutral)
|
||||
}
|
||||
if ahead == nil || behind == nil {
|
||||
// 读不到就说读不到 —— 绝不显示成 0。
|
||||
SyncChip(text: "↑↓ \(GitPanelCopy.unknownCount)", tone: .warning)
|
||||
}
|
||||
if !band.isBehindVerified {
|
||||
SyncChip(text: GitPanelCopy.unverified, tone: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 小胶囊:语义色 + 淡底(与 `DirtyBadge` 同一做法,明暗两态都清晰)。
|
||||
struct SyncChip: View {
|
||||
enum Tone {
|
||||
case good
|
||||
case warning
|
||||
case dirty
|
||||
case neutral
|
||||
}
|
||||
|
||||
let text: String
|
||||
var tone: Tone = .neutral
|
||||
|
||||
private var color: Color {
|
||||
switch tone {
|
||||
case .good: return DS.Palette.statusWorking
|
||||
case .warning: return DS.Palette.statusWaiting
|
||||
case .dirty: return DS.Palette.statusWaiting
|
||||
case .neutral: return DS.Palette.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(DS.Typography.caption.weight(.medium))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.vertical, DS.Space.xs2)
|
||||
.background(color.opacity(Self.fillOpacity), in: Capsule())
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
/// 软填充胶囊的底色透明度(与 `DirtyBadge` 一致)。
|
||||
private static let fillOpacity: Double = 0.18
|
||||
}
|
||||
|
||||
// MARK: - 未推送边界(w6/G4)
|
||||
|
||||
/// `git log` 列表里的一条分界线:以上尚未推送到 `<upstream>`。
|
||||
struct UnpushedBoundary: View {
|
||||
let upstream: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
Rectangle()
|
||||
.fill(DS.Palette.statusWaiting)
|
||||
.frame(height: DS.Stroke.hairline)
|
||||
// upstream 是服务器字节 → 拼进文案前仍走 verbatim 渲染。
|
||||
Text(verbatim: GitPanelCopy.unpushedBoundary(upstream))
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
.lineLimit(1)
|
||||
Rectangle()
|
||||
.fill(DS.Palette.statusWaiting)
|
||||
.frame(height: DS.Stroke.hairline)
|
||||
}
|
||||
.padding(.vertical, DS.Space.xs2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 行内提示
|
||||
|
||||
/// 一行成功/失败提示。服务器安全文案原样显示(verbatim),绝不静默。
|
||||
struct InlineMessage: View {
|
||||
enum Tone {
|
||||
case error
|
||||
case success
|
||||
case info
|
||||
}
|
||||
|
||||
let text: String
|
||||
var tone: Tone = .info
|
||||
|
||||
private var color: Color {
|
||||
switch tone {
|
||||
case .error: return DS.Palette.statusStuck
|
||||
case .success: return DS.Palette.statusWorking
|
||||
case .info: return DS.Palette.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch tone {
|
||||
case .error: return "exclamationmark.triangle"
|
||||
case .success: return "checkmark.circle"
|
||||
case .info: return "info.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: DS.Space.sm8) {
|
||||
Image(systemName: symbol)
|
||||
.foregroundStyle(color)
|
||||
Text(verbatim: text)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PR / CI 芯片(w3-pr-ci-chip 的移动端对齐)
|
||||
|
||||
/// 一行 PR 状态 + CI 汇总。
|
||||
///
|
||||
/// gh 的降级(未安装/未登录/无 PR/已关闭/出错)在服务端是 200 + `availability`,
|
||||
/// 所以这里一条分支都不当错误处理 —— 芯片照常在,只是换句话说。
|
||||
struct PrStatusRow: View {
|
||||
let status: PrStatus
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
TelemetryChip(systemImage: "arrow.triangle.pull", text: headline)
|
||||
if let checks = status.checks, checks.total > 0 {
|
||||
TelemetryChip(
|
||||
systemImage: checkSymbol(checks),
|
||||
text: PrCopy.checks(checks),
|
||||
isWarning: checks.failing > 0
|
||||
)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
if let title = status.title, !title.isEmpty {
|
||||
// PR 标题是 GitHub 上的任意文本 → verbatim。
|
||||
Text(verbatim: title)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
if let url = PrLink.url(from: status.url) {
|
||||
Link(destination: url) {
|
||||
Label(PrCopy.openInBrowser, systemImage: "safari")
|
||||
.font(DS.Typography.caption)
|
||||
}
|
||||
.frame(minHeight: DS.Layout.minHitTarget, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headline: String {
|
||||
switch status.availability {
|
||||
case .ok:
|
||||
return PrCopy.number(status.number, state: status.state, isDraft: status.isDraft)
|
||||
case .noPr: return PrCopy.noPr
|
||||
case .notInstalled: return PrCopy.notInstalled
|
||||
case .unauthenticated: return PrCopy.unauthenticated
|
||||
case .disabled: return PrCopy.disabled
|
||||
case .error: return PrCopy.error
|
||||
}
|
||||
}
|
||||
|
||||
private func checkSymbol(_ checks: PrCheckSummary) -> String {
|
||||
if checks.failing > 0 { return "xmark.octagon" }
|
||||
if checks.pending > 0 { return "clock" }
|
||||
return "checkmark.seal"
|
||||
}
|
||||
}
|
||||
|
||||
/// PR 链接白名单:URL 是**服务器字节**,直接丢给 `openURL` 等于让远端选择要打开
|
||||
/// 哪个 App(自定义 scheme 可以跳出浏览器)。因此只接受 https + github.com。
|
||||
enum PrLink {
|
||||
private static let allowedScheme = "https"
|
||||
private static let allowedHostSuffix = "github.com"
|
||||
|
||||
static func url(from raw: String?) -> URL? {
|
||||
guard let raw, let url = URL(string: raw), url.scheme?.lowercased() == allowedScheme,
|
||||
let host = url.host?.lowercased(),
|
||||
host == allowedHostSuffix || host.hasSuffix("." + allowedHostSuffix)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
enum PrCopy {
|
||||
static let noPr = "无 PR"
|
||||
static let notInstalled = "主机未安装 gh"
|
||||
static let unauthenticated = "gh 未登录"
|
||||
static let disabled = "PR 查询已关闭"
|
||||
static let error = "PR 状态获取失败"
|
||||
static let openInBrowser = "在浏览器打开"
|
||||
static let draft = "草稿"
|
||||
|
||||
static func number(_ number: Int?, state: String?, isDraft: Bool?) -> String {
|
||||
var text = number.map { "PR #\($0)" } ?? "PR"
|
||||
if let state, !state.isEmpty { text += " · \(state)" }
|
||||
if isDraft == true { text += " · \(draft)" }
|
||||
return text
|
||||
}
|
||||
|
||||
static func checks(_ checks: PrCheckSummary) -> String {
|
||||
"✓\(checks.passing) ✗\(checks.failing) ⏳\(checks.pending)"
|
||||
}
|
||||
}
|
||||
84
ios/App/WebTerm/Screens/PairingCopy.swift
Normal file
84
ios/App/WebTerm/Screens/PairingCopy.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import HostRegistry
|
||||
|
||||
/// User-facing pairing copy (plan §3.4 taxonomy → actionable wording; §5.2
|
||||
/// Local-Network guidance including the iOS 18 restart caveat).
|
||||
///
|
||||
/// Extracted from `PairingViewModel.swift` by C1: the VM grew the access-token
|
||||
/// and host-management flows, and copy is presentation, not state machine
|
||||
/// (plan §4 file-size discipline — many small focused files).
|
||||
///
|
||||
/// SECURITY (§5.3): no function here ever takes or echoes a token value. The
|
||||
/// shape-rejection copy is derived from `AccessTokenError`, which deliberately
|
||||
/// carries a length at most — never the rejected secret.
|
||||
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 =
|
||||
"连接超时。请确认主机在线、与手机在同一网络后重试。"
|
||||
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
|
||||
static let deviceCertRequired =
|
||||
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
|
||||
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
|
||||
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
|
||||
static let clientCertRejected =
|
||||
"本设备证书无效或已吊销,请重新导入。"
|
||||
|
||||
// MARK: - Access token (C1 · ios-completion §1.1)
|
||||
|
||||
/// 401 from `POST /auth` — the token itself is wrong. Never echoes it.
|
||||
static let tokenInvalid =
|
||||
"访问令牌不正确。请到主机上核对 WEBTERM_TOKEN 的值后重新输入。"
|
||||
/// 429 — the server allows 10 `/auth` attempts per minute per IP.
|
||||
static let tokenRateLimited =
|
||||
"尝试次数过多(主机限制每分钟 10 次),请稍后再试。"
|
||||
/// 204 WITHOUT `Set-Cookie`: this host never enabled auth, so there is
|
||||
/// nothing to authenticate against and nothing gets saved. The pairing
|
||||
/// failure therefore has another cause — almost always `ALLOWED_ORIGINS`.
|
||||
static let tokenNotRequired =
|
||||
"该主机没有启用访问令牌(服务器未设置 WEBTERM_TOKEN),令牌不会被保存。"
|
||||
+ "配对失败的原因更可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
|
||||
/// Defensive: a shape violation that slipped past the field validation.
|
||||
static let tokenShapeUnknown =
|
||||
"访问令牌格式不符合要求(16–512 位,且只能包含 A-Z a-z 0-9 . _ ~ + / = -)。"
|
||||
static let hostsLoadFailed =
|
||||
"无法读取已配对的主机列表(钥匙串读取失败)。"
|
||||
static let hostRemoveFailed =
|
||||
"移除主机失败(钥匙串写入失败),请重试。"
|
||||
|
||||
/// Turn the typed `AccessTokenError` into field-level copy. The length cases
|
||||
/// report the length only — that is not secret, and it is exactly what tells
|
||||
/// the user whether they pasted a truncated value.
|
||||
static func tokenShapeRejected(_ error: AccessTokenError) -> String {
|
||||
switch error {
|
||||
case .tooShort(let length):
|
||||
return "访问令牌太短(\(length) 位,至少 \(AccessToken.minLength) 位)。"
|
||||
case .tooLong(let length):
|
||||
return "访问令牌太长(\(length) 位,最多 \(AccessToken.maxLength) 位)。"
|
||||
case .invalidCharacters:
|
||||
return "访问令牌含有不允许的字符(只能是 A-Z a-z 0-9 . _ ~ + / = -)。"
|
||||
case .unknownHost:
|
||||
return hostsLoadFailed
|
||||
}
|
||||
}
|
||||
|
||||
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,或反馈该网段。"
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ struct PairingScreen: View {
|
||||
@State private var manualURLText = ""
|
||||
@State private var isShowingScanner = false
|
||||
@State private var scannerError: String?
|
||||
/// C1 · Typed access token. Lives in `SecureField` view state only for as
|
||||
/// long as the prompt is up, and is cleared the moment it is submitted or
|
||||
/// abandoned — the persisted copy belongs in the Keychain, nowhere else.
|
||||
@State private var accessTokenText = ""
|
||||
/// C1 · Host pending the "移除主机" confirmation dialog (nil = none).
|
||||
@State private var hostPendingRemoval: HostRegistry.Host?
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
@@ -37,47 +43,162 @@ struct PairingScreen: View {
|
||||
probingView(pending)
|
||||
case .failed(let pending, let failure):
|
||||
FailureView(pending: pending, failure: failure, viewModel: viewModel)
|
||||
case .awaitingToken(let pending):
|
||||
tokenPrompt(pending, isValidating: false)
|
||||
case .validatingToken(let pending):
|
||||
tokenPrompt(pending, isValidating: true)
|
||||
case .paired(let host):
|
||||
pairedView(host)
|
||||
}
|
||||
}
|
||||
private var idleView: some View {
|
||||
|
||||
// MARK: - Access-token prompt (C1 · §1.1)
|
||||
|
||||
/// The host answered 401. `SecureField` (never a plain `TextField`), no
|
||||
/// autocorrect/autocapitalisation — a token is not prose — and the inline
|
||||
/// rejection comes from the VM, which never echoes the value back.
|
||||
private func tokenPrompt(
|
||||
_ pending: PairingViewModel.PendingHost, isValidating: Bool
|
||||
) -> some View {
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Space.xl20) {
|
||||
VStack(spacing: DS.Space.md12) { // inviting hero
|
||||
Image(systemName: "desktopcomputer")
|
||||
.font(DS.Typography.largeTitle)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.padding(.top, DS.Space.sm8)
|
||||
Text(ScreenCopy.heroTitle)
|
||||
.font(DS.Typography.title)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(ScreenCopy.heroSubtitle)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
tokenFieldCard(pending, isValidating: isValidating)
|
||||
if let rejection = viewModel.tokenRejection {
|
||||
Label(rejection, systemImage: "exclamationmark.circle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
SectionHeader(title: ScreenCopy.manualSectionTitle)
|
||||
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
|
||||
.font(DS.Typography.mono())
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.go)
|
||||
.onSubmit { viewModel.submitManualURL(manualURLText) }
|
||||
.accessibilityIdentifier("pairing.urlField")
|
||||
Divider()
|
||||
Button(ScreenCopy.manualSubmit) {
|
||||
DS.Haptics.selection()
|
||||
viewModel.submitManualURL(manualURLText)
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("pairing.submitButton")
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
if isValidating {
|
||||
ProgressView()
|
||||
}
|
||||
Button(ScreenCopy.tokenSubmit) { submitAccessToken() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(isValidating || accessTokenText.isEmpty)
|
||||
.accessibilityIdentifier("pairing.tokenSubmit")
|
||||
Button(ScreenCopy.cancel) {
|
||||
accessTokenText = ""
|
||||
viewModel.cancelAccessTokenEntry()
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(isValidating)
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
}
|
||||
|
||||
private func tokenFieldCard(
|
||||
_ pending: PairingViewModel.PendingHost, isValidating: Bool
|
||||
) -> some View {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
SectionHeader(title: ScreenCopy.tokenSectionTitle)
|
||||
Text(pending.displayAddress)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
Text(ScreenCopy.tokenHint)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
Divider()
|
||||
SecureField(ScreenCopy.tokenPlaceholder, text: $accessTokenText)
|
||||
.font(DS.Typography.mono())
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.go)
|
||||
.disabled(isValidating)
|
||||
.onSubmit { submitAccessToken() }
|
||||
.accessibilityIdentifier("pairing.tokenField")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand the token to the VM and drop the view's copy immediately.
|
||||
private func submitAccessToken() {
|
||||
let token = accessTokenText
|
||||
accessTokenText = ""
|
||||
Task { await viewModel.submitAccessToken(token) }
|
||||
}
|
||||
private var idleView: some View {
|
||||
idleContent
|
||||
.sheet(isPresented: $isShowingScanner) { scannerSheet }
|
||||
.task { await viewModel.loadPairedHosts() }
|
||||
.confirmationDialog(
|
||||
ScreenCopy.removeConfirmTitle,
|
||||
isPresented: removalDialogBinding,
|
||||
titleVisibility: .visible,
|
||||
presenting: hostPendingRemoval
|
||||
) { host in
|
||||
removalDialogActions(host)
|
||||
} message: { _ in
|
||||
Text(ScreenCopy.removeConfirmMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Presented iff a host is staged for removal; dismissal clears the staging.
|
||||
private var removalDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { hostPendingRemoval != nil },
|
||||
set: { presented in if !presented { hostPendingRemoval = nil } }
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private func removalDialogActions(
|
||||
_ host: HostRegistry.Host
|
||||
) -> some View {
|
||||
Button(ScreenCopy.removeConfirm(host.name), role: .destructive) {
|
||||
hostPendingRemoval = nil
|
||||
Task { await viewModel.removeHost(id: host.id) }
|
||||
}
|
||||
Button(ScreenCopy.cancel, role: .cancel) { hostPendingRemoval = nil }
|
||||
}
|
||||
|
||||
private var hero: some View {
|
||||
VStack(spacing: DS.Space.md12) { // inviting hero
|
||||
Image(systemName: "desktopcomputer")
|
||||
.font(DS.Typography.largeTitle)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.padding(.top, DS.Space.sm8)
|
||||
Text(ScreenCopy.heroTitle)
|
||||
.font(DS.Typography.title)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(ScreenCopy.heroSubtitle)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var manualEntryCard: some View {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
SectionHeader(title: ScreenCopy.manualSectionTitle)
|
||||
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
|
||||
.font(DS.Typography.mono())
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.go)
|
||||
.onSubmit { viewModel.submitManualURL(manualURLText) }
|
||||
.accessibilityIdentifier("pairing.urlField")
|
||||
Divider()
|
||||
Button(ScreenCopy.manualSubmit) {
|
||||
DS.Haptics.selection()
|
||||
viewModel.submitManualURL(manualURLText)
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("pairing.submitButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var idleContent: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Space.xl20) {
|
||||
hero
|
||||
manualEntryCard
|
||||
if let rejection = viewModel.inputRejection {
|
||||
Label(rejection, systemImage: "exclamationmark.circle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
@@ -97,10 +218,72 @@ struct PairingScreen: View {
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
pairedHostsSection
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
.sheet(isPresented: $isShowingScanner) { scannerSheet }
|
||||
}
|
||||
|
||||
// MARK: - Paired hosts (C1 · remove + "re-pair to add/update the token")
|
||||
|
||||
/// The app's host-management surface: re-pairing the same address updates
|
||||
/// that host in place (that is how an existing host gains an access token
|
||||
/// after the server enabled `WEBTERM_TOKEN`), and 移除 deletes the record —
|
||||
/// which takes its stored token with it and de-registers its APNs token.
|
||||
@ViewBuilder private var pairedHostsSection: some View {
|
||||
if !viewModel.pairedHosts.isEmpty || viewModel.hostsErrorMessage != nil {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
SectionHeader(title: ScreenCopy.pairedSectionTitle)
|
||||
if let message = viewModel.hostsErrorMessage {
|
||||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
}
|
||||
ForEach(viewModel.pairedHosts) { host in
|
||||
pairedHostRow(host)
|
||||
if host.id != viewModel.pairedHosts.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
Text(ScreenCopy.pairedSectionHint)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pairedHostRow(_ host: HostRegistry.Host) -> some View {
|
||||
HStack(spacing: DS.Space.md12) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
Text(verbatim: host.name)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text(host.endpoint.originHeader)
|
||||
.dsMetaText()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
// PRESENCE only — a token value never reaches the screen.
|
||||
if host.accessToken != nil {
|
||||
Label(ScreenCopy.tokenStored, systemImage: "key.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Button(role: .destructive) {
|
||||
hostPendingRemoval = host
|
||||
} label: {
|
||||
Label(ScreenCopy.remove, systemImage: "trash")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.accessibilityLabel(ScreenCopy.removeAccessibility(host.name))
|
||||
.accessibilityIdentifier("pairing.removeHost")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var scannerSheet: some View {
|
||||
@@ -277,12 +460,22 @@ private struct FailureView: View {
|
||||
}
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
let needsSettings = failure.action == .openLocalNetworkSettings
|
||||
// C1 · 401 is ambiguous (token OR Origin), so BOTH remedies
|
||||
// stay on screen: the token prompt leads, retry stays put.
|
||||
let needsToken = failure.action == .enterAccessToken
|
||||
if needsSettings {
|
||||
Button(ScreenCopy.openSettings) { openAppSettings() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
}
|
||||
if needsToken {
|
||||
Button(ScreenCopy.enterToken) { viewModel.beginAccessTokenEntry() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("pairing.enterTokenButton")
|
||||
}
|
||||
Button(ScreenCopy.retry) { Task { await viewModel.retry() } }
|
||||
.buttonStyle(DSButtonStyle(kind: needsSettings ? .secondary : .primary))
|
||||
.buttonStyle(DSButtonStyle(
|
||||
kind: (needsSettings || needsToken) ? .secondary : .primary
|
||||
))
|
||||
Button(ScreenCopy.back) { viewModel.cancel() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
}
|
||||
@@ -391,6 +584,24 @@ private enum ScreenCopy {
|
||||
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
|
||||
static let publicAcknowledge = "我已了解风险,仍要连接"
|
||||
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
|
||||
// C1 · access token + host management
|
||||
static let enterToken = "输入访问令牌"
|
||||
static let tokenSectionTitle = "输入访问令牌"
|
||||
static let tokenPlaceholder = "WEBTERM_TOKEN"
|
||||
static let tokenHint =
|
||||
"这台主机设置了 WEBTERM_TOKEN。令牌只会保存在本机钥匙串里,绝不出现在网址或日志中。"
|
||||
static let tokenSubmit = "验证并保存"
|
||||
static let tokenStored = "已保存访问令牌"
|
||||
static let pairedSectionTitle = "已配对主机"
|
||||
static let pairedSectionHint =
|
||||
"想给已配对的主机补/换访问令牌:在上面重新输入同一个地址配对一次即可(不会重复添加)。"
|
||||
static let remove = "移除"
|
||||
static let removeConfirmTitle = "移除这台主机?"
|
||||
static let removeConfirmMessage =
|
||||
"会同时删除本机保存的访问令牌,并在该主机上注销本设备的推送。主机上正在跑的会话不受影响。"
|
||||
|
||||
static func removeConfirm(_ name: String) -> String { "移除「\(name)」" }
|
||||
static func removeAccessibility(_ name: String) -> String { "移除主机 \(name)" }
|
||||
|
||||
static func probing(_ address: String) -> String { "正在验证 \(address) …" }
|
||||
static func paired(_ name: String) -> String { "已配对:\(name)" }
|
||||
|
||||
@@ -5,6 +5,14 @@ import WireProtocol
|
||||
/// T-iOS-26 · 项目详情屏:sessions/worktrees/CLAUDE.md 渲染 + diff 入口
|
||||
/// (T-iOS-27 的 `DiffScreen(endpoint:path:http:)`)+ "在此仓库开新会话"。
|
||||
///
|
||||
/// C2 增量(与 web v0.6 / Android 对齐):
|
||||
/// - 头部**环境同步态**(`GitSyncBand`:↑↓ / 无上游 / 分离 HEAD / 脏度),不用敲
|
||||
/// git 命令就能看到"有没有没推的提交";
|
||||
/// - **Git 面板**入口(stage/commit/push/fetch + `git log` + PR/CI);
|
||||
/// - **worktree 生命周期**(T-iOS-32):新建 / 回收失效 / 删除(两级确认),
|
||||
/// 取代原先只读的列表;
|
||||
/// - **`claude --resume` 历史**:挑一条过去的会话在本仓库里接着跑。
|
||||
///
|
||||
/// 安全:名字/路径/分支/CLAUDE.md 内容全是**不可信服务器字节** ——
|
||||
/// 一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测)。
|
||||
struct ProjectDetailScreen: View {
|
||||
@@ -12,7 +20,30 @@ struct ProjectDetailScreen: View {
|
||||
private let endpoint: HostEndpoint
|
||||
private let http: any HTTPTransport
|
||||
private let onOpenClaude: (String) -> Void
|
||||
@State private var isDiffPresented = false
|
||||
private let onResumeClaude: (String, String) -> Void
|
||||
|
||||
/// 详情屏级别的 worktree 状态机:只负责 prune / remove(create 归 sheet 自己的
|
||||
/// 实例,两个状态机互不干扰)。
|
||||
@State private var worktreeActions: WorktreeViewModel
|
||||
@State private var route: DetailRoute?
|
||||
@State private var sheet: DetailSheet?
|
||||
@State private var worktreePendingRemoval: WorktreeInfo?
|
||||
@State private var isPruneConfirmPresented = false
|
||||
|
||||
/// 推入式子屏(单一 destination,避免多个 `isPresented:` 目的地互相打断)。
|
||||
private enum DetailRoute: Hashable, Identifiable {
|
||||
case diff
|
||||
case gitPanel
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
private enum DetailSheet: Hashable, Identifiable {
|
||||
case newWorktree
|
||||
case resumeHistory
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
private enum Metrics {
|
||||
/// CLAUDE.md 预览行数上限(内容裁剪,非视觉 token)。
|
||||
@@ -23,12 +54,17 @@ struct ProjectDetailScreen: View {
|
||||
viewModel: ProjectDetailViewModel,
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
onOpenClaude: @escaping (String) -> Void
|
||||
onOpenClaude: @escaping (String) -> Void,
|
||||
onResumeClaude: @escaping (String, String) -> Void
|
||||
) {
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
self.endpoint = endpoint
|
||||
self.http = http
|
||||
self.onOpenClaude = onOpenClaude
|
||||
self.onResumeClaude = onResumeClaude
|
||||
_worktreeActions = State(initialValue: .forProject(
|
||||
endpoint: endpoint, http: http, path: viewModel.path
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -36,14 +72,118 @@ struct ProjectDetailScreen: View {
|
||||
.navigationTitle(ProjectDetailCopy.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await viewModel.load() }
|
||||
.navigationDestination(isPresented: $isDiffPresented) {
|
||||
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
|
||||
.navigationDestination(item: $route) { destination(for: $0) }
|
||||
.sheet(item: $sheet) { presentedSheet(for: $0) }
|
||||
}
|
||||
|
||||
/// 破坏性 worktree 操作的确认层:prune 一道、remove 两道(第二道只在服务器
|
||||
/// 回 409「有未提交改动」时才出现)。挂在 `content` 上而不是 `body` 上,
|
||||
/// 是为了让 `body` 保持可读。
|
||||
@ViewBuilder private var content: some View {
|
||||
phaseContent
|
||||
.confirmationDialog(
|
||||
WorktreeCopy.pruneConfirmTitle,
|
||||
isPresented: $isPruneConfirmPresented,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(WorktreeCopy.pruneConfirm, role: .destructive) {
|
||||
Task {
|
||||
await worktreeActions.prune()
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(WorktreeCopy.pruneConfirmMessage)
|
||||
}
|
||||
.confirmationDialog(
|
||||
WorktreeCopy.removeConfirmTitle,
|
||||
isPresented: removalDialogBinding,
|
||||
titleVisibility: .visible,
|
||||
presenting: worktreePendingRemoval
|
||||
) { worktree in
|
||||
Button(WorktreeCopy.removeConfirm, role: .destructive) {
|
||||
remove(worktree)
|
||||
}
|
||||
} message: { worktree in
|
||||
Text(verbatim: WorktreeCopy.removeConfirmMessage(worktree.path))
|
||||
}
|
||||
.confirmationDialog(
|
||||
WorktreeCopy.forceConfirmTitle,
|
||||
isPresented: forceDialogBinding,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
// 409(脏工作树)后的**第二次**确认 —— 绝无单击数据丢失。
|
||||
Button(WorktreeCopy.forceConfirm, role: .destructive) {
|
||||
Task {
|
||||
await worktreeActions.confirmForceRemove()
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
Button(WorktreeCopy.cancel, role: .cancel) {
|
||||
worktreeActions.cancelForceRemove()
|
||||
}
|
||||
} message: {
|
||||
Text(WorktreeCopy.forceConfirmMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func destination(for route: DetailRoute) -> some View {
|
||||
switch route {
|
||||
case .diff:
|
||||
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
|
||||
case .gitPanel:
|
||||
GitPanelScreen(endpoint: endpoint, http: http, path: viewModel.path)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func presentedSheet(for sheet: DetailSheet) -> some View {
|
||||
switch sheet {
|
||||
case .newWorktree:
|
||||
WorktreeSheet(
|
||||
viewModel: .forProject(endpoint: endpoint, http: http, path: viewModel.path),
|
||||
onCreated: { Task { await viewModel.load() } },
|
||||
onOpenSession: onOpenClaude
|
||||
)
|
||||
case .resumeHistory:
|
||||
ResumeHistorySheet(
|
||||
viewModel: .forProject(endpoint: endpoint, http: http, path: viewModel.path),
|
||||
onResume: onResumeClaude
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅当某个 worktree 被暂存待删时呈现;关闭即清除暂存。
|
||||
private var removalDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { worktreePendingRemoval != nil },
|
||||
set: { presented in if !presented { worktreePendingRemoval = nil } }
|
||||
)
|
||||
}
|
||||
|
||||
/// 409 → 二次确认。关闭(含滑走)等于取消,绝不残留在待 force 态。
|
||||
private var forceDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
if case .forceConfirming = worktreeActions.removePhase { return true }
|
||||
return false
|
||||
},
|
||||
set: { presented in if !presented { worktreeActions.cancelForceRemove() } }
|
||||
)
|
||||
}
|
||||
|
||||
private func remove(_ worktree: WorktreeInfo) {
|
||||
Task {
|
||||
await worktreeActions.remove(worktreePath: worktree.path)
|
||||
if case .removed = worktreeActions.removePhase {
|
||||
DS.Haptics.warning()
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase switch
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
@ViewBuilder private var phaseContent: some View {
|
||||
switch viewModel.phase {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
@@ -93,8 +233,8 @@ struct ProjectDetailScreen: View {
|
||||
List {
|
||||
headerSection(detail)
|
||||
actionsSection(detail)
|
||||
sessionsSection(detail.sessions)
|
||||
worktreesSection(detail.worktrees)
|
||||
sessionsSection(detail)
|
||||
worktreesSection(detail)
|
||||
claudeMdSection(detail)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
@@ -127,6 +267,13 @@ struct ProjectDetailScreen: View {
|
||||
DirtyBadge()
|
||||
}
|
||||
}
|
||||
// 环境同步态(w6/G3):不敲 git 命令就知道有没有没推的提交。
|
||||
if let band = GitSyncBand.make(
|
||||
sync: detail.sync, dirtyCount: detail.dirtyCount,
|
||||
nowMs: Date().timeIntervalSince1970 * 1_000
|
||||
) {
|
||||
SyncSummaryChips(band: band)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,32 +293,53 @@ struct ProjectDetailScreen: View {
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
if detail.isGit {
|
||||
Button {
|
||||
isDiffPresented = true
|
||||
} label: {
|
||||
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
|
||||
secondaryAction(GitPanelCopy.entryLabel, symbol: "point.3.filled.connected.trianglepath.dotted") {
|
||||
route = .gitPanel
|
||||
}
|
||||
secondaryAction(ProjectDetailCopy.viewDiff, symbol: "plus.forwardslash.minus") {
|
||||
route = .diff
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.lg16,
|
||||
bottom: DS.Space.sm8, trailing: DS.Space.lg16
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func sessionsSection(_ sessions: [ProjectSessionRef]) -> some View {
|
||||
private func secondaryAction(
|
||||
_ title: String, symbol: String, action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Label(title, systemImage: symbol)
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.lg16,
|
||||
bottom: DS.Space.xs4, trailing: DS.Space.lg16
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
@ViewBuilder private func sessionsSection(_ detail: ProjectDetail) -> some View {
|
||||
Section(ProjectDetailCopy.sessionsHeader) {
|
||||
if sessions.isEmpty {
|
||||
if detail.sessions.isEmpty {
|
||||
Text(ProjectDetailCopy.noSessions)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
} else {
|
||||
ForEach(sessions, id: \.id) { session in
|
||||
ForEach(detail.sessions, id: \.id) { session in
|
||||
sessionRow(session)
|
||||
}
|
||||
}
|
||||
// `claude --resume <id>`:挑一条历史会话在本仓库接着跑(T-iOS-32)。
|
||||
Button {
|
||||
sheet = .resumeHistory
|
||||
} label: {
|
||||
Label(ResumeCopy.entryLabel, systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.lg16,
|
||||
bottom: DS.Space.xs4, trailing: DS.Space.lg16
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,16 +367,75 @@ struct ProjectDetailScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
|
||||
if !worktrees.isEmpty {
|
||||
Section(ProjectDetailCopy.worktreesHeader) {
|
||||
ForEach(worktrees, id: \.path) { worktree in
|
||||
// MARK: - Worktrees(T-iOS-32:读 + 建 + 回收 + 删)
|
||||
|
||||
/// git 仓库一律显示本区(哪怕只有一个 worktree)—— 标题固定,"空"不等于"没查"
|
||||
/// (w6/G5 的同一条纪律)。
|
||||
@ViewBuilder private func worktreesSection(_ detail: ProjectDetail) -> some View {
|
||||
if detail.isGit {
|
||||
Section {
|
||||
ForEach(detail.worktrees, id: \.path) { worktree in
|
||||
worktreeRow(worktree)
|
||||
}
|
||||
Button {
|
||||
sheet = .newWorktree
|
||||
} label: {
|
||||
Label(WorktreeCopy.sheetTitle, systemImage: "plus.rectangle.on.folder")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.lg16,
|
||||
bottom: DS.Space.xs4, trailing: DS.Space.lg16
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
worktreeFeedback
|
||||
} header: {
|
||||
worktreeHeader(detail)
|
||||
} footer: {
|
||||
if detail.worktrees.contains(where: WorktreeViewModel.canRemove) {
|
||||
Text(ProjectDetailCopy.worktreeSwipeHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func worktreeHeader(_ detail: ProjectDetail) -> some View {
|
||||
HStack {
|
||||
Text(ProjectDetailCopy.worktreesHeader)
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
// 只有真的存在 prunable 登记项时才提供回收(不邀请无效动作)。
|
||||
if detail.worktrees.contains(where: { $0.prunable == true }) {
|
||||
Button(WorktreeCopy.pruneButton) {
|
||||
isPruneConfirmPresented = true
|
||||
}
|
||||
.font(DS.Typography.caption.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.frame(minHeight: DS.Layout.minHitTarget)
|
||||
.disabled(worktreeActions.isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// prune / remove 的回执(服务器安全文案原样显示;成功也要说一声)。
|
||||
@ViewBuilder private var worktreeFeedback: some View {
|
||||
switch worktreeActions.prunePhase {
|
||||
case .done(let message):
|
||||
InlineMessage(text: message, tone: .success)
|
||||
case .failed(let message):
|
||||
InlineMessage(text: message, tone: .error)
|
||||
case .idle, .pruning:
|
||||
EmptyView()
|
||||
}
|
||||
switch worktreeActions.removePhase {
|
||||
case .removed(let path):
|
||||
InlineMessage(text: WorktreeCopy.removed(path), tone: .success)
|
||||
case .failed(let message):
|
||||
InlineMessage(text: message, tone: .error)
|
||||
case .idle, .removing, .forceConfirming:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
@@ -225,12 +452,28 @@ struct ProjectDetailScreen: View {
|
||||
if worktree.locked == true {
|
||||
TagBadge(text: ProjectDetailCopy.worktreeLocked)
|
||||
}
|
||||
if worktree.prunable == true {
|
||||
TagBadge(text: ProjectDetailCopy.worktreePrunable)
|
||||
}
|
||||
}
|
||||
Text(verbatim: worktree.path)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if worktree.locked == true {
|
||||
Text(WorktreeCopy.lockedHint)
|
||||
.dsMetaText()
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
// 主 worktree(仓库本体)与锁定的不给入口 —— 服务器必拒(400/409)。
|
||||
if WorktreeViewModel.canRemove(worktree) {
|
||||
Button(WorktreeCopy.removeButton, role: .destructive) {
|
||||
worktreePendingRemoval = worktree
|
||||
}
|
||||
.accessibilityLabel(WorktreeCopy.removeAccessibilityLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +511,7 @@ struct DirtyBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// worktree 属性标签(主/当前/已锁定):accent 描边胶囊。
|
||||
/// worktree 属性标签(主/当前/已锁定/可回收):accent 描边胶囊。
|
||||
struct TagBadge: View {
|
||||
let text: String
|
||||
|
||||
@@ -297,6 +540,8 @@ enum ProjectDetailCopy {
|
||||
static let worktreeMain = "主"
|
||||
static let worktreeCurrent = "当前"
|
||||
static let worktreeLocked = "已锁定"
|
||||
static let worktreePrunable = "可回收"
|
||||
static let worktreeSwipeHint = "左滑一行可删除该 worktree(主 worktree 与已锁定的不可删)。"
|
||||
static let detachedHead = "(分离 HEAD)"
|
||||
static let claudeMdHeader = "CLAUDE.md"
|
||||
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"
|
||||
|
||||
@@ -39,7 +39,11 @@ struct ProjectsScreen: View {
|
||||
viewModel: viewModel.makeDetailViewModel(path: route.path),
|
||||
endpoint: viewModel.host.endpoint,
|
||||
http: viewModel.http,
|
||||
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
|
||||
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) },
|
||||
// T-iOS-32(C2):历史会话恢复走同一条开会话缝。
|
||||
onResumeClaude: { cwd, sessionId in
|
||||
viewModel.requestResumeClaude(cwd: cwd, sessionId: sessionId)
|
||||
}
|
||||
)
|
||||
}
|
||||
// T-iPad-4 · iPhone 透传(现有全高 sheet 字节级不变);iPad 套
|
||||
|
||||
116
ios/App/WebTerm/Screens/ResumeHistorySheet.swift
Normal file
116
ios/App/WebTerm/Screens/ResumeHistorySheet.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
import APIClient
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-32 · `claude --resume <id>` 历史选择器(`GET /sessions`)。
|
||||
///
|
||||
/// 只列出 cwd 落在本仓库内的历史会话(在别的仓库跑过的会话,用本仓库的 cwd 去
|
||||
/// resume 是错的)。选中一条 → 在**该会话自己的 cwd** 里开新会话并注入
|
||||
/// `claude --resume <id>\r`(worktree 会话因此回到那个 worktree)。
|
||||
///
|
||||
/// 安全:id/cwd/preview 全是不可信服务器字节 —— 一律 `Text(verbatim:)`;
|
||||
/// id 未过 `ProjectResumeCommand` 白名单的行**不提供恢复按钮**(那串东西会被送进
|
||||
/// 一条真的 PTY 命令行)。
|
||||
struct ResumeHistorySheet: View {
|
||||
@State private var viewModel: ResumeHistoryViewModel
|
||||
/// (cwd, sessionId) —— 由详情屏转交给"在此仓库开会话"的同一条缝。
|
||||
let onResume: (String, String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private enum Metrics {
|
||||
/// 首条提示的展示行数上限(服务器已截断到 120 字符)。
|
||||
static let previewLineLimit = 2
|
||||
}
|
||||
|
||||
init(viewModel: ResumeHistoryViewModel, onResume: @escaping (String, String) -> Void) {
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
self.onResume = onResume
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.navigationTitle(ResumeCopy.sheetTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(WorktreeCopy.cancel) { dismiss() }
|
||||
}
|
||||
}
|
||||
.task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch viewModel.phase {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .empty:
|
||||
ContentUnavailableView {
|
||||
Label(ResumeCopy.emptyTitle, systemImage: "clock.arrow.circlepath")
|
||||
} description: {
|
||||
Text(ResumeCopy.emptyDetail)
|
||||
}
|
||||
case .failed(let message):
|
||||
ContentUnavailableView {
|
||||
Label(ResumeCopy.loadFailed, systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(verbatim: message)
|
||||
} actions: {
|
||||
Button(ResumeCopy.retry) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(DS.Palette.accent)
|
||||
}
|
||||
case .loaded(let candidates):
|
||||
List(candidates) { candidate in
|
||||
row(candidate)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ candidate: ResumeHistoryViewModel.ResumeCandidate) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
// 首条提示(服务器已折叠空白并截断)→ verbatim。
|
||||
Text(verbatim: candidate.session.preview.isEmpty
|
||||
? ResumeCopy.noPreview : candidate.session.preview)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(Metrics.previewLineLimit)
|
||||
Text(verbatim: candidate.cwd)
|
||||
.font(DS.Typography.mono(.caption2))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text(GitTimeFormat.relative(
|
||||
fromMs: candidate.session.mtimeMs,
|
||||
nowMs: Date().timeIntervalSince1970 * 1_000
|
||||
))
|
||||
.dsMetaText()
|
||||
if !candidate.canResume {
|
||||
Text(ResumeCopy.notResumable)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
if candidate.canResume {
|
||||
Button(ResumeCopy.resume) {
|
||||
DS.Haptics.selection()
|
||||
onResume(candidate.cwd, candidate.session.id)
|
||||
dismiss()
|
||||
}
|
||||
.font(DS.Typography.body.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
|
||||
.accessibilityLabel(
|
||||
ResumeCopy.resumeAccessibilityLabel(candidate.session.project)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,17 @@ struct SessionListScreen: View {
|
||||
} label: {
|
||||
Label(ScreenCopy.addHost, systemImage: "plus")
|
||||
}
|
||||
// C1 · Same sheet as 配对新主机 — `PairingScreen` is the host
|
||||
// management surface (access token + 移除主机), and it already
|
||||
// receives the real `HostStore` through its VM. A second entry
|
||||
// with the management label is what makes those two actions
|
||||
// discoverable without a second navigation path to maintain.
|
||||
Button {
|
||||
onAddHost()
|
||||
} label: {
|
||||
Label(ScreenCopy.manageHosts, systemImage: "key")
|
||||
}
|
||||
.accessibilityIdentifier("sessions.manageHosts")
|
||||
Button {
|
||||
onEnroll()
|
||||
} label: {
|
||||
@@ -365,6 +376,8 @@ private enum ScreenCopy {
|
||||
static let newSession = "新建会话"
|
||||
static let kill = "结束"
|
||||
static let addHost = "配对新主机"
|
||||
/// C1 · Access token + 移除主机 (same sheet as `addHost` — see the menu).
|
||||
static let manageHosts = "管理主机与令牌"
|
||||
static let deviceCert = "设备证书"
|
||||
static let enroll = "自动获取证书"
|
||||
static let hostMenuFallback = "主机"
|
||||
|
||||
122
ios/App/WebTerm/Screens/SettingsScreen.swift
Normal file
122
ios/App/WebTerm/Screens/SettingsScreen.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-34 · 设置页 —— 目前只有一件事:**主题**(跟随系统 / 深色 / 浅色)。
|
||||
///
|
||||
/// 刻意不做成「一堆开关」:终端字号跟随系统 Dynamic Type(`TerminalTheme` 取
|
||||
/// `.footnote` 度量),主机/令牌在配对页,快捷键栏在终端工具栏 —— 那些都有更近
|
||||
/// 的入口,搬到这里只会多一条重复路径。
|
||||
///
|
||||
/// 选择即生效:`ThemeStore.select` 落盘 + `@Observable` 触发根视图重算
|
||||
/// `preferredColorScheme`,不需要「保存」按钮。
|
||||
struct SettingsScreen: View {
|
||||
/// 主题真值(`RootView` 持有并注入;这里只读写它,不自己存一份)。
|
||||
let themeStore: ThemeStore
|
||||
|
||||
/// 当前**有效**外观 —— 根视图的 `preferredColorScheme` 已经把它压进了环境,
|
||||
/// 所以终端预览直接读环境即可,不必再算一遍「跟随系统时到底是哪档」。
|
||||
@Environment(\.colorScheme) private var effectiveScheme
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
themeSection
|
||||
terminalPreviewSection
|
||||
}
|
||||
.navigationTitle(SettingsCopy.title)
|
||||
}
|
||||
|
||||
// MARK: - 主题选择
|
||||
|
||||
private var themeSection: some View {
|
||||
Section {
|
||||
ForEach(AppTheme.allCases, id: \.self) { theme in
|
||||
themeRow(theme)
|
||||
}
|
||||
} header: {
|
||||
Text(SettingsCopy.themeHeader)
|
||||
} footer: {
|
||||
Text(SettingsCopy.themeFooter)
|
||||
}
|
||||
}
|
||||
|
||||
private func themeRow(_ theme: AppTheme) -> some View {
|
||||
Button {
|
||||
themeStore.select(theme)
|
||||
DS.Haptics.selection()
|
||||
} label: {
|
||||
HStack(spacing: DS.Space.md12) {
|
||||
Image(systemName: theme.symbolName)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.frame(width: DS.Space.xxl24)
|
||||
Text(theme.label)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
if themeStore.theme == theme {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: DS.Layout.minHitTarget)
|
||||
}
|
||||
.accessibilityIdentifier("settings.theme.\(theme.rawValue)")
|
||||
// 选中态对 VoiceOver 可见(勾号是视觉信号,不是语义信号)。
|
||||
.accessibilityAddTraits(themeStore.theme == theme ? [.isSelected] : [])
|
||||
}
|
||||
|
||||
// MARK: - 终端画布预览
|
||||
|
||||
/// 主题的最大可见差异就是终端那一整块画布,所以给一个真实取色的预览条:
|
||||
/// 背景/前景/光标全部来自 `TerminalPalette`(与真终端同一出处)。
|
||||
private var terminalPreviewSection: some View {
|
||||
Section {
|
||||
let colors = TerminalPalette.colors(for: effectiveScheme)
|
||||
HStack(spacing: DS.Space.xs2) {
|
||||
Text(verbatim: SettingsCopy.terminalSample)
|
||||
.font(DS.Typography.mono(.footnote))
|
||||
.foregroundStyle(Color(uiColor: colors.foreground))
|
||||
Rectangle()
|
||||
.fill(Color(uiColor: colors.caret))
|
||||
.frame(width: DS.Space.sm8, height: DS.Space.lg16)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(DS.Space.md12)
|
||||
.background(
|
||||
Color(uiColor: colors.background),
|
||||
in: RoundedRectangle(cornerRadius: DS.Radius.sm8)
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(SettingsCopy.terminalPreviewA11y)
|
||||
} header: {
|
||||
Text(SettingsCopy.terminalHeader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页用户可见文案。
|
||||
enum SettingsCopy {
|
||||
static let title = "设置"
|
||||
static let themeHeader = "外观"
|
||||
static let themeFooter = "默认深色 —— 与网页端一致。选「跟随系统」时随 iOS 的浅色/深色切换。"
|
||||
static let terminalHeader = "终端预览"
|
||||
static let terminalSample = "$ claude --resume"
|
||||
static let terminalPreviewA11y = "终端配色预览"
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("SettingsScreen") {
|
||||
NavigationStack {
|
||||
SettingsScreen(themeStore: ThemeStore(defaults: PreviewThemeDefaults()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览用的内存 defaults —— 预览绝不写用户的真实 `UserDefaults`。
|
||||
private final class PreviewThemeDefaults: ThemeDefaults {
|
||||
private var values: [String: String] = [:]
|
||||
|
||||
func themeRaw(forKey key: String) -> String? { values[key] }
|
||||
|
||||
func setThemeRaw(_ value: String, forKey key: String) {
|
||||
values = values.merging([key: value]) { _, new in new }
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,13 @@ struct TerminalScreen: View {
|
||||
/// T-iPad-3 · 用户对 KeyBar 的显式覆盖:nil = 跟随自动默认。
|
||||
@State private var keyBarUserOverride: Bool?
|
||||
|
||||
/// T-iOS-33 · 终端内搜索面板状态(引擎在 `makeUIView` 里绑定)。
|
||||
@State private var searchModel = TerminalSearchModel()
|
||||
/// T-iOS-31 · 语音 PTT 状态机 + 它的 epoch 源。两者在 `init` 里成对建立,
|
||||
/// 因为 PTT 的注入出口/只读判据/epoch 都来自本屏的 `viewModel`。
|
||||
@State private var voiceEpochSource: VoiceEpochSource
|
||||
@State private var voiceModel: VoicePTTViewModel
|
||||
|
||||
/// 无障碍:减弱动态效果时,横幅进出塌成即时切换(DS.Motion.gated)。
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@@ -46,6 +53,33 @@ struct TerminalScreen: View {
|
||||
static let hideKeyBar = "隐藏快捷键栏"
|
||||
}
|
||||
|
||||
/// 显式 `init`(成员逐一对齐旧的合成 init,调用点不变):语音 PTT 的
|
||||
/// ViewModel 必须在 `@State` 初值里就拿到本屏的 `viewModel`,才能把注入出口
|
||||
/// 接到 `sendInput`、把 epoch 接到 sessionId/连接世代上。
|
||||
///
|
||||
/// SwiftUI 只保留**首次**的 `@State` 初值 —— 这正确,因为
|
||||
/// `TerminalContainerView` 用 `.id(controller.generation)` 换代时会整屏重建,
|
||||
/// 一代之内 `controller.terminalViewModel` 是稳定的。
|
||||
init(
|
||||
viewModel: TerminalViewModel,
|
||||
onNewSessionInCwd: (@MainActor () -> Void)? = nil,
|
||||
onKillSession: (@MainActor () -> Void)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.onNewSessionInCwd = onNewSessionInCwd
|
||||
self.onKillSession = onKillSession
|
||||
|
||||
let epochSource = VoiceEpochSource()
|
||||
_voiceEpochSource = State(initialValue: epochSource)
|
||||
_voiceModel = State(initialValue: VoicePTTViewModel(
|
||||
dictation: SpeechDictation(),
|
||||
clock: ContinuousClock(),
|
||||
epoch: { epochSource.epoch },
|
||||
isReadOnly: { viewModel.isReadOnly },
|
||||
inject: { text in viewModel.sendInput(text) }
|
||||
))
|
||||
}
|
||||
|
||||
/// KeyBar 是否可见 —— 唯一判据经 `KeyBarVisibility` 纯谓词。
|
||||
private var isKeyBarVisible: Bool {
|
||||
KeyBarVisibility.isVisible(
|
||||
@@ -58,6 +92,8 @@ struct TerminalScreen: View {
|
||||
TerminalHostView(
|
||||
viewModel: viewModel,
|
||||
keyBarVisible: isKeyBarVisible,
|
||||
searchModel: searchModel,
|
||||
voiceModel: voiceModel,
|
||||
onNewSessionInCwd: onNewSessionInCwd,
|
||||
onKillSession: onKillSession
|
||||
)
|
||||
@@ -70,11 +106,37 @@ struct TerminalScreen: View {
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
// T-iOS-33 · 搜索条钉在右上 —— 位置对齐 web `#searchbox`
|
||||
// (`position:fixed; top; right`, style.css:812)。
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if searchModel.isPresented {
|
||||
TerminalSearchBar(model: searchModel)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.top, DS.Space.sm8)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
// T-iOS-31 · PTT 卡贴底(紧邻键栏上的 🎤 键)。
|
||||
.overlay(alignment: .bottom) {
|
||||
VoicePTTBanner(model: voiceModel)
|
||||
.padding(.horizontal, DS.Space.md12)
|
||||
.padding(.bottom, DS.Space.xl20)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: viewModel.bannerModel
|
||||
)
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: searchModel.isPresented
|
||||
)
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: voiceModel.phase
|
||||
)
|
||||
.toolbar {
|
||||
searchToolbarItem
|
||||
newSessionToolbarItem
|
||||
keyBarToggleToolbarItem
|
||||
}
|
||||
@@ -84,7 +146,34 @@ struct TerminalScreen: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
|
||||
hasHardwareKeyboard = GCKeyboard.coalesced != nil
|
||||
}
|
||||
.onAppear { viewModel.start() }
|
||||
// T-iOS-31 · epoch 源跟住会话身份与连接世代:任一变化都作废在飞的口述。
|
||||
.onChange(of: viewModel.sessionId) { _, id in voiceEpochSource.noteSession(id) }
|
||||
.onChange(of: viewModel.banner) { _, banner in voiceEpochSource.noteBanner(banner) }
|
||||
.onAppear {
|
||||
viewModel.start()
|
||||
voiceEpochSource.noteSession(viewModel.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iOS-33 · 搜索开关(镜像 web toolbar 的 🔍:开/关同一个键)。
|
||||
@ToolbarContentBuilder private var searchToolbarItem: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
if searchModel.isPresented {
|
||||
searchModel.dismiss()
|
||||
} else {
|
||||
searchModel.present()
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
searchModel.isPresented
|
||||
? TerminalSearchModel.Copy.close : TerminalSearchModel.Copy.open,
|
||||
systemImage: searchModel.isPresented ? "magnifyingglass.circle.fill"
|
||||
: "magnifyingglass"
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("terminal.searchToggleButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iPad-3 · KeyBar 手动切换。仅在硬件键盘在场时出现 —— 无硬件键盘的
|
||||
@@ -155,6 +244,10 @@ private struct TerminalHostView: UIViewRepresentable {
|
||||
/// T-iPad-3 · KeyBar (`inputAccessoryView`) 可见性,由 `KeyBarVisibility`
|
||||
/// 谓词在 SwiftUI 侧算出;`updateUIView` 仅在变化时增量应用。
|
||||
let keyBarVisible: Bool
|
||||
/// T-iOS-33 · 搜索面板 —— `makeUIView` 把 SwiftTerm 视图绑给它做检索引擎。
|
||||
let searchModel: TerminalSearchModel
|
||||
/// T-iOS-31 · PTT 状态机 —— 键栏 🎤 键的按下/松手出口。
|
||||
let voiceModel: VoicePTTViewModel
|
||||
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
||||
var onKillSession: (@MainActor () -> Void)? = nil
|
||||
|
||||
@@ -170,7 +263,15 @@ private struct TerminalHostView: UIViewRepresentable {
|
||||
let viewModel = viewModel
|
||||
terminal.onKeyCommand = { key in viewModel.send(key: key) }
|
||||
|
||||
let keyBar = KeyBarView()
|
||||
// T-iOS-33 · SwiftTerm 自带搜索即检索引擎(纯读,绝不产生 PTY 字节)。
|
||||
searchModel.attach(terminal)
|
||||
|
||||
// T-iOS-31 · 🎤 键:按下开录、松手收尾,全程不经 KeyByteMap。
|
||||
let voiceModel = voiceModel
|
||||
let keyBar = KeyBarView(voice: KeyBarVoiceHandlers(
|
||||
onDown: { Task { await voiceModel.pressDown() } },
|
||||
onUp: { Task { await voiceModel.pressUp() } }
|
||||
))
|
||||
keyBar.onKey = { key in viewModel.send(key: key) }
|
||||
terminal.installKeyBar(keyBar, visible: keyBarVisible)
|
||||
|
||||
@@ -323,11 +424,15 @@ final class KeyCommandTerminalView: TerminalView {
|
||||
addInteraction(UIContextMenuInteraction(delegate: delegate))
|
||||
}
|
||||
|
||||
/// Whether a selection exists — reuses SwiftTerm's own `copy` eligibility
|
||||
/// (`canPerformAction` returns `selection.active`); pure read, no bytes.
|
||||
var hasActiveSelection: Bool {
|
||||
canPerformAction(#selector(UIResponderStandardEditActions.copy(_:)), withSender: nil)
|
||||
}
|
||||
// NOTE (T-iOS-33/31 root fix): the "does a selection exist" read used to be
|
||||
// a LOCAL `hasActiveSelection` here, computed via SwiftTerm's own `copy`
|
||||
// eligibility (`canPerformAction` answers from `selection.active`). SwiftTerm
|
||||
// v1.14.0 promoted the very same thing to `public var hasActiveSelection`
|
||||
// (`selection?.active ?? false`) on its own view, which then COLLIDED with
|
||||
// the local one and pinned the dependency at 1.13.0 (`override` cannot fix a
|
||||
// `public`-but-not-`open` property). The local copy is therefore gone and
|
||||
// every caller — the pointer context menu, the find-bar tests — now reads
|
||||
// upstream's property, so the pin rides at 1.15.0. Do not reintroduce it.
|
||||
|
||||
/// Copy the current selection via SwiftTerm's own `copy(_:)` (selection →
|
||||
/// `UIPasteboard`). Pure UI: it never writes to the PTY, so the byte stream
|
||||
@@ -336,3 +441,23 @@ final class KeyCommandTerminalView: TerminalView {
|
||||
copy(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal search binding (T-iOS-33)
|
||||
|
||||
/// The find bar's engine is SwiftTerm's own scrollback search
|
||||
/// (`TerminalViewSearch.swift`): `findNext`/`findPrevious` select + scroll the
|
||||
/// match (that selection IS the highlight) and `clearSearch` drops both. Pure
|
||||
/// read — no PTY byte is produced, so search also works on an exited session.
|
||||
extension KeyCommandTerminalView: TerminalSearching {
|
||||
func searchNext(term: String) -> Bool {
|
||||
findNext(term)
|
||||
}
|
||||
|
||||
func searchPrevious(term: String) -> Bool {
|
||||
findPrevious(term)
|
||||
}
|
||||
|
||||
func searchClear() {
|
||||
clearSearch()
|
||||
}
|
||||
}
|
||||
|
||||
131
ios/App/WebTerm/Screens/WorktreeSheet.swift
Normal file
131
ios/App/WebTerm/Screens/WorktreeSheet.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import APIClient
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-32 · 新建 Worktree(`POST /projects/worktree`,G)。
|
||||
///
|
||||
/// 表单只有两格:新分支名(必填、即时校验)+ 起点 ref(留空 = 当前 HEAD)。
|
||||
/// 成功后展示服务器给的 canonical 路径/分支,并提供"在新 Worktree 开会话"——
|
||||
/// 这就是 "spin up a worktree, land the winner, delete the rest" 循环的入口。
|
||||
///
|
||||
/// 安全:分支名先过 `WorktreeBranchRule`(校验先于网络);服务器返回的路径/分支/
|
||||
/// 错误文案都是不可信字节 → `Text(verbatim:)`。
|
||||
struct WorktreeSheet: View {
|
||||
@State private var viewModel: WorktreeViewModel
|
||||
/// 创建成功(详情屏据此刷新 worktree 列表)。
|
||||
let onCreated: () -> Void
|
||||
/// "在新 worktree 开会话":把服务器给的 canonical 路径交回详情屏的开会话钩子。
|
||||
let onOpenSession: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(
|
||||
viewModel: WorktreeViewModel,
|
||||
onCreated: @escaping () -> Void,
|
||||
onOpenSession: @escaping (String) -> Void
|
||||
) {
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
self.onCreated = onCreated
|
||||
self.onOpenSession = onOpenSession
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var bindable = viewModel
|
||||
return NavigationStack {
|
||||
Form {
|
||||
if case .created(let path, let branch) = viewModel.createPhase {
|
||||
createdSection(path: path, branch: branch)
|
||||
} else {
|
||||
formSection(
|
||||
branch: $bindable.branchText, base: $bindable.baseText
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(WorktreeCopy.sheetTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(WorktreeCopy.cancel) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func formSection(
|
||||
branch: Binding<String>, base: Binding<String>
|
||||
) -> some View {
|
||||
Section {
|
||||
TextField(WorktreeCopy.branchField, text: branch)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.done)
|
||||
.onSubmit { submit() }
|
||||
TextField(WorktreeCopy.baseField, text: base)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} footer: {
|
||||
if let message = viewModel.branchValidationMessage {
|
||||
Text(message)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
submit()
|
||||
} label: {
|
||||
if viewModel.createPhase == .creating {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
|
||||
} else {
|
||||
Label(WorktreeCopy.submit, systemImage: "plus.rectangle.on.folder")
|
||||
}
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(!viewModel.canSubmitCreate)
|
||||
.listRowBackground(Color.clear)
|
||||
if case .failed(let message) = viewModel.createPhase {
|
||||
InlineMessage(text: message, tone: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func createdSection(path: String, branch: String) -> some View {
|
||||
Section(WorktreeCopy.createdTitle) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
Text(verbatim: branch)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(verbatim: path)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
DS.Haptics.selection()
|
||||
onOpenSession(path)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label(WorktreeCopy.openInNewWorktree, systemImage: "terminal.fill")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(path.isEmpty)
|
||||
.listRowBackground(Color.clear)
|
||||
Button(WorktreeCopy.cancel) { dismiss() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
Task {
|
||||
await viewModel.create()
|
||||
if case .created = viewModel.createPhase {
|
||||
DS.Haptics.success()
|
||||
onCreated() // 详情屏刷新(新 worktree 立刻出现在列表里)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
161
ios/App/WebTerm/ViewModels/GitPanelPresentation.swift
Normal file
161
ios/App/WebTerm/ViewModels/GitPanelPresentation.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
|
||||
// C2 · git 面板的**纯呈现规则**(零 I/O、零 SwiftUI)—— 把 w6 那条"只有一种
|
||||
// 状态可以显示为绿色"的设计铁律做成可单测的数据,而不是散落在视图里的条件。
|
||||
//
|
||||
// 真源:`docs/plans/w6-project-git-panel.md`(Design rule that drives everything)
|
||||
// + web 参照实现 `public/projects.ts:615-745 makeSyncBand`。iOS 与 web 必须给出
|
||||
// 同一判断,只有布局不同(手机上四列带换成竖排卡片)。
|
||||
|
||||
// MARK: - 同步带(w6/G3)
|
||||
|
||||
/// 项目/worktree 的上游同步态,已按"可信度"归约完毕。
|
||||
///
|
||||
/// 为什么每个字段都是 optional 而不是 0:`ahead`/`behind` 是对 `@{u}` 的比较,
|
||||
/// 而 `@{u}` 是**本地缓存**的远端引用,只有 fetch 会推动它。所以
|
||||
/// - `ahead` 只用本地引用 ⇒ 永远可信;
|
||||
/// - `behind` 的新鲜度**不超过** `lastFetchMs` ⇒ 过期时必须标"未核实";
|
||||
/// - `upstream == nil` 意思是"无可比对",**不是**"没有要推的东西";
|
||||
/// - `nil` 计数是"算不出来",用 `?? 0` 折叠它就会让一个读取失败的仓库显示绿色 ——
|
||||
/// 这正是本类型存在的原因。
|
||||
struct GitSyncBand: Equatable {
|
||||
/// HEAD 的三种处境(互斥)。
|
||||
enum Head: Equatable {
|
||||
/// 分离 HEAD:没有分支,也就没有 ahead/behind。
|
||||
case detached
|
||||
/// 分支不跟踪任何上游(新建 worktree 分支的常态)。
|
||||
case noUpstream
|
||||
/// 正常跟踪。计数为 nil = 读不到(渲染 `—`,绝不当 0)。
|
||||
case tracking(upstream: String, ahead: Int?, behind: Int?)
|
||||
}
|
||||
|
||||
/// 工作区脏度三态。`unknown` 是主机关掉了 dirty 检查(`PROJECT_DIRTY_CHECK=0`),
|
||||
/// **不等于** clean —— 把它渲染成"干净"就是在替主机说谎。
|
||||
enum Dirty: Equatable {
|
||||
case unknown
|
||||
case clean
|
||||
case changed(Int)
|
||||
}
|
||||
|
||||
/// `FETCH_HEAD` 可以有多旧,`behind` 才不再可信(镜像 web `FETCH_STALE_MS`,
|
||||
/// `public/projects.ts:615`)。
|
||||
static let fetchStaleMs: Double = 60 * 60 * 1000
|
||||
|
||||
let head: Head
|
||||
let dirty: Dirty
|
||||
/// 唯一允许显示为绿色的状态:有上游 && ↑0 && ↓0 && fetch 新鲜。
|
||||
let isInSync: Bool
|
||||
/// `behind` 是否经过一次新鲜 fetch 核实过(false ⇒ 必须标"未核实")。
|
||||
let isBehindVerified: Bool
|
||||
/// 分离 HEAD 上没有可 fetch 的目标(服务器会 400)——按钮直接禁用。
|
||||
let canFetch: Bool
|
||||
|
||||
/// nil = 非 git 目录(没有任何可如实陈述的内容)。
|
||||
static func make(sync: SyncState?, dirtyCount: Int?, nowMs: Double) -> GitSyncBand? {
|
||||
guard let sync else { return nil }
|
||||
let isStale = Self.isStale(lastFetchMs: sync.lastFetchMs, nowMs: nowMs)
|
||||
let head = Self.head(for: sync)
|
||||
let isTracking: Bool
|
||||
if case .tracking = head { isTracking = true } else { isTracking = false }
|
||||
return GitSyncBand(
|
||||
head: head,
|
||||
dirty: Self.dirty(for: dirtyCount),
|
||||
isInSync: isTracking && sync.ahead == 0 && sync.behind == 0 && !isStale,
|
||||
isBehindVerified: isTracking && !isStale && sync.behind != nil,
|
||||
canFetch: sync.detached != true
|
||||
)
|
||||
}
|
||||
|
||||
/// 从未 fetch(nil)也算过期 —— "没 fetch 过"比"很久没 fetch"更不可信。
|
||||
private static func isStale(lastFetchMs: Double?, nowMs: Double) -> Bool {
|
||||
guard let lastFetchMs else { return true }
|
||||
return nowMs - lastFetchMs > fetchStaleMs
|
||||
}
|
||||
|
||||
private static func head(for sync: SyncState) -> Head {
|
||||
if sync.detached == true { return .detached }
|
||||
guard let upstream = sync.upstream else { return .noUpstream }
|
||||
return .tracking(upstream: upstream, ahead: sync.ahead, behind: sync.behind)
|
||||
}
|
||||
|
||||
private static func dirty(for dirtyCount: Int?) -> Dirty {
|
||||
guard let dirtyCount else { return .unknown }
|
||||
return dirtyCount > 0 ? .changed(dirtyCount) : .clean
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 未推送边界(w6/G4)
|
||||
|
||||
/// `git log` 列表里"已推送/未推送"的分界线位置。
|
||||
///
|
||||
/// 标记本身由服务器算(`unpushed`,按 SHA 前缀匹配 `@{u}..HEAD`)——客户端只决定
|
||||
/// 画在哪一行之后,且**只画一次**。日期序会把一个未推送的 merge 排到已推送提交
|
||||
/// 下面,所以边界必须取"最后一个 unpushed"的位置,而不是"前 N 行"。
|
||||
enum GitLogBoundary {
|
||||
/// 边界画在返回索引所指行的**下方**;nil = 不画(无上游 ⇒ 无可比对;
|
||||
/// 或者一条未推送都没有)。
|
||||
static func index(commits: [CommitLogEntry], upstream: String?) -> Int? {
|
||||
guard upstream != nil else { return nil }
|
||||
// `unpushed` 只在严格 true 时生效:nil 是"服务器没说",不是 false。
|
||||
return commits.lastIndex { $0.unpushed == true }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 相对时间
|
||||
|
||||
/// 提交时间/最近 fetch 的相对文案(中文)。服务器时间戳可能因主机时钟漂移落在
|
||||
/// 未来 —— 那时退化为「刚刚」,绝不输出负数。
|
||||
enum GitTimeFormat {
|
||||
private static let msPerSecond: Double = 1_000
|
||||
private static let secondsPerMinute: Double = 60
|
||||
private static let secondsPerHour: Double = 60 * 60
|
||||
private static let secondsPerDay: Double = 24 * 60 * 60
|
||||
/// 一分钟以内一律「刚刚」。
|
||||
private static let justNowSeconds: Double = 60
|
||||
|
||||
static func relative(fromMs: Double, nowMs: Double) -> String {
|
||||
let seconds = max(0, (nowMs - fromMs) / msPerSecond)
|
||||
if seconds < justNowSeconds { return Copy.justNow }
|
||||
if seconds < secondsPerHour {
|
||||
return Copy.minutesAgo(Int(seconds / secondsPerMinute))
|
||||
}
|
||||
if seconds < secondsPerDay {
|
||||
return Copy.hoursAgo(Int(seconds / secondsPerHour))
|
||||
}
|
||||
return Copy.daysAgo(Int(seconds / secondsPerDay))
|
||||
}
|
||||
|
||||
private enum Copy {
|
||||
static let justNow = "刚刚"
|
||||
static func minutesAgo(_ value: Int) -> String { "\(value) 分钟前" }
|
||||
static func hoursAgo(_ value: Int) -> String { "\(value) 小时前" }
|
||||
static func daysAgo(_ value: Int) -> String { "\(value) 天前" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 写操作失败文案
|
||||
|
||||
/// git 写操作(stage/commit/push/fetch/worktree)的错误话术**单一出口**。
|
||||
///
|
||||
/// 纪律:服务器已经把 git stderr 分类成安全短句(SEC-M10),客户端**原样显示**
|
||||
/// 那句话 —— 既不吞掉(用户会以为操作成功了),也不自己编造原因。只有服务器
|
||||
/// 没给 message 时才落到调用方的兜底短语。
|
||||
enum GitWriteFeedback {
|
||||
static func message(status: Int, serverMessage: String?, fallback: String) -> String {
|
||||
guard let serverMessage, !serverMessage.isEmpty else {
|
||||
return "\(fallback)(HTTP \(status))"
|
||||
}
|
||||
return serverMessage
|
||||
}
|
||||
|
||||
/// 抛出的错误:`APIClientError` 自带面向用户的话术(含 401 引导补令牌),
|
||||
/// 其余(传输层)落兜底短语 —— 绝不把 `localizedDescription` 当主文案,
|
||||
/// 它是英文系统串。
|
||||
static func message(for error: any Error, fallback: String) -> String {
|
||||
(error as? APIClientError)?.message ?? fallback
|
||||
}
|
||||
|
||||
/// 429:限流是服务器的保护机制,**绝不**自动重试。
|
||||
static let rateLimited = APIClientError.rateLimited.message
|
||||
}
|
||||
369
ios/App/WebTerm/ViewModels/GitPanelViewModel.swift
Normal file
369
ios/App/WebTerm/ViewModels/GitPanelViewModel.swift
Normal file
@@ -0,0 +1,369 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// C2 · git 面板状态(`docs/plans/w6-project-git-panel.md` 的移动端对齐)。
|
||||
///
|
||||
/// 面板的职责是**如实告诉你发生了什么**,并提供 web 已有的那几个写操作:
|
||||
/// 同步带(↑↓ + 脏度 + fetch)· 改动文件的 stage/unstage · commit · push ·
|
||||
/// `git log`(含未推送边界)· PR/CI 芯片。
|
||||
///
|
||||
/// 三条纪律(都被单测钉住):
|
||||
/// 1. **绝不本地推算 git 数字**。任一写操作成功后重新向服务器要一遍详情/日志 ——
|
||||
/// `ahead`/`behind`/`lastFetchMs` 只有服务器说的算(w6 的核心教训:本地缓存的
|
||||
/// 数字会在 push 后继续撒谎)。
|
||||
/// 2. **分区独立降级**。日志失败不该让同步带消失,PR(gh 会走外网)单独加载,
|
||||
/// 详情失败就**没有**同步带,而不是显示一个编造的"已同步"。
|
||||
/// 3. **服务器安全文案原样上浮**(SEC-M10 已在服务端把 stderr 分类成安全短句)——
|
||||
/// 写操作绝不静默失败。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GitPanelViewModel {
|
||||
|
||||
/// 一个待处理的改动文件。
|
||||
///
|
||||
/// `stagePaths` 与显示路径分开:重命名必须**同时**把 oldPath 与 newPath 交给
|
||||
/// `git add`(镜像 web `public/diff.ts` 的做法),否则只暂存新增侧、删除侧留在
|
||||
/// 工作区,提交出来是"复制"而不是"重命名"。
|
||||
struct ChangedFile: Equatable, Identifiable, Sendable {
|
||||
let displayPath: String
|
||||
let stagePaths: [String]
|
||||
let status: DiffFileStatus
|
||||
let added: Int
|
||||
let removed: Int
|
||||
|
||||
var id: String { displayPath }
|
||||
|
||||
static func make(from file: DiffFile) -> ChangedFile {
|
||||
let primary = file.newPath.isEmpty ? file.oldPath : file.newPath
|
||||
let isRename = file.status == .renamed && !file.oldPath.isEmpty
|
||||
&& file.oldPath != file.newPath
|
||||
return ChangedFile(
|
||||
displayPath: isRename ? "\(file.oldPath) → \(file.newPath)" : primary,
|
||||
stagePaths: isRename ? [file.oldPath, file.newPath] : [primary],
|
||||
status: file.status,
|
||||
added: file.added,
|
||||
removed: file.removed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 正在进行的写操作(用于禁用按钮 + 行内 spinner)。
|
||||
enum Operation: Equatable {
|
||||
case fetch
|
||||
case commit
|
||||
case push
|
||||
case stage(String)
|
||||
case unstage(String)
|
||||
}
|
||||
|
||||
/// 注入的依赖 —— 生产由 `forProject` 包 `APIClient`/`DiffFetcher`,测试注入 fake。
|
||||
struct Dependencies: Sendable {
|
||||
let detail: @Sendable () async throws -> ProjectDetail
|
||||
let log: @Sendable () async throws -> GitLogResult
|
||||
let pr: @Sendable () async throws -> PrStatus
|
||||
let changes: @Sendable (_ staged: Bool) async throws -> [ChangedFile]
|
||||
let stage: @Sendable (_ files: [String], _ stage: Bool) async throws
|
||||
-> GitWriteOutcome<StageResult>
|
||||
let commit: @Sendable (_ message: String) async throws -> GitWriteOutcome<CommitResult>
|
||||
let push: @Sendable () async throws -> GitWriteOutcome<PushResult>
|
||||
let fetchRemote: @Sendable () async throws -> GitWriteOutcome<FetchResult>
|
||||
/// 判定 fetch 新鲜度的"现在"(可注入,测试才能确定性地跨过 1h 阈值)。
|
||||
let nowMs: @Sendable () -> Double
|
||||
|
||||
init(
|
||||
detail: @escaping @Sendable () async throws -> ProjectDetail,
|
||||
log: @escaping @Sendable () async throws -> GitLogResult,
|
||||
pr: @escaping @Sendable () async throws -> PrStatus,
|
||||
changes: @escaping @Sendable (Bool) async throws -> [ChangedFile],
|
||||
stage: @escaping @Sendable ([String], Bool) async throws -> GitWriteOutcome<StageResult>,
|
||||
commit: @escaping @Sendable (String) async throws -> GitWriteOutcome<CommitResult>,
|
||||
push: @escaping @Sendable () async throws -> GitWriteOutcome<PushResult>,
|
||||
fetchRemote: @escaping @Sendable () async throws -> GitWriteOutcome<FetchResult>,
|
||||
nowMs: @escaping @Sendable () -> Double = { Date().timeIntervalSince1970 * 1_000 }
|
||||
) {
|
||||
self.detail = detail
|
||||
self.log = log
|
||||
self.pr = pr
|
||||
self.changes = changes
|
||||
self.stage = stage
|
||||
self.commit = commit
|
||||
self.push = push
|
||||
self.fetchRemote = fetchRemote
|
||||
self.nowMs = nowMs
|
||||
}
|
||||
}
|
||||
|
||||
let path: String
|
||||
|
||||
private(set) var isLoaded = false
|
||||
private(set) var branch: String?
|
||||
/// nil = 非 git 目录 **或** 详情读取失败(见 `stateErrorMessage`)。
|
||||
private(set) var band: GitSyncBand?
|
||||
private(set) var stateErrorMessage: String?
|
||||
private(set) var log: GitLogResult?
|
||||
private(set) var logErrorMessage: String?
|
||||
private(set) var pr: PrStatus?
|
||||
private(set) var unstaged: [ChangedFile] = []
|
||||
private(set) var staged: [ChangedFile] = []
|
||||
private(set) var changesErrorMessage: String?
|
||||
private(set) var busy: Operation?
|
||||
/// 最近一次写操作失败(服务器安全文案优先)。
|
||||
private(set) var errorMessage: String?
|
||||
/// 最近一次写操作成功的简短确认。
|
||||
private(set) var noticeMessage: String?
|
||||
/// 提交信息草稿(`TextField` 绑定)。
|
||||
var commitMessage = ""
|
||||
|
||||
@ObservationIgnored
|
||||
private let dependencies: Dependencies
|
||||
|
||||
init(path: String, dependencies: Dependencies) {
|
||||
self.path = path
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
/// 生产装配缝(ProjectDetailScreen 只需转手 endpoint/http/path)。
|
||||
static func forProject(
|
||||
endpoint: HostEndpoint, http: any HTTPTransport, path: String
|
||||
) -> GitPanelViewModel {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let diff = DiffFetcher(endpoint: endpoint, http: http)
|
||||
return GitPanelViewModel(path: path, dependencies: Dependencies(
|
||||
detail: { try await client.projectDetail(path: path) },
|
||||
log: { try await client.gitLog(path: path) },
|
||||
pr: { try await client.prStatus(path: path) },
|
||||
changes: { staged in
|
||||
try await diff.fetch(path: path, staged: staged).files.map(ChangedFile.make(from:))
|
||||
},
|
||||
stage: { files, stage in
|
||||
try await client.gitStage(path: path, files: files, stage: stage)
|
||||
},
|
||||
commit: { message in try await client.gitCommit(path: path, message: message) },
|
||||
push: { try await client.gitPush(path: path) },
|
||||
fetchRemote: { try await client.gitFetch(path: path) }
|
||||
))
|
||||
}
|
||||
|
||||
var canCommit: Bool {
|
||||
busy == nil && !commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// 分离 HEAD 上没有可 fetch 的目标(服务器会 400)——按钮直接禁用。
|
||||
var canFetch: Bool { busy == nil && band?.canFetch == true }
|
||||
|
||||
// MARK: - 读
|
||||
|
||||
/// 首屏 + 每次写操作之后的刷新。三个分区各自独立降级。
|
||||
func load() async {
|
||||
await loadState()
|
||||
await loadLog()
|
||||
await loadChanges()
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
/// PR/CI 单独一条命令:gh 会替我们走一次外网调用,慢到秒级,绝不放进首屏串行链。
|
||||
func loadPullRequest() async {
|
||||
do {
|
||||
pr = try await dependencies.pr()
|
||||
} catch {
|
||||
// 200 + 降级 availability 才是"gh 不可用"的正常表达;真错误只是让芯片
|
||||
// 缺席(面板其余部分照常),绝不弹成写操作错误。
|
||||
pr = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func loadState() async {
|
||||
do {
|
||||
let detail = try await dependencies.detail()
|
||||
branch = detail.branch
|
||||
band = GitSyncBand.make(
|
||||
sync: detail.sync, dirtyCount: detail.dirtyCount, nowMs: dependencies.nowMs()
|
||||
)
|
||||
stateErrorMessage = nil
|
||||
} catch {
|
||||
branch = nil
|
||||
band = nil // 读不到就没有同步带 —— 绝不显示一个编造的"已同步"
|
||||
stateErrorMessage = GitWriteFeedback.message(
|
||||
for: error, fallback: GitPanelCopy.stateFailed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLog() async {
|
||||
do {
|
||||
log = try await dependencies.log()
|
||||
logErrorMessage = nil
|
||||
} catch {
|
||||
logErrorMessage = GitWriteFeedback.message(for: error, fallback: GitPanelCopy.logFailed)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChanges() async {
|
||||
do {
|
||||
unstaged = try await dependencies.changes(false)
|
||||
staged = try await dependencies.changes(true)
|
||||
changesErrorMessage = nil
|
||||
} catch {
|
||||
changesErrorMessage = GitWriteFeedback.message(
|
||||
for: error, fallback: GitPanelCopy.changesFailed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 写
|
||||
|
||||
func setStaged(_ file: ChangedFile, staged: Bool) async {
|
||||
await perform(
|
||||
staged ? .stage(file.id) : .unstage(file.id),
|
||||
fallback: staged ? GitPanelCopy.stageFailed : GitPanelCopy.unstageFailed,
|
||||
write: { try await self.dependencies.stage(file.stagePaths, staged) },
|
||||
onSuccess: { [weak self] (result: StageResult) in
|
||||
self?.noticeMessage = staged
|
||||
? GitPanelCopy.staged(result.count)
|
||||
: GitPanelCopy.unstaged(result.count)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func commit() async {
|
||||
let message = commitMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else {
|
||||
errorMessage = GitPanelCopy.commitMessageRequired
|
||||
return
|
||||
}
|
||||
await perform(
|
||||
.commit,
|
||||
fallback: GitPanelCopy.commitFailed,
|
||||
write: { try await self.dependencies.commit(message) },
|
||||
onSuccess: { [weak self] (result: CommitResult) in
|
||||
// 只有成功才清草稿:409「无暂存」时用户的文字必须留着。
|
||||
self?.commitMessage = ""
|
||||
self?.noticeMessage = GitPanelCopy.committed(result.commit)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func push() async {
|
||||
await perform(
|
||||
.push,
|
||||
fallback: GitPanelCopy.pushFailed,
|
||||
write: { try await self.dependencies.push() },
|
||||
onSuccess: { [weak self] (result: PushResult) in
|
||||
self?.noticeMessage = GitPanelCopy.pushed(
|
||||
branch: result.branch, remote: result.remote
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func fetchRemote() async {
|
||||
await perform(
|
||||
.fetch,
|
||||
fallback: GitPanelCopy.fetchFailed,
|
||||
write: { try await self.dependencies.fetchRemote() },
|
||||
onSuccess: { [weak self] (_: FetchResult) in
|
||||
self?.noticeMessage = GitPanelCopy.fetched
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 所有写操作的唯一执行点:忙态去重 → 三种结局映射 → 成功后**重新向服务器
|
||||
/// 取真相**。`write`/`onSuccess` 都是非逃逸闭包,因此在 MainActor 上原地执行。
|
||||
private func perform<Payload: Sendable & Equatable>(
|
||||
_ operation: Operation,
|
||||
fallback: String,
|
||||
write: () async throws -> GitWriteOutcome<Payload>,
|
||||
onSuccess: (Payload) -> Void
|
||||
) async {
|
||||
guard busy == nil else { return } // 重复点击只发一次
|
||||
busy = operation
|
||||
errorMessage = nil
|
||||
noticeMessage = nil
|
||||
do {
|
||||
switch try await write() {
|
||||
case .ok(let payload):
|
||||
onSuccess(payload)
|
||||
busy = nil
|
||||
await load() // 数字只认服务器(w6:本地推算会在 push 后继续撒谎)
|
||||
return
|
||||
case .rejected(let status, let message):
|
||||
errorMessage = GitWriteFeedback.message(
|
||||
status: status, serverMessage: message, fallback: fallback
|
||||
)
|
||||
case .rateLimited:
|
||||
errorMessage = GitWriteFeedback.rateLimited // 限流绝不自动重试
|
||||
}
|
||||
} catch {
|
||||
errorMessage = GitWriteFeedback.message(for: error, fallback: fallback)
|
||||
}
|
||||
busy = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量,plan §4)
|
||||
|
||||
enum GitPanelCopy {
|
||||
static let title = "Git 面板"
|
||||
static let entryLabel = "Git 面板"
|
||||
static let stateSection = "同步状态"
|
||||
static let changesSection = "改动"
|
||||
static let commitSection = "提交"
|
||||
static let logSection = "最近提交"
|
||||
static let prSection = "Pull Request"
|
||||
|
||||
static let upstreamCaption = "上游"
|
||||
static let toPushCaption = "待推送"
|
||||
static let toPullCaption = "待拉取"
|
||||
static let unknownCount = "—"
|
||||
static let inSync = "已同步"
|
||||
static let unverified = "未核实"
|
||||
static let noUpstream = "无上游"
|
||||
static let detachedHead = "分离 HEAD"
|
||||
static let dirtyUnknown = "未检查改动"
|
||||
static let clean = "工作区干净"
|
||||
static let neverFetched = "从未 fetch —— 待拉取数不可信"
|
||||
static let staleFetch = "上次 fetch 已久 —— 待拉取数不可信"
|
||||
static let noUpstreamDetail = "此分支不跟踪任何上游,没有可报告的待推送/待拉取。"
|
||||
static let detachedDetail = "HEAD 处于分离状态:没有分支,也无法 fetch。"
|
||||
|
||||
static let fetchButton = "Fetch"
|
||||
static let fetchHint = "只刷新远端引用,不 pull、不合并"
|
||||
static let commitButton = "提交"
|
||||
static let pushButton = "推送"
|
||||
static let commitPlaceholder = "提交信息"
|
||||
static let stageButton = "暂存"
|
||||
static let unstageButton = "取消暂存"
|
||||
static let noChanges = "工作区没有待提交的改动。"
|
||||
static let noStaged = "暂存区为空。"
|
||||
static let noCommits = "暂无提交记录。"
|
||||
static let truncatedLog = "仅显示最近若干条提交。"
|
||||
|
||||
static let commitMessageRequired = "请先填写提交信息。"
|
||||
static let stateFailed = "读取 git 状态失败"
|
||||
static let logFailed = "读取提交记录失败"
|
||||
static let changesFailed = "读取改动列表失败"
|
||||
static let stageFailed = "暂存失败"
|
||||
static let unstageFailed = "取消暂存失败"
|
||||
static let commitFailed = "提交失败"
|
||||
static let pushFailed = "推送失败"
|
||||
static let fetchFailed = "Fetch 失败"
|
||||
static let fetched = "已刷新远端引用。"
|
||||
|
||||
static func staged(_ count: Int) -> String { "已暂存 \(count) 个文件。" }
|
||||
static func unstaged(_ count: Int) -> String { "已取消暂存 \(count) 个文件。" }
|
||||
|
||||
static func committed(_ commit: String) -> String {
|
||||
commit.isEmpty ? "已提交。" : "已提交 \(commit)。"
|
||||
}
|
||||
|
||||
static func pushed(branch: String?, remote: String?) -> String {
|
||||
guard let branch, let remote else { return "已推送。" }
|
||||
return "已推送 \(branch) → \(remote)。"
|
||||
}
|
||||
|
||||
static func unpushedBoundary(_ upstream: String) -> String { "以上未推送到 \(upstream)" }
|
||||
static func dirtyCount(_ count: Int) -> String { "\(count) 处未提交改动" }
|
||||
static func lastFetched(_ label: String) -> String { "上次 fetch:\(label)" }
|
||||
}
|
||||
@@ -42,9 +42,59 @@ import WireProtocol
|
||||
@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>
|
||||
/// Everything the pairing flow needs from the network/platform, injected as
|
||||
/// ONE value built by the composition root (`AppEnvironment.probe`).
|
||||
///
|
||||
/// Why a struct and not three parameters: `AppEnvironment.probe` is handed to
|
||||
/// this VM by `AppCoordinator`, so growing the capability set inside the
|
||||
/// value keeps the composition root the single place they are built — adding
|
||||
/// separately-defaulted parameters instead would silently fall back to
|
||||
/// defaults in production, i.e. a dead hook.
|
||||
///
|
||||
/// Tests fake results AND assert non-invocation before the user confirms
|
||||
/// (task RED list); `validateToken`/`unregisterPush` default to the
|
||||
/// zero-config behaviour so existing call sites stay one-liners.
|
||||
struct Probe: Sendable {
|
||||
/// Two-step host verification (`runPairingProbe`), carrying an optional
|
||||
/// candidate access token for a host that gates its surface (§1.1).
|
||||
let verifyHost: @Sendable (HostEndpoint, AccessToken?) async
|
||||
-> Result<HostEndpoint, PairingError>
|
||||
/// One-shot `POST /auth` validation of a candidate token — §1.1's four
|
||||
/// outcomes, or a transport-level failure.
|
||||
let validateToken: @Sendable (HostEndpoint, AccessToken) async
|
||||
-> Result<AccessTokenProbeResult, TokenProbeFailure>
|
||||
/// Side effect of removing a host: de-register THIS device's APNs token
|
||||
/// on that host (`PushRegistrar.handleHostRemoved`). Failure is the
|
||||
/// registrar's to log — removal must never be blocked by it.
|
||||
let unregisterPush: @Sendable (HostRegistry.Host) async -> Void
|
||||
|
||||
init(
|
||||
verifyHost: @escaping @Sendable (HostEndpoint, AccessToken?) async
|
||||
-> Result<HostEndpoint, PairingError>,
|
||||
/// Unscripted default = "this host has auth disabled", which is the
|
||||
/// zero-config LAN reality and never fabricates an authenticated state.
|
||||
validateToken: @escaping @Sendable (HostEndpoint, AccessToken) async
|
||||
-> Result<AccessTokenProbeResult, TokenProbeFailure> = { _, _ in
|
||||
.success(.authDisabled)
|
||||
},
|
||||
unregisterPush: @escaping @Sendable (HostRegistry.Host) async -> Void = { _ in }
|
||||
) {
|
||||
self.verifyHost = verifyHost
|
||||
self.validateToken = validateToken
|
||||
self.unregisterPush = unregisterPush
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a `POST /auth` probe never produced one of the four §1.1 outcomes.
|
||||
/// Deliberately payload-free about the token itself (§5.3).
|
||||
enum TokenProbeFailure: Error, Equatable, Sendable {
|
||||
/// Transport-level failure / unexpected status; carries the network
|
||||
/// description only (never the token).
|
||||
case unreachable(String)
|
||||
/// The candidate violated the frozen shape before any I/O — defensive:
|
||||
/// the VM validates first, so this should be unreachable in practice.
|
||||
case malformed
|
||||
}
|
||||
|
||||
// MARK: - UI state model
|
||||
|
||||
@@ -81,6 +131,11 @@ final class PairingViewModel {
|
||||
/// iOS Local Network permission was denied → deep-link to the app's
|
||||
/// Settings pane (its 本地网络 toggle lives there).
|
||||
case openLocalNetworkSettings
|
||||
/// C1 · The host answered **401**, which is ambiguous by design (Origin
|
||||
/// or access token — same status). Offer the one remedy that can be
|
||||
/// applied from the phone: type the access token. Retry stays available
|
||||
/// for the Origin case.
|
||||
case enterAccessToken
|
||||
}
|
||||
|
||||
struct FailureDisplay: Equatable, Sendable {
|
||||
@@ -93,6 +148,10 @@ final class PairingViewModel {
|
||||
case confirming(PendingHost)
|
||||
case probing(PendingHost)
|
||||
case failed(PendingHost, FailureDisplay)
|
||||
/// C1 · Access-token prompt for a host that answered 401.
|
||||
case awaitingToken(PendingHost)
|
||||
/// C1 · `POST /auth` in flight for the typed candidate.
|
||||
case validatingToken(PendingHost)
|
||||
case paired(HostRegistry.Host)
|
||||
}
|
||||
|
||||
@@ -112,11 +171,24 @@ final class PairingViewModel {
|
||||
/// 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?
|
||||
/// Inline copy under the token field (wrong token / rate-limited / this host
|
||||
/// has no auth at all / shape violation). NEVER echoes the token itself.
|
||||
private(set) var tokenRejection: String?
|
||||
/// C1 · Already-paired hosts, for the manage section (token update + remove).
|
||||
/// Loaded explicitly by the screen — pairing itself never needs it.
|
||||
private(set) var pairedHosts: [HostRegistry.Host] = []
|
||||
/// Explicit store-failure copy for the manage section (never a silent
|
||||
/// empty list hiding a broken keychain).
|
||||
private(set) var hostsErrorMessage: String?
|
||||
|
||||
// MARK: - Dependencies (not observed)
|
||||
|
||||
@ObservationIgnored private let store: any HostStore
|
||||
@ObservationIgnored private let probe: Probe
|
||||
/// The token validated by `POST /auth` for the host being paired, held in
|
||||
/// memory ONLY until the host record is written (Keychain via `HostStore`).
|
||||
/// nil = no token (LAN default, or the host reported auth disabled).
|
||||
@ObservationIgnored private var candidateToken: AccessToken?
|
||||
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
|
||||
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
|
||||
/// production reads the keychain. Defaulted so existing call sites compile.
|
||||
@@ -124,7 +196,7 @@ final class PairingViewModel {
|
||||
|
||||
init(
|
||||
store: any HostStore,
|
||||
probe: @escaping Probe,
|
||||
probe: Probe, // C1 · a value now (three capabilities), no longer a closure
|
||||
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
|
||||
KeychainClientIdentityStore().hasInstalledIdentity()
|
||||
}
|
||||
@@ -170,11 +242,15 @@ final class PairingViewModel {
|
||||
}
|
||||
|
||||
/// Back out of confirm/failed to a clean entry state. Never probes.
|
||||
/// Drops the in-memory token candidate too — an abandoned pairing must not
|
||||
/// leave a secret behind for the next target.
|
||||
func cancel() {
|
||||
phase = .idle
|
||||
inputRejection = nil
|
||||
hasAcknowledgedPublicRisk = false
|
||||
needsPublicRiskAcknowledgement = false
|
||||
tokenRejection = nil
|
||||
candidateToken = nil
|
||||
}
|
||||
|
||||
// MARK: - Confirm → probe → store
|
||||
@@ -200,7 +276,9 @@ final class PairingViewModel {
|
||||
switch phase {
|
||||
case .idle, .confirming, .failed:
|
||||
return true
|
||||
case .probing, .paired:
|
||||
case .probing, .awaitingToken, .validatingToken, .paired:
|
||||
// Mid-credential-entry counts as busy: a scan landing while the
|
||||
// token prompt is up must not silently retarget the token.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -209,6 +287,8 @@ final class PairingViewModel {
|
||||
inputRejection = nil
|
||||
hasAcknowledgedPublicRisk = false
|
||||
needsPublicRiskAcknowledgement = false
|
||||
tokenRejection = nil
|
||||
candidateToken = nil // a new target never inherits the previous token
|
||||
hostName = endpoint.baseURL.host ?? ""
|
||||
phase = .confirming(PendingHost(
|
||||
endpoint: endpoint, warning: Self.warning(for: endpoint)
|
||||
@@ -228,7 +308,10 @@ final class PairingViewModel {
|
||||
return
|
||||
}
|
||||
phase = .probing(pending)
|
||||
switch await probe(pending.endpoint) {
|
||||
// The candidate token (if any) travels with BOTH probe legs — the HTTP
|
||||
// one as a `Cookie` header via APIClient, the WS one via the
|
||||
// probe-scoped transport the composition root builds (§1.1).
|
||||
switch await probe.verifyHost(pending.endpoint, candidateToken) {
|
||||
case .failure(let error):
|
||||
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
|
||||
case .success(let endpoint):
|
||||
@@ -239,13 +322,23 @@ final class PairingViewModel {
|
||||
/// §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.
|
||||
///
|
||||
/// C1 · Re-pairing the SAME origin updates that host in place (its `id` is
|
||||
/// reused) instead of appending a duplicate. That is what makes "this host
|
||||
/// just got a WEBTERM_TOKEN" recoverable: pair it again, type the token, and
|
||||
/// the existing record — the one every `lastSessionId`/watermark is keyed by
|
||||
/// — gains the token.
|
||||
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
|
||||
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
|
||||
let existing = await existingHost(matching: endpoint)
|
||||
let host = HostRegistry.Host(
|
||||
id: UUID(),
|
||||
name: trimmedName.isEmpty ? fallbackName : trimmedName,
|
||||
endpoint: endpoint
|
||||
id: existing?.id ?? UUID(),
|
||||
name: resolvedName(for: endpoint, existing: existing),
|
||||
endpoint: endpoint,
|
||||
// A validated candidate wins; otherwise keep whatever the record
|
||||
// already had (re-pairing to fix a name must not wipe a working
|
||||
// credential). `.authDisabled` clears the candidate first, so a
|
||||
// host that reports no auth can never persist a token here.
|
||||
accessToken: candidateToken ?? existing?.accessToken
|
||||
)
|
||||
do {
|
||||
_ = try await store.upsert(host)
|
||||
@@ -255,10 +348,128 @@ final class PairingViewModel {
|
||||
))
|
||||
return
|
||||
}
|
||||
candidateToken = nil // persisted — drop the in-memory copy
|
||||
pairedHost = host
|
||||
phase = .paired(host)
|
||||
}
|
||||
|
||||
/// The already-paired host at the same origin, or nil. A store read failure
|
||||
/// degrades to nil (pair as new) rather than blocking the pairing.
|
||||
private func existingHost(matching endpoint: HostEndpoint) async -> HostRegistry.Host? {
|
||||
let hosts = try? await store.loadAll()
|
||||
return hosts?.first { $0.endpoint.originHeader == endpoint.originHeader }
|
||||
}
|
||||
|
||||
/// User-typed name wins; an untouched field keeps the existing host's name
|
||||
/// (re-pairing must not rename "MacBook" to "192.168.1.5").
|
||||
private func resolvedName(
|
||||
for endpoint: HostEndpoint, existing: HostRegistry.Host?
|
||||
) -> String {
|
||||
let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let derived = endpoint.baseURL.host ?? endpoint.originHeader
|
||||
if trimmed.isEmpty || trimmed == derived {
|
||||
return existing?.name ?? derived
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// MARK: - Access token (§1.1: POST /auth is a one-shot pairing probe)
|
||||
|
||||
/// Failure page → token prompt. Only from a 401-shaped failure, which is the
|
||||
/// only failure whose remedy might be a token.
|
||||
func beginAccessTokenEntry() {
|
||||
guard case .failed(let pending, let failure) = phase,
|
||||
failure.action == .enterAccessToken else { return }
|
||||
tokenRejection = nil
|
||||
phase = .awaitingToken(pending)
|
||||
}
|
||||
|
||||
/// Token prompt → back to the confirm page (the URL is still fine; the user
|
||||
/// may prefer to fix `ALLOWED_ORIGINS` instead).
|
||||
func cancelAccessTokenEntry() {
|
||||
guard case .awaitingToken(let pending) = phase else { return }
|
||||
tokenRejection = nil
|
||||
candidateToken = nil
|
||||
phase = .confirming(pending)
|
||||
}
|
||||
|
||||
/// Validate a typed token against the host, then re-run the host probe with
|
||||
/// it. Shape is checked BEFORE any I/O (§1.1 charset/length is the server's
|
||||
/// own rule, so a shape violation can never be the right token).
|
||||
///
|
||||
/// The four §1.1 outcomes are all handled explicitly, and `authDisabled`
|
||||
/// deliberately does NOT persist anything: a 204 without `Set-Cookie` means
|
||||
/// the host never enabled auth, so claiming "authenticated" would be a lie
|
||||
/// and storing the token would gate nothing.
|
||||
func submitAccessToken(_ raw: String) async {
|
||||
guard case .awaitingToken(let pending) = phase else { return }
|
||||
let token: AccessToken
|
||||
do {
|
||||
token = try AccessToken(validating: raw)
|
||||
} catch let error as AccessTokenError {
|
||||
tokenRejection = PairingCopy.tokenShapeRejected(error)
|
||||
return
|
||||
} catch {
|
||||
tokenRejection = PairingCopy.tokenShapeUnknown
|
||||
return
|
||||
}
|
||||
tokenRejection = nil
|
||||
phase = .validatingToken(pending)
|
||||
switch await probe.validateToken(pending.endpoint, token) {
|
||||
case .success(.valid):
|
||||
candidateToken = token
|
||||
await runProbe(for: pending) // re-verify, now authenticated
|
||||
case .success(.authDisabled):
|
||||
candidateToken = nil
|
||||
tokenRejection = PairingCopy.tokenNotRequired
|
||||
phase = .awaitingToken(pending)
|
||||
case .success(.invalidToken):
|
||||
tokenRejection = PairingCopy.tokenInvalid
|
||||
phase = .awaitingToken(pending)
|
||||
case .success(.rateLimited):
|
||||
tokenRejection = PairingCopy.tokenRateLimited
|
||||
phase = .awaitingToken(pending)
|
||||
case .failure(.unreachable(let underlying)):
|
||||
tokenRejection = PairingCopy.hostUnreachable(underlying)
|
||||
phase = .awaitingToken(pending)
|
||||
case .failure(.malformed):
|
||||
tokenRejection = PairingCopy.tokenShapeUnknown
|
||||
phase = .awaitingToken(pending)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paired-host management (C1 · the removal path `handleHostRemoved` lacked)
|
||||
|
||||
/// Load the manage section's host list. Explicit error copy on failure.
|
||||
func loadPairedHosts() async {
|
||||
do {
|
||||
pairedHosts = try await store.loadAll()
|
||||
hostsErrorMessage = nil
|
||||
} catch {
|
||||
hostsErrorMessage = PairingCopy.hostsLoadFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a paired host: de-register this device's APNs token on it, then
|
||||
/// delete the record (which takes its access token with it — the token is a
|
||||
/// field of the record, so no orphaned secret can survive).
|
||||
///
|
||||
/// ORDER MATTERS: the de-registration POST goes out FIRST, while the record
|
||||
/// (and therefore the host's access token, which the request needs to get
|
||||
/// past a token gate) still exists. A failing de-registration never blocks
|
||||
/// the removal — the user asked for the host to be gone, and the server also
|
||||
/// prunes tokens itself on an APNs 410.
|
||||
func removeHost(id: UUID) async {
|
||||
guard let host = pairedHosts.first(where: { $0.id == id }) else { return }
|
||||
await probe.unregisterPush(host)
|
||||
do {
|
||||
pairedHosts = try await store.remove(id: id)
|
||||
hostsErrorMessage = nil
|
||||
} catch {
|
||||
hostsErrorMessage = PairingCopy.hostRemoveFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PairingError → copy + action (task RED list, one case each)
|
||||
|
||||
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
|
||||
@@ -289,7 +500,14 @@ final class PairingViewModel {
|
||||
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)
|
||||
//
|
||||
// C1 · The action is `.enterAccessToken`, not `.retry`: this case now
|
||||
// also carries the AMBIGUOUS 401 (Origin *or* token — the server
|
||||
// answers the same status for both, see `unauthorizedPairingHint`),
|
||||
// and typing a token is the only remedy reachable from the phone.
|
||||
// The failure view keeps a retry button alongside it, so the pure
|
||||
// Origin case (the 403 on the guarded kill) loses nothing.
|
||||
return FailureDisplay(message: hint, action: .enterAccessToken)
|
||||
case .atsBlocked(let host):
|
||||
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
|
||||
case .tlsFailure:
|
||||
@@ -418,40 +636,3 @@ final class PairingViewModel {
|
||||
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 =
|
||||
"连接超时。请确认主机在线、与手机在同一网络后重试。"
|
||||
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
|
||||
static let deviceCertRequired =
|
||||
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
|
||||
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
|
||||
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
|
||||
static let clientCertRejected =
|
||||
"本设备证书无效或已吊销,请重新导入。"
|
||||
|
||||
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,或反馈该网段。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,28 @@ final class ProjectsViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
/// T-iOS-32(C2)· 恢复一条历史会话:**同一条** `attach(null, cwd)` 缝,只把
|
||||
/// bootstrap 换成 `claude --resume <id>\r`(镜像 web
|
||||
/// `public/tabs.ts:918 newTabForResume`)。
|
||||
///
|
||||
/// 两道校验都在铸造 request 之前:cwd 必须是绝对路径(与上面同款纪律),
|
||||
/// 会话 id 必须过 `ProjectResumeCommand` 白名单 —— 那串字节会被拼进一条真的送进
|
||||
/// PTY 的命令行,web 侧没有这层校验,iOS 不复制这个缺口。
|
||||
func requestResumeClaude(cwd: String, sessionId: String) {
|
||||
guard Validation.isAbsoluteCwd(cwd) else {
|
||||
openErrorMessage = ProjectsCopy.openClaudeInvalidPath
|
||||
return
|
||||
}
|
||||
guard let bootstrap = ProjectResumeCommand.bootstrapInput(sessionId: sessionId) else {
|
||||
openErrorMessage = ResumeCopy.notResumable
|
||||
return
|
||||
}
|
||||
openErrorMessage = nil
|
||||
openRequest = ProjectOpenRequest(
|
||||
id: UUID(), host: host, cwd: cwd, bootstrapInput: bootstrap
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Detail assembly(详情/差异屏的装配缝)
|
||||
|
||||
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {
|
||||
|
||||
146
ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift
Normal file
146
ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-32 · `claude --resume <id>` 历史(`GET /sessions`)。
|
||||
///
|
||||
/// 服务端把主机 `~/.claude/projects` 下最近修改的会话文件列给我们(
|
||||
/// `src/http/history.ts`),这正是 `claude --resume` 选择器的数据。恢复动作走的是
|
||||
/// 已有的"在此仓库开新会话"缝:`attach(null, cwd)` + attach 后注入
|
||||
/// `claude --resume <id>\r`(镜像 web `public/tabs.ts:918 newTabForResume`)。
|
||||
///
|
||||
/// **安全(本文件存在的主要理由)**:`id`/`cwd`/`preview` 都是不可信服务器字节,
|
||||
/// 而 `id` 会被拼进一条**真的送进 PTY 的命令行**。因此 `ProjectResumeCommand` 用
|
||||
/// 白名单校验 id,任何越界字符(`;` `` ` `` `$(` 空格、换行…)一律拒绝、该行不可恢复。
|
||||
/// web 侧没有这一层(`claude --resume ${sessionId}\r` 直接拼),iOS 不复制这个缺口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ResumeHistoryViewModel {
|
||||
|
||||
/// 一条可展示的历史会话。`bootstrapInput == nil` ⇒ id 未通过白名单 ⇒ 行照常
|
||||
/// 显示(用户能看到主机上有这条历史),但不提供恢复按钮。
|
||||
struct ResumeCandidate: Equatable, Identifiable, Sendable {
|
||||
let session: HistorySession
|
||||
/// 会话自己的 cwd(worktree 会话会回到那个 worktree,而不是仓库根)。
|
||||
let cwd: String
|
||||
let bootstrapInput: String?
|
||||
|
||||
var id: String { session.id }
|
||||
var canResume: Bool { bootstrapInput != nil }
|
||||
}
|
||||
|
||||
enum Phase: Equatable {
|
||||
case loading
|
||||
case loaded([ResumeCandidate])
|
||||
/// 服务器无历史(或全都不属于本仓库)——正常态,不是失败。
|
||||
case empty
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
let projectPath: String
|
||||
private(set) var phase: Phase = .loading
|
||||
|
||||
@ObservationIgnored
|
||||
private let fetch: @Sendable () async throws -> [HistorySession]
|
||||
|
||||
init(projectPath: String, fetch: @escaping @Sendable () async throws -> [HistorySession]) {
|
||||
self.projectPath = projectPath
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
/// 生产装配缝。
|
||||
static func forProject(
|
||||
endpoint: HostEndpoint, http: any HTTPTransport, path: String
|
||||
) -> ResumeHistoryViewModel {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return ResumeHistoryViewModel(projectPath: path, fetch: {
|
||||
try await client.claudeSessions()
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch 并归约。也是「重试」路径。
|
||||
func load() async {
|
||||
phase = .loading
|
||||
do {
|
||||
let candidates = Self.candidates(from: try await fetch(), projectPath: projectPath)
|
||||
phase = candidates.isEmpty ? .empty : .loaded(candidates)
|
||||
} catch {
|
||||
phase = .failed(GitWriteFeedback.message(for: error, fallback: ResumeCopy.loadFailed))
|
||||
}
|
||||
}
|
||||
|
||||
/// 过滤 + 映射(纯函数)。服务器已按 mtime 新→旧排好,**客户端不重排**。
|
||||
///
|
||||
/// 只保留 cwd 落在本项目内的会话:在另一个仓库跑过的会话,用本仓库的 cwd 去
|
||||
/// resume 是错的。前缀比较带 `/` 边界,否则 `/repos/web-terminal-old` 会被
|
||||
/// 当成 `/repos/web-terminal` 的子目录。
|
||||
static func candidates(
|
||||
from sessions: [HistorySession], projectPath: String
|
||||
) -> [ResumeCandidate] {
|
||||
let root = normalized(projectPath)
|
||||
guard Validation.isAbsoluteCwd(root) else { return [] }
|
||||
return sessions.compactMap { session in
|
||||
let cwd = normalized(session.cwd)
|
||||
guard Validation.isAbsoluteCwd(cwd), isInside(cwd: cwd, root: root) else { return nil }
|
||||
return ResumeCandidate(
|
||||
session: session,
|
||||
cwd: cwd,
|
||||
bootstrapInput: ProjectResumeCommand.bootstrapInput(sessionId: session.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 去掉尾部 `/`(根 `/` 保留),让前缀比较有确定的边界。
|
||||
private static func normalized(_ path: String) -> String {
|
||||
guard path.count > 1, path.hasSuffix("/") else { return path }
|
||||
return String(path.dropLast())
|
||||
}
|
||||
|
||||
private static func isInside(cwd: String, root: String) -> Bool {
|
||||
cwd == root || cwd.hasPrefix(root.hasSuffix("/") ? root : root + "/")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 恢复命令合成(安全边界)
|
||||
|
||||
/// 把一个服务器给的会话 id 变成一条可以送进 PTY 的命令 —— 或者拒绝它。
|
||||
///
|
||||
/// 白名单而非黑名单:id 在服务端就是 `.jsonl` 的文件名主干(实际是 UUID),因此
|
||||
/// `[A-Za-z0-9._-]` 足够,其余字符(空格、引号、`;`、`|`、`&`、`` ` ``、`$`、
|
||||
/// 换行、路径分隔符…)全部意味着"这不是一个会话 id"。被拒的 id 绝不进命令行。
|
||||
enum ProjectResumeCommand {
|
||||
/// 文件名主干的合理上限(UUID 是 36)。
|
||||
static let maxIdLength = 128
|
||||
|
||||
private static let allowed = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
|
||||
|
||||
/// nil = id 不可信 ⇒ 该行不可恢复。成功时以 `\r`(0x0D)结尾 —— Enter 是 `\r`
|
||||
/// 不是 `\n`(CLAUDE.md Gotchas)。
|
||||
static func bootstrapInput(sessionId: String) -> String? {
|
||||
guard isWellFormed(sessionId) else { return nil }
|
||||
return "claude --resume \(sessionId)\r"
|
||||
}
|
||||
|
||||
static func isWellFormed(_ sessionId: String) -> Bool {
|
||||
!sessionId.isEmpty
|
||||
&& sessionId.count <= maxIdLength
|
||||
&& sessionId.allSatisfy(allowed.contains)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量,plan §4)
|
||||
|
||||
enum ResumeCopy {
|
||||
static let entryLabel = "恢复历史会话"
|
||||
static let sheetTitle = "历史会话"
|
||||
static let emptyTitle = "暂无历史会话"
|
||||
static let emptyDetail = "主机在此仓库下还没有 Claude Code 历史记录(`~/.claude/projects`)。"
|
||||
static let loadFailed = "读取历史会话失败"
|
||||
static let retry = "重试"
|
||||
static let resume = "恢复"
|
||||
static let notResumable = "会话标识不合法,无法恢复。"
|
||||
static let noPreview = "(无首条提示)"
|
||||
|
||||
static func resumeAccessibilityLabel(_ project: String) -> String { "恢复 \(project) 的会话" }
|
||||
}
|
||||
@@ -57,6 +57,19 @@ final class TerminalViewModel {
|
||||
static let replayTooLargeMessage =
|
||||
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
|
||||
|
||||
/// Actionable copy for `.failed(.unauthorized)` (C1 over B3, ios-completion
|
||||
/// §1.1). The server answers **401 on the WS upgrade for two different
|
||||
/// reasons** — the Origin/CSWSH check runs first, the `webterm_auth` cookie
|
||||
/// second (src/server.ts:1367-1379) — and the status is identical, so the
|
||||
/// client provably cannot tell them apart. Hence the copy names BOTH
|
||||
/// remedies instead of guessing one. Retrying is pointless (the engine
|
||||
/// already treats this as terminal), so the message asks for a credential
|
||||
/// change, not patience.
|
||||
static let unauthorizedMessage =
|
||||
"主机拒绝了连接(401):访问令牌缺失或不正确,也可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
|
||||
+ "请在会话列表的主机菜单里「管理主机与令牌」重新输入访问令牌;"
|
||||
+ "若令牌无误,就把主机的 ALLOWED_ORIGINS 设成 App 连接的完整地址后重启 web-terminal。"
|
||||
|
||||
/// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
|
||||
/// Read by the wiring layer for `notifyForegrounded(dims:)` — the frozen
|
||||
/// §3.2 signature needs real cols/rows and this is their single source.
|
||||
@@ -271,6 +284,11 @@ final class TerminalViewModel {
|
||||
case .failed(.replayTooLarge):
|
||||
banner = .none
|
||||
phase = .failed(message: Self.replayTooLargeMessage)
|
||||
case .failed(.unauthorized):
|
||||
// Same shape as replayTooLarge: a terminal state, so the spinner
|
||||
// must go and the terminal becomes read-only with actionable copy.
|
||||
banner = .none
|
||||
phase = .failed(message: Self.unauthorizedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
327
ios/App/WebTerm/ViewModels/WorktreeViewModel.swift
Normal file
327
ios/App/WebTerm/ViewModels/WorktreeViewModel.swift
Normal file
@@ -0,0 +1,327 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-32 · worktree 生命周期状态(create / prune / remove)。
|
||||
///
|
||||
/// 真源 `src/http/worktrees.ts` + `docs/plans/w4-worktree-lifecycle.md`;web 参照
|
||||
/// 实现 `public/projects.ts`(`validateBranchNameClient`、`confirmAndRemoveWorktree`、
|
||||
/// `confirmAndPruneWorktrees`)。
|
||||
///
|
||||
/// 三条纪律:
|
||||
/// 1. **校验先于网络**:非法分支名连请求都不发(服务器也会拒,但一次往返换不来
|
||||
/// 任何信息)。
|
||||
/// 2. **破坏性操作必须显式确认**,并且 409(脏工作树)**绝不自动升级为 force** ——
|
||||
/// 第二次确认由 UI 再问一次(无单击数据丢失)。
|
||||
/// 3. **服务器安全文案原样上浮**(SEC-M10:服务端已把 git stderr 分类成安全短句)。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WorktreeViewModel {
|
||||
|
||||
struct Dependencies: Sendable {
|
||||
let create: @Sendable (_ branch: String, _ base: String?) async throws
|
||||
-> GitWriteOutcome<CreateWorktreeResult>
|
||||
let prune: @Sendable () async throws -> GitWriteOutcome<PruneWorktreesResult>
|
||||
let remove: @Sendable (_ worktreePath: String, _ force: Bool) async throws
|
||||
-> GitWriteOutcome<RemoveWorktreeResult>
|
||||
}
|
||||
|
||||
enum CreatePhase: Equatable {
|
||||
case idle
|
||||
case creating
|
||||
/// 服务器返回的 canonical path/branch —— 以它为真相,不回显本地输入。
|
||||
case created(path: String, branch: String)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
enum PrunePhase: Equatable {
|
||||
case idle
|
||||
case pruning
|
||||
case done(String)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
enum RemovePhase: Equatable {
|
||||
case idle
|
||||
case removing
|
||||
/// 409:脏工作树需要 force。等用户第二次确认(`confirmForceRemove`),
|
||||
/// 或取消(`cancelForceRemove`)—— 客户端绝不自己升级。
|
||||
case forceConfirming(path: String, message: String)
|
||||
case removed(path: String)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
/// 新分支名(`TextField` 绑定)。
|
||||
var branchText = ""
|
||||
/// 起点 ref;留空 = 从 HEAD 切(服务器语义),**绝不**送空串。
|
||||
var baseText = ""
|
||||
|
||||
private(set) var createPhase: CreatePhase = .idle
|
||||
private(set) var prunePhase: PrunePhase = .idle
|
||||
private(set) var removePhase: RemovePhase = .idle
|
||||
|
||||
@ObservationIgnored
|
||||
private let dependencies: Dependencies
|
||||
|
||||
init(dependencies: Dependencies) {
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
/// 生产装配缝。
|
||||
static func forProject(
|
||||
endpoint: HostEndpoint, http: any HTTPTransport, path: String
|
||||
) -> WorktreeViewModel {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return WorktreeViewModel(dependencies: Dependencies(
|
||||
create: { branch, base in
|
||||
try await client.createWorktree(path: path, branch: branch, base: base)
|
||||
},
|
||||
prune: { try await client.pruneWorktrees(path: path) },
|
||||
remove: { worktreePath, force in
|
||||
try await client.removeWorktree(
|
||||
path: path, worktreePath: worktreePath, force: force
|
||||
)
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
/// 输入时的即时校验(空串不提示 —— 还没开始填)。
|
||||
var branchValidationMessage: String? {
|
||||
let branch = trimmedBranch
|
||||
guard !branch.isEmpty else { return nil }
|
||||
return WorktreeBranchRule.validate(branch)
|
||||
}
|
||||
|
||||
var canSubmitCreate: Bool {
|
||||
createPhase != .creating && WorktreeBranchRule.validate(trimmedBranch) == nil
|
||||
}
|
||||
|
||||
var isBusy: Bool {
|
||||
createPhase == .creating || prunePhase == .pruning || removePhase == .removing
|
||||
}
|
||||
|
||||
/// 只有"链接的、未锁定的"worktree 可以删:主 worktree 是仓库本体(服务器 400),
|
||||
/// 锁定的要先在终端里 unlock(服务器 409)—— UI 不邀请一个必然被拒的动作。
|
||||
static func canRemove(_ worktree: WorktreeInfo) -> Bool {
|
||||
!worktree.isMain && worktree.locked != true
|
||||
}
|
||||
|
||||
private var trimmedBranch: String {
|
||||
branchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var trimmedBase: String? {
|
||||
let base = baseText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return base.isEmpty ? nil : base
|
||||
}
|
||||
|
||||
// MARK: - 创建
|
||||
|
||||
func create() async {
|
||||
guard createPhase != .creating else { return } // 重复提交只发一次
|
||||
let branch = trimmedBranch
|
||||
if let invalid = WorktreeBranchRule.validate(branch) {
|
||||
createPhase = .failed(invalid) // 校验在边界:零网络 I/O
|
||||
return
|
||||
}
|
||||
createPhase = .creating
|
||||
do {
|
||||
switch try await dependencies.create(branch, trimmedBase) {
|
||||
case .ok(let result):
|
||||
createPhase = .created(
|
||||
path: result.path ?? "", branch: result.branch ?? branch
|
||||
)
|
||||
case .rejected(let status, let message):
|
||||
createPhase = .failed(GitWriteFeedback.message(
|
||||
status: status, serverMessage: message, fallback: WorktreeCopy.createFailed
|
||||
))
|
||||
case .rateLimited:
|
||||
createPhase = .failed(GitWriteFeedback.rateLimited)
|
||||
}
|
||||
} catch {
|
||||
createPhase = .failed(GitWriteFeedback.message(
|
||||
for: error, fallback: WorktreeCopy.createFailed
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建成功后重置表单(sheet 关闭时调用)。
|
||||
func resetCreate() {
|
||||
branchText = ""
|
||||
baseText = ""
|
||||
createPhase = .idle
|
||||
}
|
||||
|
||||
// MARK: - prune(调用方必须先拿到用户确认)
|
||||
|
||||
func prune() async {
|
||||
guard prunePhase != .pruning else { return }
|
||||
prunePhase = .pruning
|
||||
do {
|
||||
switch try await dependencies.prune() {
|
||||
case .ok(let result):
|
||||
// 空列表 = 没有可回收的(路由是幂等的),这不是错误。
|
||||
prunePhase = .done(
|
||||
result.pruned.isEmpty
|
||||
? WorktreeCopy.pruneNothing
|
||||
: WorktreeCopy.pruneDone(result.pruned.count)
|
||||
)
|
||||
case .rejected(let status, let message):
|
||||
prunePhase = .failed(GitWriteFeedback.message(
|
||||
status: status, serverMessage: message, fallback: WorktreeCopy.pruneFailed
|
||||
))
|
||||
case .rateLimited:
|
||||
prunePhase = .failed(GitWriteFeedback.rateLimited)
|
||||
}
|
||||
} catch {
|
||||
prunePhase = .failed(GitWriteFeedback.message(
|
||||
for: error, fallback: WorktreeCopy.pruneFailed
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func clearPruneResult() {
|
||||
prunePhase = .idle
|
||||
}
|
||||
|
||||
// MARK: - remove(两级确认)
|
||||
|
||||
/// 第一次确认之后调用:一律 `force: false`。
|
||||
func remove(worktreePath: String) async {
|
||||
await runRemove(worktreePath: worktreePath, force: false)
|
||||
}
|
||||
|
||||
/// `.forceConfirming` 下用户第二次确认之后调用。
|
||||
func confirmForceRemove() async {
|
||||
guard case .forceConfirming(let path, _) = removePhase else { return }
|
||||
await runRemove(worktreePath: path, force: true)
|
||||
}
|
||||
|
||||
func cancelForceRemove() {
|
||||
guard case .forceConfirming = removePhase else { return }
|
||||
removePhase = .idle
|
||||
}
|
||||
|
||||
func clearRemoveResult() {
|
||||
removePhase = .idle
|
||||
}
|
||||
|
||||
private func runRemove(worktreePath: String, force: Bool) async {
|
||||
guard removePhase != .removing else { return }
|
||||
removePhase = .removing
|
||||
do {
|
||||
switch try await dependencies.remove(worktreePath, force) {
|
||||
case .ok(let result):
|
||||
removePhase = .removed(path: result.path ?? worktreePath)
|
||||
case .rejected(let status, let message):
|
||||
let text = GitWriteFeedback.message(
|
||||
status: status, serverMessage: message, fallback: WorktreeCopy.removeFailed
|
||||
)
|
||||
// 409 = 脏工作树,唯一可以升级为 force 的情形,且必须再问一次。
|
||||
// 已经 force 过还 409 ⇒ 直接失败,绝不循环。
|
||||
removePhase = (status == WorktreeStatus.conflict && !force)
|
||||
? .forceConfirming(path: worktreePath, message: text)
|
||||
: .failed(text)
|
||||
case .rateLimited:
|
||||
removePhase = .failed(GitWriteFeedback.rateLimited)
|
||||
}
|
||||
} catch {
|
||||
removePhase = .failed(GitWriteFeedback.message(
|
||||
for: error, fallback: WorktreeCopy.removeFailed
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 本 VM 需要区分的 HTTP 状态(APIClient 的同名枚举是 internal,不跨包)。
|
||||
private enum WorktreeStatus {
|
||||
static let conflict = 409
|
||||
}
|
||||
|
||||
// MARK: - 分支名校验(纯函数)
|
||||
|
||||
/// 逐条镜像 web `validateBranchNameClient`(`public/projects.ts:982`),也就是
|
||||
/// `git check-ref-format` 的常见子集。
|
||||
///
|
||||
/// 这不是"防注入"(服务器用 `execFile` argv,从不过 shell,且 `--` 终止选项),
|
||||
/// 而是**先于往返把必然失败的名字拦下来**,顺带解释原因。
|
||||
enum WorktreeBranchRule {
|
||||
/// 与 web 一致的长度上限。
|
||||
static let maxLength = 250
|
||||
|
||||
static func validate(_ branch: String) -> String? {
|
||||
if branch.isEmpty { return Message.empty }
|
||||
if branch.count > maxLength { return Message.tooLong }
|
||||
if branch.unicodeScalars.contains(where: isForbiddenControlScalar) {
|
||||
return Message.whitespaceOrControl
|
||||
}
|
||||
if branch.hasPrefix("-") { return Message.leadingHyphen }
|
||||
if branch.contains("..") { return Message.doubleDot }
|
||||
if branch.hasSuffix(".lock") { return Message.lockSuffix }
|
||||
if branch.contains(where: forbiddenCharacters.contains) { return Message.forbiddenCharacter }
|
||||
if branch.contains("@{") { return Message.atBrace }
|
||||
if branch.hasPrefix("/") || branch.hasSuffix("/") || branch.contains("//") {
|
||||
return Message.slashUsage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// `[\x00-\x20\x7f]` —— 空格与所有 C0 控制字符 + DEL。
|
||||
private static func isForbiddenControlScalar(_ scalar: Unicode.Scalar) -> Bool {
|
||||
scalar.value <= 0x20 || scalar.value == 0x7F
|
||||
}
|
||||
|
||||
private static let forbiddenCharacters: Set<Character> = ["~", "^", ":", "?", "*", "[", "\\"]
|
||||
|
||||
private enum Message {
|
||||
static let empty = "分支名不能为空。"
|
||||
static let tooLong = "分支名过长(最多 \(maxLength) 个字符)。"
|
||||
static let whitespaceOrControl = "分支名不能包含空格或控制字符。"
|
||||
static let leadingHyphen = "分支名不能以 - 开头。"
|
||||
static let doubleDot = "分支名不能包含 \"..\"。"
|
||||
static let lockSuffix = "分支名不能以 \".lock\" 结尾。"
|
||||
static let forbiddenCharacter = "分支名包含非法字符(~ ^ : ? * [ \\)。"
|
||||
static let atBrace = "分支名不能包含 \"@{\"。"
|
||||
static let slashUsage = "分支名的 / 用法不合法。"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量,plan §4)
|
||||
|
||||
enum WorktreeCopy {
|
||||
static let sheetTitle = "新建 Worktree"
|
||||
static let branchField = "新分支名"
|
||||
static let baseField = "起点(留空 = 当前 HEAD)"
|
||||
static let submit = "创建 Worktree"
|
||||
static let cancel = "取消"
|
||||
static let createdTitle = "Worktree 已创建"
|
||||
static let openInNewWorktree = "在新 Worktree 开会话"
|
||||
static let createFailed = "创建 worktree 失败"
|
||||
|
||||
static let pruneButton = "回收失效 Worktree"
|
||||
static let pruneConfirmTitle = "回收文件夹已消失的 worktree?"
|
||||
static let pruneConfirmMessage = "只清理 git 中已失效的登记项,不会删除任何仍存在的工作树。"
|
||||
static let pruneConfirm = "回收"
|
||||
static let pruneNothing = "没有可回收的 worktree。"
|
||||
static let pruneFailed = "回收失败"
|
||||
|
||||
static let removeButton = "删除"
|
||||
static let removeAccessibilityLabel = "删除 worktree"
|
||||
static let removeConfirmTitle = "删除这个 worktree?"
|
||||
static let removeConfirm = "删除"
|
||||
static let forceConfirmTitle = "有未提交的改动"
|
||||
static let forceConfirmMessage = "继续将丢失该 worktree 里未提交的改动。"
|
||||
static let forceConfirm = "强制删除"
|
||||
static let removeFailed = "删除 worktree 失败"
|
||||
static let lockedHint = "已锁定:请先在终端里 unlock。"
|
||||
|
||||
static func pruneDone(_ count: Int) -> String { "已回收 \(count) 个失效 worktree。" }
|
||||
static func removeConfirmMessage(_ path: String) -> String {
|
||||
"将删除工作树目录:\(path)"
|
||||
}
|
||||
static func removed(_ path: String) -> String { "已删除 \(path)。" }
|
||||
static func created(path: String, branch: String) -> String {
|
||||
"已在 \(path) 创建分支 \(branch)。"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import ClientTLS
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Production dependency graph (composition root). One immutable
|
||||
@@ -12,12 +13,15 @@ import WireProtocol
|
||||
/// Assembly security audit (task 安全注, verified at this single point):
|
||||
/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via
|
||||
/// SessionListViewModel's client, probe's kill round-trip inside
|
||||
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own.
|
||||
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own
|
||||
/// EXCEPT the access-token `Cookie` (C1) — see its type doc for why that one
|
||||
/// belongs at the transport and cannot bypass the Origin rule.
|
||||
/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport;
|
||||
/// HTTP: APIClient's route builder) — nothing here hand-assembles one.
|
||||
/// - Secrets: hosts in `KeychainHostStore`
|
||||
/// - Secrets: hosts (and their access tokens) in `KeychainHostStore`
|
||||
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
|
||||
/// UserDefaults carries only the non-secret per-host lastSessionId.
|
||||
/// UserDefaults carries only the non-secret per-host lastSessionId. A token
|
||||
/// never reaches a log line, a URL query, or an error description.
|
||||
/// - No debug ATS overrides exist (project.yml declares NO
|
||||
/// NSAllowsArbitraryLoads in any configuration — only the five §5.2 CIDR
|
||||
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
|
||||
@@ -27,15 +31,38 @@ struct AppEnvironment: Sendable {
|
||||
let hostStore: any HostStore
|
||||
let lastSessionStore: any LastSessionStore
|
||||
let http: any HTTPTransport
|
||||
/// The WS transport used when no per-host factory is injected: it carries
|
||||
/// NO access token, so it is correct only for a host that has none. Every
|
||||
/// terminal goes through `makeTermTransport(for:)`, which prefers
|
||||
/// `termTransportFactory` — production always installs one.
|
||||
let termTransport: any TermTransport
|
||||
/// Injected into `PairingViewModel` — production is `runPairingProbe`
|
||||
/// over the real transports (two-step: RO GET, then WS attach + guarded
|
||||
/// kill; only runs after the user's explicit confirm, T-iOS-12).
|
||||
/// kill; only runs after the user's explicit confirm, T-iOS-12), plus the
|
||||
/// `POST /auth` token probe and the host-removal side effect (C1).
|
||||
let probe: PairingViewModel.Probe
|
||||
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
|
||||
/// `var` + default so the memberwise init stays source-compatible for
|
||||
/// pre-P1 call sites while tests can inject an in-memory fake.
|
||||
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
||||
/// E1 · Builds the WS transport for ONE host, so the upgrade can carry that
|
||||
/// host's access token (§1.1). nil ⇒ `termTransport` for every host (the
|
||||
/// App-layer tests' `FakeTransport`); production installs the real factory.
|
||||
var termTransportFactory: (@Sendable (HostRegistry.Host) -> any TermTransport)?
|
||||
|
||||
/// The WS transport a terminal on `host` must dial.
|
||||
///
|
||||
/// E1 (HIGH) · This exists because the access-token cookie is PER HOST: the
|
||||
/// app previously shared one transport whose token was resolved
|
||||
/// host-independently, which returned nil for the mixed fleet the token
|
||||
/// feature is for (a tokened tunnel host beside an open LAN host) — the
|
||||
/// upgrade omitted the cookie, the server 401'd, and that failure is
|
||||
/// terminal with no in-app remedy. Android never had the bug because
|
||||
/// `OkHttpTermTransport` resolves `tokens.tokenFor(endpoint)`; this is the
|
||||
/// same rule.
|
||||
func makeTermTransport(for host: HostRegistry.Host) -> any TermTransport {
|
||||
termTransportFactory?(host) ?? termTransport
|
||||
}
|
||||
|
||||
static func production() -> AppEnvironment {
|
||||
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
|
||||
@@ -50,24 +77,181 @@ struct AppEnvironment: Sendable {
|
||||
let identityProvider: @Sendable () -> ClientIdentity? = {
|
||||
identityStore.loadedIdentityOrNil()
|
||||
}
|
||||
let http = URLSessionHTTPTransport(identityProvider: identityProvider)
|
||||
let termTransport = URLSessionTermTransport(identityProvider: identityProvider)
|
||||
let hostStore = KeychainHostStore()
|
||||
// C1 · ONE access-token source for the whole app, reading the same
|
||||
// Keychain records the pairing flow writes. Both transports consult it
|
||||
// per request/connect, so a token typed mid-run applies immediately —
|
||||
// no relaunch, and no token snapshot captured at composition.
|
||||
let tokens = AccessTokenSource(store: hostStore)
|
||||
let http = URLSessionHTTPTransport(
|
||||
identityProvider: identityProvider,
|
||||
tokenForOrigin: { origin in await tokens.token(forOrigin: origin) }
|
||||
)
|
||||
// E1 · ONE transport per host: the upgrade's Cookie is this host's token
|
||||
// and never another's. The provider is re-consulted on every connect
|
||||
// (rotation applies on the next reconnect, no relaunch).
|
||||
let termTransportFactory: @Sendable (HostRegistry.Host) -> any TermTransport = { host in
|
||||
URLSessionTermTransport(
|
||||
identityProvider: identityProvider,
|
||||
tokenProvider: { tokens.wsToken(for: host) }
|
||||
)
|
||||
}
|
||||
return AppEnvironment(
|
||||
hostStore: KeychainHostStore(),
|
||||
hostStore: hostStore,
|
||||
lastSessionStore: UserDefaultsLastSessionStore(),
|
||||
http: http,
|
||||
termTransport: termTransport,
|
||||
probe: { endpoint in
|
||||
await runPairingProbe(endpoint: endpoint, http: http, ws: termTransport)
|
||||
}
|
||||
termTransport: URLSessionTermTransport(identityProvider: identityProvider),
|
||||
probe: PairingViewModel.Probe(
|
||||
verifyHost: { endpoint, token in
|
||||
// The candidate token has to reach BOTH probe legs. HTTP
|
||||
// takes it as a parameter (APIClient stamps the Cookie); the
|
||||
// WS leg gets a PROBE-SCOPED transport carrying exactly this
|
||||
// candidate — the frozen `TermTransport.connect` has no
|
||||
// credential parameter, and the host is not paired yet, so
|
||||
// the shared transport could not resolve it from the store.
|
||||
let ws = URLSessionTermTransport(
|
||||
identityProvider: identityProvider,
|
||||
tokenProvider: { token?.rawValue }
|
||||
)
|
||||
return await runPairingProbe(
|
||||
endpoint: endpoint, http: http, ws: ws,
|
||||
accessToken: token?.rawValue
|
||||
)
|
||||
},
|
||||
validateToken: { endpoint, token in
|
||||
await probeAccessToken(endpoint: endpoint, http: http, token: token)
|
||||
},
|
||||
unregisterPush: { host in
|
||||
await PushHostDeregistration.run(for: host)
|
||||
}
|
||||
),
|
||||
termTransportFactory: termTransportFactory
|
||||
)
|
||||
}
|
||||
|
||||
/// Away-digest source for a session engine: wraps `APIClient.events` per
|
||||
/// host (the engine never holds an HTTP client — plan §3.2).
|
||||
/// host (the engine never holds an HTTP client — plan §3.2). The token rides
|
||||
/// along at the transport, so this stays token-free.
|
||||
func makeEventsSource(endpoint: HostEndpoint)
|
||||
-> @Sendable (UUID) async throws -> [TimelineEvent] {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return { id in try await client.events(id: id) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Access-token source (C1 · ios-completion §1.1)
|
||||
|
||||
/// The app's single read path for per-host access tokens.
|
||||
///
|
||||
/// Reads the `HostStore` (Keychain) on demand rather than caching a value at
|
||||
/// composition: pairing writes a token into the same records, and a snapshot
|
||||
/// taken at launch would make "type the token, then connect" require a relaunch.
|
||||
/// A Keychain read is a sub-millisecond `SecItemCopyMatching` + JSON decode, so
|
||||
/// per-request resolution is affordable at the app's polling cadence.
|
||||
///
|
||||
/// SECURITY (§5.3): the token is returned as a `String` for exactly one use — a
|
||||
/// `Cookie` header value. It is never logged, never interpolated into a URL, and
|
||||
/// the values it returns are `AccessToken`-validated (§1.1 charset), which is
|
||||
/// what makes header injection impossible.
|
||||
final class AccessTokenSource: @unchecked Sendable {
|
||||
private let store: any HostStore
|
||||
private let lock = NSLock()
|
||||
/// Origin → token, rebuilt from the store on every `token(forOrigin:)`.
|
||||
/// See `wsToken(for:)`. Guarded by `lock`; `nonisolated` state is the reason
|
||||
/// this type is `@unchecked Sendable` (an actor cannot serve the WS
|
||||
/// provider, which is synchronous).
|
||||
private var snapshot: [String: String] = [:]
|
||||
|
||||
init(store: any HostStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
/// This host's token, matched by ORIGIN (`HostEndpoint.originHeader` — the
|
||||
/// single derivation point, plan §5.1), or nil when the host has none / is
|
||||
/// unknown / the store read fails. A failed read degrades to "no token"
|
||||
/// rather than throwing: the request then gets the server's 401, which the
|
||||
/// UI already explains, instead of the app breaking outright.
|
||||
func token(forOrigin origin: String) async -> String? {
|
||||
let hosts = (try? await store.loadAll()) ?? []
|
||||
updateSnapshot(from: hosts)
|
||||
return hosts.first { $0.endpoint.originHeader == origin }?.accessToken?.rawValue
|
||||
}
|
||||
|
||||
/// The token a WS upgrade to `host` must present (§1.1) — for the one caller
|
||||
/// that can be given neither an `await` nor a parameter: SessionCore's
|
||||
/// frozen `tokenProvider: @Sendable () -> String?`, which the per-host
|
||||
/// transport closes over (`AppEnvironment.makeTermTransport(for:)`).
|
||||
///
|
||||
/// Two reads, in this order, and both are THIS host's own value — no answer
|
||||
/// derived from any other host can ever be returned (§5.3):
|
||||
/// 1. the latest value the store returned for this origin (so a token
|
||||
/// rotated mid-run applies on the next connect, no relaunch);
|
||||
/// 2. the `host` record itself, which the coordinator read from the same
|
||||
/// Keychain store when the session was opened — this is what makes the
|
||||
/// FIRST connect correct even before any HTTP request has warmed the
|
||||
/// snapshot (a cold-start terminal must not depend on that race), and
|
||||
/// what keeps a failed store read from silently dropping the cookie.
|
||||
///
|
||||
/// Consequence of (2), stated plainly: a token DELETED from the store keeps
|
||||
/// riding until this terminal is reopened. That is still this host's own
|
||||
/// value — the server just answers 401 if it is no longer valid — and it is
|
||||
/// the price of never dropping the cookie on a cold connect.
|
||||
func wsToken(for host: HostRegistry.Host) -> String? {
|
||||
let origin = host.endpoint.originHeader
|
||||
return lock.withLock { snapshot[origin] } ?? host.accessToken?.rawValue
|
||||
}
|
||||
|
||||
private func updateSnapshot(from hosts: [HostRegistry.Host]) {
|
||||
let resolved = hosts.reduce(into: [String: String]()) { map, host in
|
||||
guard let token = host.accessToken?.rawValue else { return }
|
||||
map[host.endpoint.originHeader] = token
|
||||
}
|
||||
lock.withLock { snapshot = resolved }
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /auth` (§1.1) as the pairing flow needs it: the four documented
|
||||
/// outcomes, or a typed transport failure. Lives here (composition root) because
|
||||
/// it is the seam between `APIClient`'s throwing API and the VM's total switch.
|
||||
private func probeAccessToken(
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
token: AccessToken
|
||||
) async -> Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
|
||||
do {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return .success(try await client.probeAccessToken(token.rawValue))
|
||||
} catch APIClientError.malformedToken {
|
||||
return .failure(.malformed)
|
||||
} catch let apiError as APIClientError {
|
||||
// `.message` is user-facing copy about the STATUS, never about the token.
|
||||
return .failure(.unreachable(apiError.message))
|
||||
} catch {
|
||||
return .failure(.unreachable((error as NSError).localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Host removal → APNs de-registration (C1 · fixes the dead hook)
|
||||
|
||||
/// Bridge from "the user removed a host" to `PushRegistrar.handleHostRemoved`.
|
||||
///
|
||||
/// The registrar owns the in-memory APNs device token (deliberately never
|
||||
/// persisted — iOS re-delivers it on every registration), so the de-registration
|
||||
/// can only be done by the LIVE instance. That instance is created and held by
|
||||
/// `PushAppDelegate` (`makePushWiring`), which the system builds after this
|
||||
/// composition root — hence the resolution happens at CALL time through
|
||||
/// `UIApplication.shared.delegate`, the platform's own object, rather than any
|
||||
/// singleton of ours.
|
||||
///
|
||||
/// nil delegate / nil registrar (unit tests, XCUITest, a build where push was
|
||||
/// never wired) is a deliberate no-op: removal must never depend on push.
|
||||
enum PushHostDeregistration {
|
||||
@MainActor
|
||||
static func run(for host: HostRegistry.Host) async {
|
||||
guard let delegate = UIApplication.shared.delegate as? PushAppDelegate,
|
||||
let registrar = delegate.registrar else {
|
||||
return
|
||||
}
|
||||
await registrar.handleHostRemoved(host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,28 @@ import SwiftUI
|
||||
/// `StackRootView` 是原 `RootView` 的 body **原样搬迁** —— compact 分支复用它
|
||||
/// 不改一行逻辑,故 iPhone 逐屏渲染与适配前一致(零回归硬性要求)。视觉层
|
||||
/// 只按冻结的 `DS.*` token 重塑(横幅/占位/遮罩),行为不动。
|
||||
/// T-iOS-34 · 主题(`ThemeStore`)就挂在这一层:它是 `@main` 唯一的实例化点,
|
||||
/// 所以「一个 store、一次注入、一处 `preferredColorScheme`」在这里成立,
|
||||
/// 不会出现两份主题真值。
|
||||
struct RootView: View {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
|
||||
/// 主题设置的单一真值(`UserDefaults` 持久化)。`@State` ⇒ 与根视图同寿命。
|
||||
@State private var themeStore = ThemeStore()
|
||||
|
||||
var body: some View {
|
||||
AdaptiveRootView(coordinator: coordinator)
|
||||
// DS:唯一的根 tint 注入点(Tokens.swift 头注约定)。子树若需别的
|
||||
// 语义色(gate 的 amber、终端的 orange)在各自局部 `.tint` 覆盖。
|
||||
.tint(DS.Palette.accent)
|
||||
// 深色优先 —— 对齐桌面 web 主题(DEFAULT_SETTINGS.theme = 'dark')。
|
||||
// 琥珀金强调色是为暖深色背景设计的(桌面 --bg #100F0D);浅色底上
|
||||
// 金字对比不足。深色下 accent/status 全部高对比,且与桌面观感一致。
|
||||
.preferredColorScheme(.dark)
|
||||
// 主题:默认仍是深色(对齐桌面 web `DEFAULT_SETTINGS.theme='dark'`,
|
||||
// 也等于此前硬锁 `.preferredColorScheme(.dark)` 的观感 ⇒ 升级零变化)。
|
||||
// 「跟随系统」时 `colorScheme` 为 nil = 不表态,交给 iOS。
|
||||
// 曾经硬锁深色的理由是「金色在浅底对比不足」;那是 token 问题而不是
|
||||
// 主题问题,已在 `Tokens.swift` 逐色补了浅色值(WCAG 3:1 起)。
|
||||
.preferredColorScheme(themeStore.theme.colorScheme)
|
||||
// 设置页与终端预览都从环境取同一个 store(不传参穿透整棵树)。
|
||||
.environment(themeStore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,10 +204,16 @@ struct CertRenewalWarningBanner: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Projects toolbar item (shared stack + split, DRY)
|
||||
// MARK: - Root leading toolbar (shared stack + split, DRY)
|
||||
|
||||
/// 「项目」leading 工具栏入口 —— stack 与 split 共用(同 disabled 条件、同
|
||||
/// `presentProjects` 动作、同 a11y id)。根 tint 令其 label 呈 accent。
|
||||
/// 会话列表的 leading 工具栏组 —— stack 与 split 共用(同 disabled 条件、同动作、
|
||||
/// 同 a11y id)。根 tint 令其 label 呈 accent。
|
||||
///
|
||||
/// 组里现在有两项:「项目」(原有)+「设置」(T-iOS-34 主题入口)。
|
||||
/// **命名保留** `ProjectsToolbarItem`:`SplitRootView.swift` 按此名引用它,而该
|
||||
/// 文件不在 C4 的 Owns 里;把设置项加进这个共用组,是让 iPhone(stack) 与
|
||||
/// iPad(split) 同时拿到入口且不越界编辑的唯一办法。改名 →
|
||||
/// `RootLeadingToolbar` 留给拥有 `SplitRootView.swift` 的后续任务(纯机械重命名)。
|
||||
struct ProjectsToolbarItem: ToolbarContent {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
|
||||
@@ -211,6 +227,41 @@ struct ProjectsToolbarItem: ToolbarContent {
|
||||
.disabled(coordinator.sessionList.activeHost == nil)
|
||||
.accessibilityIdentifier("sessions.projectsButton")
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
SettingsToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置入口(齿轮)+ 它自己的 sheet。自持 `@State` 表示态,所以不需要在
|
||||
/// `AppCoordinator` 里再开一个 `isSettingsPresented` —— 设置页与会话生命周期
|
||||
/// 完全无关,没有理由进协调器的状态机。
|
||||
///
|
||||
/// `ThemeStore` 从环境取,且是**可选**读取:若某个预览/测试没注入 store,
|
||||
/// 这里就不渲染齿轮,而不是崩(环境非可选读取在缺注入时会 crash)。
|
||||
struct SettingsToolbarButton: View {
|
||||
@Environment(ThemeStore.self) private var themeStore: ThemeStore?
|
||||
@State private var isPresented = false
|
||||
|
||||
var body: some View {
|
||||
if let themeStore {
|
||||
Button {
|
||||
isPresented = true
|
||||
} label: {
|
||||
Label(RootCopy.settings, systemImage: "gearshape")
|
||||
}
|
||||
.accessibilityIdentifier("sessions.settingsButton")
|
||||
.sheet(isPresented: $isPresented) {
|
||||
NavigationStack {
|
||||
SettingsScreen(themeStore: themeStore)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(RootCopy.done) { isPresented = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +269,7 @@ struct ProjectsToolbarItem: ToolbarContent {
|
||||
enum RootCopy {
|
||||
static let continueLast = "继续上次会话"
|
||||
static let projects = "项目"
|
||||
static let settings = "设置"
|
||||
static let done = "完成"
|
||||
/// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing.
|
||||
static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试"
|
||||
|
||||
@@ -197,7 +197,10 @@ final class TerminalSessionController: Identifiable {
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||
) -> Stack {
|
||||
let engine = SessionEngine(
|
||||
transport: environment.termTransport,
|
||||
// E1 · The transport is built for THIS host, so the WS upgrade
|
||||
// carries this host's access token (§1.1) — a shared, host-blind
|
||||
// transport could not resolve a token for a mixed fleet at all.
|
||||
transport: environment.makeTermTransport(for: host),
|
||||
clock: ContinuousClock(),
|
||||
endpoint: host.endpoint,
|
||||
eventsSource: environment.makeEventsSource(endpoint: host.endpoint)
|
||||
|
||||
@@ -4,12 +4,32 @@ import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves
|
||||
/// the URLSession wrapper for the production side; no package owns it, so the
|
||||
/// assembly layer provides it). Deliberately logic-free: `APIClient` builds
|
||||
/// every request — including the Origin-iff-G rule (plan §3.4 铁律) — and this
|
||||
/// type only performs the exchange. Adding ANY header/URL logic here would
|
||||
/// bypass that single audited point (review CRITICAL).
|
||||
/// assembly layer provides it). Deliberately logic-free about ROUTING:
|
||||
/// `APIClient` builds every request — including the Origin-iff-G rule (plan §3.4
|
||||
/// 铁律) — and this type only performs the exchange. Adding URL or Origin logic
|
||||
/// here would bypass that single audited point (review CRITICAL).
|
||||
///
|
||||
/// C1 · The ONE exception is the access-token `Cookie` (ios-completion §1.1),
|
||||
/// and it is deliberate:
|
||||
/// - the token is **unconditional** — every request, RO and G alike — so it has
|
||||
/// no interaction with the conditional Origin rule it must never replace;
|
||||
/// - it is **per host**, resolved from the request's own origin, whereas
|
||||
/// `APIClient` instances are built ad hoc all over the App layer (list poll,
|
||||
/// previews, projects, diffs, push registration) with no access to the
|
||||
/// Keychain. Stamping at the shared transport is what makes "every request
|
||||
/// carries the token" true by construction instead of per-call-site;
|
||||
/// - it is resolved LAZILY per request from `tokenForOrigin` — the same
|
||||
/// no-relaunch pattern as the mTLS `identityProvider` below — so a token typed
|
||||
/// mid-run applies to the very next request.
|
||||
///
|
||||
/// A request that ALREADY carries a `Cookie` is left untouched: the pairing probe
|
||||
/// authenticates with a *candidate* token that is not in the store yet, and
|
||||
/// overwriting it here would silently unauthenticate the probe.
|
||||
struct URLSessionHTTPTransport: HTTPTransport {
|
||||
private let session: URLSession
|
||||
/// Resolves the stored access token for a request's origin (see type doc).
|
||||
/// `@Sendable`, async: the store is an actor over the Keychain.
|
||||
private let tokenForOrigin: @Sendable (String) async -> String?
|
||||
/// Strong reference to the mTLS delegate. URLSession retains its delegate
|
||||
/// until invalidated, but this ephemeral session is never explicitly
|
||||
/// invalidated, so holding it here documents the ownership and keeps the
|
||||
@@ -18,6 +38,7 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
|
||||
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||
/// provider, so behaviour is identical to capturing the identity directly.
|
||||
/// No token source ⇒ no `Cookie` is ever added (LAN zero-config default).
|
||||
init(identity: ClientIdentity? = nil) {
|
||||
self.init(identityProvider: { identity })
|
||||
}
|
||||
@@ -37,16 +58,20 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
/// contain printed secrets — and `.shared`'s default URLCache writes
|
||||
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
|
||||
/// transport and the privacy-shade posture.
|
||||
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
init(
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?,
|
||||
tokenForOrigin: @escaping @Sendable (String) async -> String? = { _ in nil }
|
||||
) {
|
||||
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
|
||||
self.tlsDelegate = delegate
|
||||
self.tokenForOrigin = tokenForOrigin
|
||||
self.session = URLSession(
|
||||
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
|
||||
)
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
let (data, response) = try await session.data(for: await authenticated(request))
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
// http(s)-only endpoints (HostEndpoint validates) always produce
|
||||
// an HTTPURLResponse; anything else is a transport-level anomaly.
|
||||
@@ -54,6 +79,50 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
}
|
||||
return (data, httpResponse)
|
||||
}
|
||||
|
||||
/// Stamp `Cookie: webterm_auth=<t>` for the request's own origin (C1).
|
||||
/// Immutable style: returns a NEW request, never mutates the caller's.
|
||||
///
|
||||
/// `internal`, not private: this is the whole behaviour `send` adds, and it
|
||||
/// is testable with zero network — the alternative would be leaving the one
|
||||
/// line that carries a credential unverified.
|
||||
func authenticated(_ request: URLRequest) async -> URLRequest {
|
||||
guard let origin = AccessTokenCookie.origin(of: request),
|
||||
let token = await tokenForOrigin(origin) else {
|
||||
return request
|
||||
}
|
||||
return AccessTokenCookie.stamped(request, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single point where an access token becomes a request header (App layer).
|
||||
/// Pure and static so the rules are unit-testable without any network.
|
||||
enum AccessTokenCookie {
|
||||
/// `AUTH_COOKIE_NAME` (src/http/auth.ts:30) — the same literal the packages
|
||||
/// pin; both of their `AuthCookie` helpers are package-internal, so the App
|
||||
/// layer needs its own single definition rather than a fourth ad-hoc string.
|
||||
static let name = "webterm_auth"
|
||||
static let header = "Cookie"
|
||||
|
||||
/// The request's origin in `HostEndpoint.originHeader` form, via the frozen
|
||||
/// single derivation point (default ports omitted, scheme/host lowercased) —
|
||||
/// never hand-assembled here. nil for a non-http(s) or host-less URL.
|
||||
static func origin(of request: URLRequest) -> String? {
|
||||
guard let url = request.url, let endpoint = HostEndpoint(baseURL: url) else {
|
||||
return nil
|
||||
}
|
||||
return endpoint.originHeader
|
||||
}
|
||||
|
||||
/// A copy of `request` carrying the token cookie — unless it already carries
|
||||
/// a `Cookie`, in which case the existing one wins (the pairing probe's
|
||||
/// candidate token must not be overwritten by the stored one).
|
||||
static func stamped(_ request: URLRequest, token: String) -> URLRequest {
|
||||
guard request.value(forHTTPHeaderField: header) == nil else { return request }
|
||||
var authenticated = request
|
||||
authenticated.setValue("\(name)=\(token)", forHTTPHeaderField: header)
|
||||
return authenticated
|
||||
}
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves
|
||||
|
||||
346
ios/App/WebTermTests/AccessTokenSourceTests.swift
Normal file
346
ios/App/WebTermTests/AccessTokenSourceTests.swift
Normal file
@@ -0,0 +1,346 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · The app's single read path for per-host access tokens, plus the one
|
||||
/// header-stamping point (`AccessTokenCookie`).
|
||||
///
|
||||
/// E1 · The WS cases are the important ones. SessionCore's `tokenProvider` is
|
||||
/// `() -> String?` — no endpoint, no await — so C1 answered it with "the token
|
||||
/// every paired host shares", which is *nil* for the deployment the token
|
||||
/// exists for (a tokened tunnel host next to an open LAN host): the upgrade then
|
||||
/// omitted the cookie, the server 401'd, and `.failed(.unauthorized)` is
|
||||
/// terminal — with no in-app remedy, since re-entering the correct token left
|
||||
/// the fleet just as mixed. The answer is now resolved PER HOST
|
||||
/// (`wsToken(for:)`), exactly like the HTTP path and like Android's
|
||||
/// `tokens.tokenFor(endpoint)`.
|
||||
@Suite("Access-token source")
|
||||
struct AccessTokenSourceTests {
|
||||
private static let tokenA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
private static let tokenB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
private struct StoreFailure: Error {}
|
||||
|
||||
private actor ThrowingHostStore: HostStore {
|
||||
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
|
||||
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
|
||||
throw StoreFailure()
|
||||
}
|
||||
func remove(id: UUID) async throws -> [HostRegistry.Host] { throw StoreFailure() }
|
||||
}
|
||||
|
||||
private func makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: base))
|
||||
return HostRegistry.Host(
|
||||
id: UUID(),
|
||||
name: base,
|
||||
endpoint: try #require(HostEndpoint(baseURL: url)),
|
||||
accessToken: try token.map { try AccessToken(validating: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func makeStore(_ hosts: [HostRegistry.Host]) async throws -> InMemoryHostStore {
|
||||
let store = InMemoryHostStore()
|
||||
for host in hosts {
|
||||
_ = try await store.upsert(host)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// MARK: - Per-origin resolution (the HTTP path)
|
||||
|
||||
@Test("按 origin 取到该主机的令牌;其它 origin 与无令牌主机都返回 nil")
|
||||
func resolvesTokenByOrigin() async throws {
|
||||
let store = try await makeStore([
|
||||
try makeHost("http://192.168.1.5:3000", token: Self.tokenA),
|
||||
try makeHost("http://192.168.1.9:3000", token: nil),
|
||||
])
|
||||
let source = AccessTokenSource(store: store)
|
||||
|
||||
#expect(await source.token(forOrigin: "http://192.168.1.5:3000") == Self.tokenA)
|
||||
#expect(await source.token(forOrigin: "http://192.168.1.9:3000") == nil)
|
||||
#expect(await source.token(forOrigin: "http://192.168.1.99:3000") == nil)
|
||||
}
|
||||
|
||||
@Test("https 默认端口按 originHeader 规范化匹配(不写 :443)")
|
||||
func matchesNormalizedHTTPSOrigin() async throws {
|
||||
let store = try await makeStore([
|
||||
try makeHost("https://mac.tailnet.ts.net", token: Self.tokenA),
|
||||
])
|
||||
let source = AccessTokenSource(store: store)
|
||||
|
||||
#expect(await source.token(forOrigin: "https://mac.tailnet.ts.net") == Self.tokenA)
|
||||
#expect(await source.token(forOrigin: "https://mac.tailnet.ts.net:443") == nil)
|
||||
}
|
||||
|
||||
@Test("存储读取失败 → nil(降级成「无令牌」,不让 App 崩或卡住)")
|
||||
func storeFailureDegradesToNoToken() async throws {
|
||||
let source = AccessTokenSource(store: ThrowingHostStore())
|
||||
|
||||
#expect(await source.token(forOrigin: "http://192.168.1.5:3000") == nil)
|
||||
}
|
||||
|
||||
// MARK: - The per-host WS answer (E1 · replaces `hostIndependentToken`)
|
||||
|
||||
@Test("混合机群:有令牌的那台拿到自己的令牌,没令牌的那台拿 nil(旧解析对两台都给 nil)")
|
||||
func resolvesPerHostTokenInAMixedFleet() async throws {
|
||||
let tokened = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||||
let open = try makeHost("http://192.168.1.9:3000", token: nil)
|
||||
let source = AccessTokenSource(store: try await makeStore([tokened, open]))
|
||||
|
||||
_ = await source.token(forOrigin: tokened.endpoint.originHeader) // 快照热身
|
||||
|
||||
#expect(source.wsToken(for: tokened) == Self.tokenA)
|
||||
#expect(source.wsToken(for: open) == nil)
|
||||
}
|
||||
|
||||
@Test("两台令牌不同 → 各拿各的,绝不把 A 的令牌发给 B")
|
||||
func neverHandsOneHostsTokenToAnother() async throws {
|
||||
let hostA = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||||
let hostB = try makeHost("http://192.168.1.9:3000", token: Self.tokenB)
|
||||
let source = AccessTokenSource(store: try await makeStore([hostA, hostB]))
|
||||
|
||||
_ = await source.token(forOrigin: hostA.endpoint.originHeader)
|
||||
|
||||
#expect(source.wsToken(for: hostA) == Self.tokenA)
|
||||
#expect(source.wsToken(for: hostB) == Self.tokenB)
|
||||
}
|
||||
|
||||
@Test("快照还没热(刚启动就开终端)→ 回落到该主机记录,绝不因竞态漏掉 Cookie")
|
||||
func fallsBackToTheHostRecordBeforeAnyStoreRead() async throws {
|
||||
let host = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||||
let source = AccessTokenSource(store: try await makeStore([host]))
|
||||
|
||||
// No `token(forOrigin:)` call yet — the snapshot is empty.
|
||||
#expect(source.wsToken(for: host) == Self.tokenA)
|
||||
}
|
||||
|
||||
@Test("令牌轮换后:下一次连接用存储里的新值,而不是打开终端时的旧值")
|
||||
func picksUpARotatedTokenOnTheNextConnect() async throws {
|
||||
let opened = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||||
let store = try await makeStore([opened])
|
||||
let source = AccessTokenSource(store: store)
|
||||
let rotated = HostRegistry.Host(
|
||||
id: opened.id, name: opened.name, endpoint: opened.endpoint,
|
||||
accessToken: try AccessToken(validating: Self.tokenB)
|
||||
)
|
||||
_ = try await store.upsert(rotated)
|
||||
|
||||
_ = await source.token(forOrigin: opened.endpoint.originHeader) // 任一 HTTP 请求即刷新
|
||||
|
||||
#expect(source.wsToken(for: opened) == Self.tokenB)
|
||||
}
|
||||
|
||||
@Test("存储读取失败 → 回落到主机记录(打开时的值),不是别的主机的令牌")
|
||||
func wsTokenSurvivesAStoreFailure() async throws {
|
||||
let host = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||||
let source = AccessTokenSource(store: ThrowingHostStore())
|
||||
|
||||
#expect(await source.token(forOrigin: host.endpoint.originHeader) == nil)
|
||||
#expect(source.wsToken(for: host) == Self.tokenA)
|
||||
}
|
||||
|
||||
@Test("完全没有令牌的机群 → nil(LAN 零配置逐字不变,不带 Cookie)")
|
||||
func tokenlessFleetYieldsNoCookie() async throws {
|
||||
let host = try makeHost("http://192.168.1.5:3000", token: nil)
|
||||
let source = AccessTokenSource(store: try await makeStore([host]))
|
||||
|
||||
_ = await source.token(forOrigin: host.endpoint.originHeader)
|
||||
|
||||
#expect(source.wsToken(for: host) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// E1 · The wiring that the per-host answer depends on: a terminal's WS
|
||||
/// transport is built for THE host it dials, not once for the whole app.
|
||||
@MainActor
|
||||
@Suite("Per-host WS transport wiring")
|
||||
struct PerHostTermTransportTests {
|
||||
/// `@Sendable`-safe recorder for the hosts the factory was asked about.
|
||||
private final class HostRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var hosts: [HostRegistry.Host] = []
|
||||
|
||||
func record(_ host: HostRegistry.Host) {
|
||||
lock.withLock { hosts.append(host) }
|
||||
}
|
||||
|
||||
var recorded: [HostRegistry.Host] { lock.withLock { hosts } }
|
||||
}
|
||||
|
||||
private func makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: base))
|
||||
return HostRegistry.Host(
|
||||
id: UUID(), name: base,
|
||||
endpoint: try #require(HostEndpoint(baseURL: url)),
|
||||
accessToken: try token.map { try AccessToken(validating: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func makeEnvironment(
|
||||
shared: FakeTransport,
|
||||
factory: (@Sendable (HostRegistry.Host) -> any TermTransport)? = nil
|
||||
) throws -> AppEnvironment {
|
||||
let defaults = try #require(UserDefaults(suiteName: "PerHostTermTransportTests"))
|
||||
return AppEnvironment(
|
||||
hostStore: InMemoryHostStore(),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: FakeHTTPTransport(),
|
||||
termTransport: shared,
|
||||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
|
||||
termTransportFactory: factory
|
||||
)
|
||||
}
|
||||
|
||||
@Test("未注入工厂 → 回落到共享传输(既有 App 层测试装配零回归)")
|
||||
func fallsBackToTheSharedTransport() throws {
|
||||
let shared = FakeTransport()
|
||||
let environment = try makeEnvironment(shared: shared)
|
||||
|
||||
let resolved = environment.makeTermTransport(for: try makeHost("http://192.168.1.5:3000", token: nil))
|
||||
|
||||
#expect(resolved as? FakeTransport === shared)
|
||||
}
|
||||
|
||||
@Test("注入工厂 → 每台主机各建一个传输,且工厂拿到的就是那台主机(含其令牌)")
|
||||
func buildsOneTransportPerHost() throws {
|
||||
let recorder = HostRecorder()
|
||||
let environment = try makeEnvironment(
|
||||
shared: FakeTransport(),
|
||||
factory: { host in
|
||||
recorder.record(host)
|
||||
return FakeTransport()
|
||||
}
|
||||
)
|
||||
let tokened = try makeHost("http://192.168.1.5:3000", token: String(repeating: "a", count: 32))
|
||||
let open = try makeHost("http://192.168.1.9:3000", token: nil)
|
||||
|
||||
_ = environment.makeTermTransport(for: tokened)
|
||||
_ = environment.makeTermTransport(for: open)
|
||||
|
||||
#expect(recorder.recorded.map(\.id) == [tokened.id, open.id])
|
||||
#expect(recorder.recorded.first?.accessToken == tokened.accessToken)
|
||||
}
|
||||
|
||||
@Test("TerminalSessionController 用自己那台主机建传输(终端 401 的根因就在这里)")
|
||||
func controllerBuildsItsTransportForItsOwnHost() throws {
|
||||
let recorder = HostRecorder()
|
||||
let environment = try makeEnvironment(
|
||||
shared: FakeTransport(),
|
||||
factory: { host in
|
||||
recorder.record(host)
|
||||
return FakeTransport()
|
||||
}
|
||||
)
|
||||
let host = try makeHost("http://192.168.1.5:3000", token: String(repeating: "b", count: 32))
|
||||
|
||||
_ = TerminalSessionController(
|
||||
host: host, sessionId: nil, environment: environment,
|
||||
onPendingChanged: { _, _ in }
|
||||
)
|
||||
|
||||
#expect(recorder.recorded.map(\.id) == [host.id])
|
||||
}
|
||||
}
|
||||
|
||||
/// C1 · The App layer's one place where a token becomes a header.
|
||||
@Suite("Access-token cookie stamping")
|
||||
struct AccessTokenCookieTests {
|
||||
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
|
||||
|
||||
private func request(_ url: String) throws -> URLRequest {
|
||||
URLRequest(url: try #require(URL(string: url)))
|
||||
}
|
||||
|
||||
@Test("请求 URL → originHeader 形式的 origin(路径/查询串被忽略)")
|
||||
func derivesOriginFromRequestURL() throws {
|
||||
let withPath = try request("http://192.168.1.5:3000/live-sessions/abc/preview?x=1")
|
||||
|
||||
#expect(AccessTokenCookie.origin(of: withPath) == "http://192.168.1.5:3000")
|
||||
}
|
||||
|
||||
@Test("https 默认端口不写 :443(与服务器的 URL 规范化一致)")
|
||||
func omitsDefaultHTTPSPort() throws {
|
||||
let secure = try request("https://mac.tailnet.ts.net/live-sessions")
|
||||
|
||||
#expect(AccessTokenCookie.origin(of: secure) == "https://mac.tailnet.ts.net")
|
||||
}
|
||||
|
||||
@Test("非 http(s) / 无 host 的请求 → nil(不可能是本 App 的主机)")
|
||||
func rejectsNonHTTPRequests() throws {
|
||||
#expect(AccessTokenCookie.origin(of: try request("ftp://192.168.1.5/x")) == nil)
|
||||
#expect(AccessTokenCookie.origin(of: URLRequest(url: URL(fileURLWithPath: "/tmp"))) == nil)
|
||||
}
|
||||
|
||||
@Test("盖上 Cookie: webterm_auth=<t>,且返回新请求(不原地改调用方的请求)")
|
||||
func stampsCookieImmutably() throws {
|
||||
let original = try request("http://192.168.1.5:3000/live-sessions")
|
||||
|
||||
let stamped = AccessTokenCookie.stamped(original, token: Self.token)
|
||||
|
||||
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||||
#expect(original.value(forHTTPHeaderField: "Cookie") == nil)
|
||||
}
|
||||
|
||||
@Test("已经带 Cookie 的请求原样通过(配对探针的候选令牌不能被覆盖)")
|
||||
func neverOverwritesAnExistingCookie() throws {
|
||||
var candidate = try request("http://192.168.1.5:3000/live-sessions")
|
||||
candidate.setValue("webterm_auth=candidate-token-value", forHTTPHeaderField: "Cookie")
|
||||
|
||||
let stamped = AccessTokenCookie.stamped(candidate, token: Self.token)
|
||||
|
||||
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate-token-value")
|
||||
}
|
||||
|
||||
@Test("Cookie 名与服务器 AUTH_COOKIE_NAME 逐字一致")
|
||||
func cookieNameMatchesTheServer() {
|
||||
#expect(AccessTokenCookie.name == "webterm_auth")
|
||||
}
|
||||
|
||||
// MARK: - The transport choke point itself (no network involved)
|
||||
|
||||
@Test("传输层给每个请求按 origin 盖上令牌 Cookie(RO GET 也一样)")
|
||||
func transportStampsEveryRequest() async throws {
|
||||
let transport = URLSessionHTTPTransport(
|
||||
identityProvider: { nil },
|
||||
tokenForOrigin: { origin in
|
||||
origin == "http://192.168.1.5:3000" ? Self.token : nil
|
||||
}
|
||||
)
|
||||
let readOnly = try request("http://192.168.1.5:3000/live-sessions")
|
||||
|
||||
let stamped = await transport.authenticated(readOnly)
|
||||
|
||||
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||||
}
|
||||
|
||||
@Test("没有该 origin 的令牌 → 请求逐字不变(LAN 零配置主机不受影响)")
|
||||
func transportLeavesTokenlessOriginsAlone() async throws {
|
||||
let transport = URLSessionHTTPTransport(
|
||||
identityProvider: { nil }, tokenForOrigin: { _ in nil }
|
||||
)
|
||||
let plain = try request("http://192.168.1.9:3000/live-sessions")
|
||||
|
||||
let result = await transport.authenticated(plain)
|
||||
|
||||
#expect(result.value(forHTTPHeaderField: "Cookie") == nil)
|
||||
#expect(result.allHTTPHeaderFields == plain.allHTTPHeaderFields)
|
||||
}
|
||||
|
||||
@Test("请求已带 Cookie(配对探针的候选令牌)→ 传输层不覆盖")
|
||||
func transportNeverOverwritesTheProbeCandidate() async throws {
|
||||
let transport = URLSessionHTTPTransport(
|
||||
identityProvider: { nil }, tokenForOrigin: { _ in Self.token }
|
||||
)
|
||||
var candidate = try request("http://192.168.1.5:3000/live-sessions")
|
||||
candidate.setValue("webterm_auth=candidate", forHTTPHeaderField: "Cookie")
|
||||
|
||||
let result = await transport.authenticated(candidate)
|
||||
|
||||
#expect(result.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate")
|
||||
}
|
||||
}
|
||||
353
ios/App/WebTermTests/AppThemeTests.swift
Normal file
353
ios/App/WebTermTests/AppThemeTests.swift
Normal file
@@ -0,0 +1,353 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-34 · 主题(跟随系统 / 深色 / 浅色)—— 模型、持久化、有效配色解析,
|
||||
/// 以及**浅色通路的 token 审计**(doc §4 A–E 条目 1–24)。
|
||||
///
|
||||
/// 审计的核心不是"把 `.preferredColorScheme(.dark)` 解锁",而是:每个语义色在
|
||||
/// 浅色档下都要有**能看清**的值。故 D 组用 WCAG 对比度(非文本 UI 组件门槛
|
||||
/// 1.4.11 = 3:1;终端正文按 AAA = 7:1)逐档量化,深色档同时**逐字节钉死**旧值
|
||||
/// 保证零回归。
|
||||
@MainActor
|
||||
@Suite("AppTheme")
|
||||
struct AppThemeTests {
|
||||
|
||||
// MARK: - A. 主题模型
|
||||
|
||||
@Test("colorScheme:.system 交给系统(nil),.dark/.light 强制自身")
|
||||
func colorSchemePerCase() {
|
||||
#expect(AppTheme.system.colorScheme == nil)
|
||||
#expect(AppTheme.dark.colorScheme == .dark)
|
||||
#expect(AppTheme.light.colorScheme == .light)
|
||||
}
|
||||
|
||||
@Test("三档 label / SF Symbol 非空且互不相同")
|
||||
func labelsAndSymbolsAreDistinct() {
|
||||
let labels = AppTheme.allCases.map(\.label)
|
||||
let symbols = AppTheme.allCases.map(\.symbolName)
|
||||
#expect(labels.allSatisfy { !$0.isEmpty })
|
||||
#expect(symbols.allSatisfy { !$0.isEmpty })
|
||||
#expect(Set(labels).count == AppTheme.allCases.count)
|
||||
#expect(Set(symbols).count == AppTheme.allCases.count)
|
||||
}
|
||||
|
||||
@Test("allCases 顺序 = 跟随系统 → 深色 → 浅色(选择器直接用它,不重排)")
|
||||
func caseOrderIsPickerOrder() {
|
||||
#expect(AppTheme.allCases == [.system, .dark, .light])
|
||||
}
|
||||
|
||||
@Test("rawValue 白名单:只认三个小写标识")
|
||||
func rawValueWhitelist() {
|
||||
#expect(AppTheme(rawValue: "system") == .system)
|
||||
#expect(AppTheme(rawValue: "dark") == .dark)
|
||||
#expect(AppTheme(rawValue: "light") == .light)
|
||||
#expect(AppTheme(rawValue: "") == nil)
|
||||
#expect(AppTheme(rawValue: "Dark") == nil)
|
||||
#expect(AppTheme(rawValue: "solarized") == nil)
|
||||
}
|
||||
|
||||
// MARK: - B. 持久化
|
||||
|
||||
@Test("未设置 → .dark(默认必须等于今天硬锁的深色,零回归)")
|
||||
func decodeMissingIsDark() {
|
||||
#expect(AppThemeCodec.decode(nil) == .dark)
|
||||
#expect(AppThemeCodec.fallback == .dark)
|
||||
}
|
||||
|
||||
@Test("脏值 / 空串 / 大小写不符 → .dark,不崩、不落 system")
|
||||
func decodeGarbageIsDark() {
|
||||
#expect(AppThemeCodec.decode("solarized") == .dark)
|
||||
#expect(AppThemeCodec.decode("") == .dark)
|
||||
#expect(AppThemeCodec.decode("DARK") == .dark)
|
||||
#expect(AppThemeCodec.decode("\u{0}light") == .dark)
|
||||
}
|
||||
|
||||
@Test("三档 encode → decode 往返恒等")
|
||||
func codecRoundTrips() {
|
||||
for theme in AppTheme.allCases {
|
||||
#expect(AppThemeCodec.decode(AppThemeCodec.encode(theme)) == theme)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("空 defaults → .dark,且首次读不写盘")
|
||||
func storeStartsDarkWithoutWriting() {
|
||||
let defaults = FakeThemeDefaults()
|
||||
let store = ThemeStore(defaults: defaults)
|
||||
|
||||
#expect(store.theme == .dark)
|
||||
#expect(defaults.writeCount == 0)
|
||||
}
|
||||
|
||||
@Test("select(.light) → 落盘 \"light\",同一 defaults 新建 store 读回(跨启动)")
|
||||
func selectPersistsAcrossStores() {
|
||||
let defaults = FakeThemeDefaults()
|
||||
let store = ThemeStore(defaults: defaults)
|
||||
|
||||
store.select(.light)
|
||||
|
||||
#expect(store.theme == .light)
|
||||
#expect(defaults.values[ThemeStore.storageKey] == "light")
|
||||
#expect(ThemeStore(defaults: defaults).theme == .light)
|
||||
}
|
||||
|
||||
@Test("select 同一档两次 → 只写一次")
|
||||
func repeatedSelectWritesOnce() {
|
||||
let defaults = FakeThemeDefaults()
|
||||
let store = ThemeStore(defaults: defaults)
|
||||
|
||||
store.select(.light)
|
||||
store.select(.light)
|
||||
|
||||
#expect(defaults.writeCount == 1)
|
||||
}
|
||||
|
||||
@Test("defaults 里是脏值 → 读作 .dark,且不动其它键")
|
||||
func dirtyStoredValueDegradesToDark() {
|
||||
let defaults = FakeThemeDefaults(values: [
|
||||
ThemeStore.storageKey: "; rm -rf ~",
|
||||
"unrelated.key": "keep-me",
|
||||
])
|
||||
|
||||
let store = ThemeStore(defaults: defaults)
|
||||
|
||||
#expect(store.theme == .dark)
|
||||
#expect(defaults.values["unrelated.key"] == "keep-me")
|
||||
}
|
||||
|
||||
// MARK: - C. 有效配色解析
|
||||
|
||||
@Test("resolvedScheme:.system 透传系统值,.dark/.light 无视系统")
|
||||
func resolvedScheme() {
|
||||
#expect(AppTheme.system.resolvedScheme(system: .light) == .light)
|
||||
#expect(AppTheme.system.resolvedScheme(system: .dark) == .dark)
|
||||
#expect(AppTheme.dark.resolvedScheme(system: .light) == .dark)
|
||||
#expect(AppTheme.light.resolvedScheme(system: .dark) == .light)
|
||||
}
|
||||
|
||||
// MARK: - D. 浅色通路 token 审计
|
||||
|
||||
/// 受审的 5 个语义色(状态三色 + 时间线两色)——它们原本都是单值深色专用。
|
||||
private static let semanticColors: [(name: String, color: Color)] = [
|
||||
("statusWorking", DS.Palette.statusWorking),
|
||||
("statusWaiting", DS.Palette.statusWaiting),
|
||||
("statusStuck", DS.Palette.statusStuck),
|
||||
("timelineTool", DS.Palette.timelineTool),
|
||||
("timelineUser", DS.Palette.timelineUser),
|
||||
]
|
||||
|
||||
@Test("5 个语义色在 light 与 dark 下解析值不同(真有浅色变体)")
|
||||
func semanticColorsAreAdaptive() {
|
||||
for (name, color) in Self.semanticColors {
|
||||
let dark = UIColor(color).resolved(.dark)
|
||||
let light = UIColor(color).resolved(.light)
|
||||
#expect(!ColorMath.approxEqual(dark, light), "\(name) 在两档下相同 → 没有浅色变体")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("深色档逐字节不变(#46D07F/#F5B14C/#FF6B6B/#5E9EFF/#AF7BFF)")
|
||||
func darkSchemeValuesUnchanged() {
|
||||
let expected: [(Color, UInt32)] = [
|
||||
(DS.Palette.statusWorking, 0x46D0_7F),
|
||||
(DS.Palette.statusWaiting, 0xF5B1_4C),
|
||||
(DS.Palette.statusStuck, 0xFF6B_6B),
|
||||
(DS.Palette.timelineTool, 0x5E9E_FF),
|
||||
(DS.Palette.timelineUser, 0xAF7B_FF),
|
||||
]
|
||||
for (color, hex) in expected {
|
||||
let resolved = UIColor(color).resolved(.dark)
|
||||
#expect(
|
||||
ColorMath.matchesHex(resolved, hex),
|
||||
"深色档漂移:实际 \(ColorMath.hexString(resolved))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("两档下语义色对该档 systemBackground 的对比度 ≥ 3:1(WCAG 1.4.11)")
|
||||
func semanticColorsMeetNonTextContrast() {
|
||||
for style in [UIUserInterfaceStyle.dark, .light] {
|
||||
let background = UIColor.systemBackground.resolved(style)
|
||||
for (name, color) in Self.semanticColors {
|
||||
let ratio = ColorMath.contrast(UIColor(color).resolved(style), background)
|
||||
#expect(ratio >= 3, "\(name) 在 \(style == .dark ? "dark" : "light") 只有 \(ratio):1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("light 档 working/waiting/stuck 仍两两可辨")
|
||||
func criticalTrioStaysDistinctInLight() {
|
||||
let working = UIColor(DS.Palette.statusWorking).resolved(.light)
|
||||
let waiting = UIColor(DS.Palette.statusWaiting).resolved(.light)
|
||||
let stuck = UIColor(DS.Palette.statusStuck).resolved(.light)
|
||||
#expect(!ColorMath.approxEqual(working, waiting))
|
||||
#expect(!ColorMath.approxEqual(working, stuck))
|
||||
#expect(!ColorMath.approxEqual(waiting, stuck))
|
||||
}
|
||||
|
||||
@Test("accentSoft 两档不同,且都仍是 wash(alpha < 0.3)")
|
||||
func accentSoftIsAdaptiveWash() {
|
||||
let soft = UIColor(DS.Palette.accentSoft)
|
||||
let dark = soft.resolved(.dark)
|
||||
let light = soft.resolved(.light)
|
||||
#expect(!ColorMath.approxEqual(dark, light))
|
||||
#expect(ColorMath.rgba(dark).a < 0.3)
|
||||
#expect(ColorMath.rgba(light).a < 0.3)
|
||||
}
|
||||
|
||||
@Test("onAccent 两档相同(金填充恒需深墨),与 accent 对比 ≥ 4.5:1")
|
||||
func onAccentIsFixedAndReadable() {
|
||||
let ink = UIColor(DS.Palette.onAccent)
|
||||
#expect(ColorMath.approxEqual(ink.resolved(.dark), ink.resolved(.light)))
|
||||
for style in [UIUserInterfaceStyle.dark, .light] {
|
||||
let ratio = ColorMath.contrast(
|
||||
ink.resolved(style), DS.Palette.accentUIColor().resolved(style)
|
||||
)
|
||||
#expect(ratio >= 4.5, "onAccent/accent 在 \(style.rawValue) 只有 \(ratio):1")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - E. 终端画布跟随主题
|
||||
|
||||
@Test("dark 档终端 = #100F0D / #ECE9E3(桌面 web --bg/--text,零回归)")
|
||||
func terminalDarkMatchesDesktop() {
|
||||
let colors = TerminalPalette.colors(for: .dark)
|
||||
#expect(ColorMath.matchesHex(colors.background, 0x100F_0D),
|
||||
"实际 \(ColorMath.hexString(colors.background))")
|
||||
#expect(ColorMath.matchesHex(colors.foreground, 0xECE9_E3),
|
||||
"实际 \(ColorMath.hexString(colors.foreground))")
|
||||
}
|
||||
|
||||
@Test("light 档终端 = #F6F7F9 / #1A1D24(镜像 web THEMES.light)")
|
||||
func terminalLightMirrorsWeb() {
|
||||
let colors = TerminalPalette.colors(for: .light)
|
||||
#expect(ColorMath.matchesHex(colors.background, 0xF6F7_F9),
|
||||
"实际 \(ColorMath.hexString(colors.background))")
|
||||
#expect(ColorMath.matchesHex(colors.foreground, 0x1A1D_24),
|
||||
"实际 \(ColorMath.hexString(colors.foreground))")
|
||||
}
|
||||
|
||||
@Test("两档终端 fg↔bg 对比度 ≥ 7:1(终端是正文,AAA)")
|
||||
func terminalContrastIsAAA() {
|
||||
for scheme in [ColorScheme.dark, .light] {
|
||||
let colors = TerminalPalette.colors(for: scheme)
|
||||
let ratio = ColorMath.contrast(colors.foreground, colors.background)
|
||||
#expect(ratio >= 7, "终端 \(scheme) 对比度只有 \(ratio):1")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("caret / selection 用该档 accent 解析值")
|
||||
func terminalCaretFollowsAccent() {
|
||||
for (scheme, style) in [(ColorScheme.dark, UIUserInterfaceStyle.dark),
|
||||
(ColorScheme.light, UIUserInterfaceStyle.light)] {
|
||||
let colors = TerminalPalette.colors(for: scheme)
|
||||
let accent = DS.Palette.accentUIColor().resolved(style)
|
||||
#expect(ColorMath.approxEqual(colors.caret, accent))
|
||||
#expect(ColorMath.approxEqual(colors.selection, accent))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("terminalBackgroundUIColor()/ForegroundUIColor() 是动态 UIColor(两档不同)")
|
||||
func terminalTokensAreDynamic() {
|
||||
for token in [DS.Palette.terminalBackgroundUIColor(),
|
||||
DS.Palette.terminalForegroundUIColor()] {
|
||||
#expect(!ColorMath.approxEqual(token.resolved(.dark), token.resolved(.light)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("SwiftUI 侧 terminalBackground/Foreground 同样跟随主题(既有调用点不退化)")
|
||||
func terminalSwiftUITokensStayDynamic() {
|
||||
for color in [DS.Palette.terminalBackground, DS.Palette.terminalForeground] {
|
||||
let bridged = UIColor(color)
|
||||
#expect(!ColorMath.approxEqual(bridged.resolved(.dark), bridged.resolved(.light)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fake defaults
|
||||
|
||||
/// `ThemeDefaults` 测试替身:记录写次数("首次读不写盘"、"重复 select 只写一次"
|
||||
/// 两条断言都靠它),并保留其它键以证明 store 不会越界清理。
|
||||
final class FakeThemeDefaults: ThemeDefaults {
|
||||
private(set) var values: [String: String]
|
||||
private(set) var writeCount = 0
|
||||
|
||||
init(values: [String: String] = [:]) {
|
||||
self.values = values
|
||||
}
|
||||
|
||||
func themeRaw(forKey key: String) -> String? { values[key] }
|
||||
|
||||
func setThemeRaw(_ value: String, forKey key: String) {
|
||||
values = values.merging([key: value]) { _, new in new }
|
||||
writeCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Color math (WCAG)
|
||||
|
||||
/// 颜色解析 + WCAG 相对亮度/对比度。放在测试侧:生产代码不需要算对比度,
|
||||
/// 但审计必须**量化**("看着还行"不是验收)。公式取 WCAG 2.1 定义。
|
||||
enum ColorMath {
|
||||
typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)
|
||||
|
||||
static func rgba(_ color: UIColor) -> RGBA {
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
color.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return (r, g, b, a)
|
||||
}
|
||||
|
||||
static func approxEqual(_ lhs: UIColor, _ rhs: UIColor, tolerance: CGFloat = 0.02) -> Bool {
|
||||
let left = rgba(lhs), right = rgba(rhs)
|
||||
return abs(left.r - right.r) < tolerance
|
||||
&& abs(left.g - right.g) < tolerance
|
||||
&& abs(left.b - right.b) < tolerance
|
||||
&& abs(left.a - right.a) < tolerance
|
||||
}
|
||||
|
||||
/// WCAG 相对亮度(sRGB → 线性)。
|
||||
static func luminance(_ color: UIColor) -> CGFloat {
|
||||
let components = rgba(color)
|
||||
func linear(_ channel: CGFloat) -> CGFloat {
|
||||
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * linear(components.r)
|
||||
+ 0.7152 * linear(components.g)
|
||||
+ 0.0722 * linear(components.b)
|
||||
}
|
||||
|
||||
/// WCAG 对比度(1:1…21:1),顺序无关。
|
||||
static func contrast(_ lhs: UIColor, _ rhs: UIColor) -> CGFloat {
|
||||
let a = luminance(lhs), b = luminance(rhs)
|
||||
let (lighter, darker) = a > b ? (a, b) : (b, a)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
/// 逐字节(±1/255)比对一个具体色与 `0xRRGGBB`。
|
||||
static func matchesHex(_ color: UIColor, _ hex: UInt32, tolerance: CGFloat = 0.004) -> Bool {
|
||||
let components = rgba(color)
|
||||
let expected = (
|
||||
r: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||
g: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||
b: CGFloat(hex & 0xFF) / 255
|
||||
)
|
||||
return abs(components.r - expected.r) < tolerance
|
||||
&& abs(components.g - expected.g) < tolerance
|
||||
&& abs(components.b - expected.b) < tolerance
|
||||
}
|
||||
|
||||
/// 失败时把实际值印成 `#RRGGBB`,便于一眼看出差多少。
|
||||
static func hexString(_ color: UIColor) -> String {
|
||||
let components = rgba(color)
|
||||
let value = (Int(components.r * 255) << 16)
|
||||
| (Int(components.g * 255) << 8) | Int(components.b * 255)
|
||||
return String(format: "#%06X", value)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
/// 按指定外观解析动态色(审计的基本动作)。
|
||||
func resolved(_ style: UIUserInterfaceStyle) -> UIColor {
|
||||
resolvedColor(with: UITraitCollection(userInterfaceStyle: style))
|
||||
}
|
||||
}
|
||||
302
ios/App/WebTermTests/DeepLinkJoinTests.swift
Normal file
302
ios/App/WebTermTests/DeepLinkJoinTests.swift
Normal file
@@ -0,0 +1,302 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-35 · web 分享 QR(`?join=`)互通(doc §4 A–C 组,条目 30–50)。
|
||||
///
|
||||
/// web 侧的分享 URL 形状是 `${location.origin}/?join=${sessionId}`
|
||||
/// (`public/share.ts:55`)。手机拿到这个 URL 时它是**不可信外部输入**(二维码
|
||||
/// 里可以是任何东西),所以 http(s) 分支比自定义 scheme 分支**更严**:
|
||||
/// scheme/host/path/query 键逐项白名单、query 精确只许一个 `join`、拒 fragment、
|
||||
/// 拒 userinfo,`sessionId` 仍只经冻结的 `Validation.isValidSessionId`。
|
||||
///
|
||||
/// 主机身份**只**经 `HostStore` 解析:origin 比对用 `HostEndpoint.originHeader`
|
||||
/// (冻结的唯一 origin 派生点),未配对 → 落配对流程,**绝不**照链接内容静默新增主机。
|
||||
@MainActor
|
||||
@Suite("DeepLinkJoin")
|
||||
struct DeepLinkJoinTests {
|
||||
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
|
||||
|
||||
private static func url(_ string: String) throws -> URL {
|
||||
try #require(URL(string: string))
|
||||
}
|
||||
|
||||
// MARK: - A. 接受 web 分享形状
|
||||
|
||||
@Test("http://<ip>:3000/?join=<v4> → .joinShared(origin, sessionId)")
|
||||
func lanShareLinkRoutes() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("http://192.168.1.5:3000/?join=\(Self.validSessionId)")
|
||||
)
|
||||
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .joinShared(origin: "http://192.168.1.5:3000", sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("https 默认端口不出现在 origin 里")
|
||||
func httpsDefaultPortOmitted() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("https://mac.tailnet.ts.net/?join=\(Self.validSessionId)")
|
||||
)
|
||||
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .joinShared(origin: "https://mac.tailnet.ts.net", sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("大写 scheme/host → origin 归一化为小写")
|
||||
func originIsNormalizedLowercase() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("HTTP://MAC.LOCAL:3000/?join=\(Self.validSessionId)")
|
||||
)
|
||||
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .joinShared(origin: "http://mac.local:3000", sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("path 为空或 \"/\" 都接受(origin + \"/?join=\" 产出 /)")
|
||||
func emptyAndRootPathsAccepted() throws {
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
let expected = DeepLinkRouter.Route.joinShared(
|
||||
origin: "http://mac.local:3000", sessionId: sessionId
|
||||
)
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local:3000/?join=\(Self.validSessionId)")
|
||||
) == expected
|
||||
)
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local:3000?join=\(Self.validSessionId)")
|
||||
) == expected
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - B. 白名单纪律
|
||||
|
||||
@Test("join 值非 v4 → .ignore(复用 Validation,不再造正则)")
|
||||
func nonV4JoinIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=\(Self.v1StyleId)"))
|
||||
== .ignore
|
||||
)
|
||||
#expect(
|
||||
DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=not-a-uuid")) == .ignore
|
||||
)
|
||||
#expect(DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=")) == .ignore)
|
||||
}
|
||||
|
||||
@Test("重复 join 键 → .ignore(歧义输入绝不部分应用)")
|
||||
func duplicateJoinIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url(
|
||||
"http://mac.local/?join=\(Self.validSessionId)&join=\(Self.validSessionId)"
|
||||
)
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("多余 query 键 → .ignore(web 形状精确只有一个 join)")
|
||||
func extraQueryKeysIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)&x=1")
|
||||
) == .ignore
|
||||
)
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local/?utm=a&join=\(Self.validSessionId)")
|
||||
) == .ignore
|
||||
)
|
||||
}
|
||||
|
||||
@Test("非空 path → .ignore")
|
||||
func nonEmptyPathIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local/manage.html?join=\(Self.validSessionId)")
|
||||
) == .ignore
|
||||
)
|
||||
}
|
||||
|
||||
@Test("带 fragment → .ignore")
|
||||
func fragmentIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)#x")
|
||||
) == .ignore
|
||||
)
|
||||
}
|
||||
|
||||
@Test("带 userinfo(二维码钓鱼形状)→ .ignore")
|
||||
func userInfoIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url("http://user:pass@mac.local/?join=\(Self.validSessionId)")
|
||||
) == .ignore
|
||||
)
|
||||
}
|
||||
|
||||
@Test("无 host → .ignore")
|
||||
func missingHostIgnored() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(url: try Self.url("http:///?join=\(Self.validSessionId)"))
|
||||
== .ignore
|
||||
)
|
||||
}
|
||||
|
||||
@Test("自定义 scheme 分支零回归:webterminal 仍需 host+join 双 v4")
|
||||
func customSchemeBranchUnchanged() throws {
|
||||
#expect(
|
||||
DeepLinkRouter.route(url: try Self.url("webterminal://open?join=\(Self.validSessionId)"))
|
||||
== .ignore
|
||||
)
|
||||
let hostId = UUID()
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(
|
||||
DeepLinkRouter.route(
|
||||
url: try Self.url(
|
||||
"webterminal://open?host=\(hostId.uuidString)&join=\(Self.validSessionId)"
|
||||
)
|
||||
) == .openSession(hostId: hostId, sessionId: sessionId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// C 组 —— `DeepLinkHandler` 侧:origin → 已配对主机的解析。
|
||||
@MainActor
|
||||
@Suite("DeepLinkJoinHandler")
|
||||
struct DeepLinkJoinHandlerTests {
|
||||
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
|
||||
@MainActor
|
||||
private final class ActionRecorder {
|
||||
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
|
||||
private(set) var pairingShownCount = 0
|
||||
|
||||
var actions: DeepLinkHandler.Actions {
|
||||
DeepLinkHandler.Actions(
|
||||
openSession: { [weak self] host, sessionId in
|
||||
self?.opened.append((host, sessionId))
|
||||
},
|
||||
showPairing: { [weak self] in self?.pairingShownCount += 1 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeHost(_ base: String) throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: "mac", endpoint: endpoint)
|
||||
}
|
||||
|
||||
private static func shareURL(_ origin: String) throws -> URL {
|
||||
try #require(URL(string: "\(origin)/?join=\(validSessionId)"))
|
||||
}
|
||||
|
||||
@Test("origin 命中已配对主机 → 恰一次 openSession,无 hint")
|
||||
func knownOriginOpensSession() async throws {
|
||||
let host = try Self.makeHost("http://192.168.1.5:3000")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://192.168.1.5:3000"))
|
||||
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(recorder.opened.count == 1)
|
||||
#expect(recorder.opened.first?.host == host)
|
||||
#expect(recorder.opened.first?.sessionId == sessionId)
|
||||
#expect(handler.hintMessage == nil)
|
||||
}
|
||||
|
||||
@Test("origin 未配对 → 零 open + 落配对流程(绝不据链接静默新增主机)")
|
||||
func unknownOriginNeverPairsSilently() async throws {
|
||||
let paired = try Self.makeHost("http://192.168.1.5:3000")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [paired] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://10.0.0.9:3000"))
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 1)
|
||||
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
|
||||
}
|
||||
|
||||
@Test("提示文案不回显链接内容(防钓鱼/防日志注入)")
|
||||
func hintNeverEchoesLinkContents() async throws {
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://evil.example:3000"))
|
||||
|
||||
let hint = try #require(handler.hintMessage)
|
||||
#expect(!hint.contains("evil.example"))
|
||||
#expect(!hint.contains(Self.validSessionId))
|
||||
}
|
||||
|
||||
@Test("origin 比对经 originHeader:大小写/尾斜杠同一主机,端口不同则不是")
|
||||
func originComparisonUsesOriginHeader() async throws {
|
||||
let host = try Self.makeHost("http://mac.local:3000")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://MAC.LOCAL:3000"))
|
||||
#expect(recorder.opened.count == 1)
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://mac.local:3001"))
|
||||
#expect(recorder.opened.count == 1) // 端口不同 → 不是同一主机
|
||||
#expect(recorder.pairingShownCount == 1)
|
||||
}
|
||||
|
||||
@Test("scheme 不同 → 不是同一主机")
|
||||
func schemeMismatchIsDifferentHost() async throws {
|
||||
let host = try Self.makeHost("http://mac.local")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.shareURL("https://mac.local"))
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 1)
|
||||
}
|
||||
|
||||
@Test("冷启动 stash 对 .joinShared 同样生效(恰应用一次)")
|
||||
func coldLaunchStashAppliesJoin() async throws {
|
||||
let host = try Self.makeHost("http://mac.local:3000")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
|
||||
await handler.handle(url: try Self.shareURL("http://mac.local:3000"))
|
||||
#expect(recorder.opened.isEmpty)
|
||||
|
||||
await handler.markReady()
|
||||
#expect(recorder.opened.count == 1)
|
||||
|
||||
await handler.markReady()
|
||||
#expect(recorder.opened.count == 1)
|
||||
}
|
||||
|
||||
@Test("非法 web 链接 → ignoredCount+1 且不入 stash")
|
||||
func invalidWebLinkCountedNeverStashed() async throws {
|
||||
let host = try Self.makeHost("http://mac.local:3000")
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
|
||||
await handler.handle(
|
||||
url: try #require(URL(string: "http://mac.local:3000/?join=nope&x=1"))
|
||||
)
|
||||
#expect(handler.ignoredCount == 1)
|
||||
|
||||
await handler.markReady()
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 0)
|
||||
}
|
||||
}
|
||||
@@ -78,14 +78,30 @@ struct DeepLinkRouterTests {
|
||||
|
||||
// MARK: - URL 解析:白名单拒绝路径(任一非法 → .ignore)
|
||||
|
||||
@Test("scheme 非 webterminal → .ignore")
|
||||
func wrongSchemeIgnored() throws {
|
||||
/// T-iOS-35 **有意改写**:本例原名"scheme 非 webterminal → .ignore",断言
|
||||
/// http(s) 一律被拒。web 分享 QR 互通落地后 http(s) 有了自己的白名单形状
|
||||
/// (`<origin>/?join=<uuid>`,见 `DeepLinkJoinTests`),所以那条断言的**理由**
|
||||
/// 变了:这里的 URL 仍 `.ignore`,但原因是它带了 `host=` 这个多余 query 键
|
||||
/// (web 形状精确只允许单一 `join` 键),而不是"scheme 不对"。
|
||||
/// scheme 白名单本身改由下一例(非 http/https/webterminal)继续钉住。
|
||||
@Test("https 但不是 web 分享形状(多余 query 键)→ .ignore")
|
||||
func httpsWithExtraQueryKeysIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("白名单外的 scheme(ftp/file/javascript)→ .ignore")
|
||||
func nonWhitelistedSchemeIgnored() throws {
|
||||
for scheme in ["ftp", "file", "javascript"] {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("\(scheme)://open?join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore, "\(scheme) 不该被接受")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("action 非 open → .ignore")
|
||||
func wrongActionIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
|
||||
@@ -44,11 +44,21 @@ struct DesignSystemTests {
|
||||
#expect(!approxEqual(waiting, stuck))
|
||||
}
|
||||
|
||||
@Test("semantic status colors match the desktop/web status palette")
|
||||
/// T-iOS-34 **有意改写**:这三个 token 从"单值"变成了 scheme-adaptive
|
||||
/// (深色 = web 原值不变,浅色 = 新增的可读变体,见 `Tokens.swift`)。原用例
|
||||
/// 用 `UIColor(color).getRed(...)` 隐式按**测试进程当前 trait**(浅色)解析,
|
||||
/// 于是量到了新的浅色值。断言的意图没变 —— "深色档必须逐字节等于桌面 web 的
|
||||
/// 状态色" —— 只是现在必须**显式指定深色 trait** 才能表达这个意图。
|
||||
/// 浅色档的取值与对比度门槛由 `AppThemeTests` 覆盖。
|
||||
@Test("semantic status colors match the desktop/web status palette (dark scheme)")
|
||||
func statusColorsMatchDirection() {
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.working).color, r: 70, g: 208, b: 127) // #46D07F web --green
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, r: 245, g: 177, b: 76) // #F5B14C web --amber
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, r: 255, g: 107, b: 107) // #FF6B6B web --red
|
||||
let dark = UITraitCollection(userInterfaceStyle: .dark)
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.working).color, in: dark,
|
||||
r: 70, g: 208, b: 127) // #46D07F web --green
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, in: dark,
|
||||
r: 245, g: 177, b: 76) // #F5B14C web --amber
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, in: dark,
|
||||
r: 255, g: 107, b: 107) // #FF6B6B web --red
|
||||
}
|
||||
|
||||
@Test("the wire ClaudeStatus bridge covers all five cases")
|
||||
@@ -150,6 +160,13 @@ struct DesignSystemTests {
|
||||
assertRGBA(rgba(color), r: r, g: g, b: b)
|
||||
}
|
||||
|
||||
/// Same, but resolving an adaptive token in an explicit scheme.
|
||||
private func assertColor(
|
||||
_ color: Color, in traits: UITraitCollection, r: Int, g: Int, b: Int
|
||||
) {
|
||||
assertRGBA(rgba(UIColor(color).resolvedColor(with: traits)), r: r, g: g, b: b)
|
||||
}
|
||||
|
||||
private func assertRGBA(_ value: RGBA, r: Int, g: Int, b: Int, tolerance: CGFloat = 0.02) {
|
||||
#expect(abs(value.r - CGFloat(r) / 255) < tolerance)
|
||||
#expect(abs(value.g - CGFloat(g) / 255) < tolerance)
|
||||
|
||||
177
ios/App/WebTermTests/DynamicTypeLayoutTests.swift
Normal file
177
ios/App/WebTermTests/DynamicTypeLayoutTests.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-34 · Dynamic Type 不破版(doc §4 F 组,条目 25–29)。
|
||||
///
|
||||
/// 验收口径是"最大字号不破版",所以断言必须是**量出来的**,不是"看着还行":
|
||||
/// 每个受测视图套 `UIHostingController` 在一个 320pt 宽(iPhone SE / 最窄
|
||||
/// 现役机型)的提议里量 `sizeThatFits`,再按两条规则判定:
|
||||
/// 1. **不横向溢出** —— 返回宽度 ≤ 提议宽度(超出即会被裁/顶出屏幕);
|
||||
/// 2. **长高而不是被裁** —— AX 档高度必须 > 标准档高度(固定高容器会让它相等)。
|
||||
///
|
||||
/// 密集数字(`TelemetryChip` / `dsMetaText`)另走**夹取**策略:表格数字放大到
|
||||
/// AX5 会把一行元信息撑成三行,故 DS 在 `DS.Typography.numericClamp` 处统一封顶,
|
||||
/// 测试用"AX2 与 AX5 量出同一高度"来钉死夹取真的生效(而不是只写了个注释)。
|
||||
@MainActor
|
||||
@Suite("DynamicTypeLayout")
|
||||
struct DynamicTypeLayoutTests {
|
||||
|
||||
// MARK: - F25 · GateBanner(最关键的一屏:批准/拒绝 shell 命令)
|
||||
|
||||
@Test("GateBanner 在 AX5 下不横向溢出,且相比标准档是长高而非被裁")
|
||||
func gateBannerSurvivesLargestType() {
|
||||
let gate = GateState(kind: .tool, detail: "Bash(rm -rf ./build)", epoch: 1)
|
||||
let banner = GateBanner(gate: gate) { _, _ in }
|
||||
|
||||
let standard = LayoutProbe.fit(banner, size: .large)
|
||||
let largest = LayoutProbe.fit(banner, size: .accessibility5)
|
||||
|
||||
#expect(largest.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
|
||||
#expect(largest.height.isFinite)
|
||||
#expect(largest.height > standard.height)
|
||||
}
|
||||
|
||||
// MARK: - F26/F27 · 密集数字:夹取生效
|
||||
|
||||
@Test("numericClamp 策略:封顶在 accessibility2,严格小于 accessibility5")
|
||||
func numericClampPolicy() {
|
||||
#expect(DS.Typography.numericClamp == .accessibility2)
|
||||
#expect(DS.Typography.numericClamp < .accessibility5)
|
||||
}
|
||||
|
||||
@Test("TelemetryChip:AX2 与 AX5 同高(夹取生效),且不横向溢出")
|
||||
func telemetryChipIsClamped() {
|
||||
let chip = TelemetryChip(systemImage: "cpu", text: "ctx 92% · $0.1234")
|
||||
|
||||
let clampEdge = LayoutProbe.fit(chip, size: DS.Typography.numericClamp)
|
||||
let largest = LayoutProbe.fit(chip, size: .accessibility5)
|
||||
|
||||
#expect(largest.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
|
||||
#expect(abs(largest.height - clampEdge.height) < LayoutProbe.epsilon)
|
||||
}
|
||||
|
||||
@Test("TelemetryChip 在夹取上限之前照常放大(不是把字号焊死)")
|
||||
func telemetryChipStillScalesBelowClamp() {
|
||||
let chip = TelemetryChip(text: "161×50")
|
||||
|
||||
let standard = LayoutProbe.fit(chip, size: .large)
|
||||
let clampEdge = LayoutProbe.fit(chip, size: DS.Typography.numericClamp)
|
||||
|
||||
#expect(clampEdge.height > standard.height)
|
||||
}
|
||||
|
||||
@Test("dsMetaText 元信息行走同一夹取(单一出处,非逐屏各写一遍)")
|
||||
func metaTextIsClamped() {
|
||||
let meta = Text(verbatim: "2 台设备在看 · 161×50").dsMetaText()
|
||||
|
||||
let clampEdge = LayoutProbe.fit(meta, size: DS.Typography.numericClamp)
|
||||
let largest = LayoutProbe.fit(meta, size: .accessibility5)
|
||||
|
||||
#expect(abs(largest.height - clampEdge.height) < LayoutProbe.epsilon)
|
||||
}
|
||||
|
||||
// MARK: - F28 · DSButtonStyle(gate 的两个半宽按钮是最窄的按钮场景)
|
||||
|
||||
@Test("DSButtonStyle 在 AX5 半宽容器里仍 ≥ 44pt 高且不横向溢出")
|
||||
func buttonStyleSurvivesLargestType() {
|
||||
let button = Button(GateChoiceSpec.label(for: .approve)) {}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
let halfWidth = LayoutProbe.phoneWidth / 2
|
||||
|
||||
let standard = LayoutProbe.fit(button, width: halfWidth, size: .large)
|
||||
let largest = LayoutProbe.fit(button, width: halfWidth, size: .accessibility5)
|
||||
|
||||
#expect(largest.width <= halfWidth + LayoutProbe.epsilon)
|
||||
#expect(largest.height >= DS.Layout.minHitTarget)
|
||||
// `minHeight`(而非 `height`)才让换行后的标签长高而不是被裁到 44pt。
|
||||
#expect(largest.height > standard.height)
|
||||
}
|
||||
|
||||
// MARK: - 新增设置页自身也过一遍 AX5
|
||||
|
||||
@Test("SettingsScreen 在 AX5 下可渲染且不横向溢出")
|
||||
func settingsScreenSurvivesLargestType() {
|
||||
let screen = SettingsScreen(themeStore: ThemeStore(defaults: FakeThemeDefaults()))
|
||||
|
||||
let size = LayoutProbe.fit(NavigationStack { screen }, size: .accessibility5)
|
||||
|
||||
#expect(size.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
|
||||
#expect(size.height.isFinite)
|
||||
}
|
||||
|
||||
// MARK: - F29 · KeyBar(UIKit,固定条高 vs 放大的键帽)—— 已知缺口
|
||||
|
||||
/// 量到的事实(iPhone 16 Pro 模拟器):AX5 下一个键帽需要
|
||||
/// **108.24pt**(footnote 行高 52.51 + caption2 行高 47.73 + 上下 4+4 内衬),
|
||||
/// 而 `KeyBarMetrics.barHeight` 是**固定 52pt**(44 + 8)——超过一半被裁。
|
||||
///
|
||||
/// 修法(一行):`barHeight` 改为随 Dynamic Type 缩放,例如
|
||||
/// `UIFontMetrics(forTextStyle: .footnote).scaledValue(for: DS.Layout.minHitTarget + DS.Space.sm8)`,
|
||||
/// 并让 `intrinsicContentSize` 用同一个值。
|
||||
/// **`Components/KeyBar.swift` 不在 C4 的 Owns 里**,故这里只用
|
||||
/// `withKnownIssue` 把缺口钉成机器可见的记录:修好之后本用例会报
|
||||
/// "known issue was not recorded",提醒把这个标记删掉。
|
||||
@Test("KeyBar 键帽在 AX5 下超出固定条高(已知缺口:KeyBar.swift 属他人 Owns)")
|
||||
func keyBarKeycapExceedsBarHeightAtLargestType() {
|
||||
withKnownIssue("KeyBarMetrics.barHeight 固定 52pt,AX5 键帽需约 108pt → 裁切") {
|
||||
let requirement = KeyBarProbe.largestTypeRequirement()
|
||||
#expect(
|
||||
requirement.needed <= requirement.barHeight,
|
||||
"AX5 键帽需 \(requirement.needed)pt,条高只有 \(requirement.barHeight)pt"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Probes
|
||||
|
||||
/// SwiftUI 度量:把视图放进 `UIHostingController`,在给定宽度的提议下问它
|
||||
/// `sizeThatFits`。不套 `.frame(width:)` —— 那会把返回宽度强行钉成提议宽度,
|
||||
/// 正好掩盖我们要抓的横向溢出。
|
||||
@MainActor
|
||||
enum LayoutProbe {
|
||||
/// 最窄现役 iPhone 逻辑宽度(SE 系)。
|
||||
static let phoneWidth: CGFloat = 320
|
||||
/// 浮点度量容差。
|
||||
static let epsilon: CGFloat = 0.5
|
||||
|
||||
static func fit<V: View>(
|
||||
_ view: V, width: CGFloat = phoneWidth, size: DynamicTypeSize
|
||||
) -> CGSize {
|
||||
let host = UIHostingController(rootView: view.dynamicTypeSize(size))
|
||||
host.view.layoutIfNeeded()
|
||||
return host.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
|
||||
}
|
||||
}
|
||||
|
||||
/// UIKit 度量:键帽在 AX5 下**需要**多高,对比键栏**给**多高。
|
||||
///
|
||||
/// 为什么不直接把 `KeyBarView` 构建在 AX5 trait 里量:`KeyBarView` 的字号来自
|
||||
/// `UIFont.preferredFont(forTextStyle:)`(无 `compatibleWith:`),它读的是
|
||||
/// **App 级** `preferredContentSizeCategory`,**不看** `UITraitCollection.current`
|
||||
/// —— 所以 `performAsCurrent { KeyBarView() }` 量出来的是默认字号(实测 38pt),
|
||||
/// 是个会"永远通过"的假绿。改为按 `compatibleWith:` 取真实 AX5 行高来算需求,
|
||||
/// 与键帽的两行(glyph + caption)+ 上下内衬一致。
|
||||
@MainActor
|
||||
enum KeyBarProbe {
|
||||
/// AX5 内容尺寸类别。
|
||||
private static let largestCategory = UIContentSizeCategory
|
||||
.accessibilityExtraExtraExtraLarge
|
||||
|
||||
/// - Returns: (键栏给的高度, AX5 下键帽两行所需高度)
|
||||
static func largestTypeRequirement() -> (barHeight: CGFloat, needed: CGFloat) {
|
||||
let traits = UITraitCollection(preferredContentSizeCategory: largestCategory)
|
||||
let glyph = UIFont.preferredFont(forTextStyle: .footnote, compatibleWith: traits)
|
||||
let caption = UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits)
|
||||
// KeyBarMetrics.buttonInsets 的上下各 xs4。
|
||||
let verticalInsets = DS.Space.xs4 * 2
|
||||
return (
|
||||
barHeight: KeyBarView().intrinsicContentSize.height,
|
||||
needed: glyph.lineHeight + caption.lineHeight + verticalInsets
|
||||
)
|
||||
}
|
||||
}
|
||||
224
ios/App/WebTermTests/GitPanelPresentationTests.swift
Normal file
224
ios/App/WebTermTests/GitPanelPresentationTests.swift
Normal file
@@ -0,0 +1,224 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// C2 · 纯呈现层(同步带 / 未推送边界 / 相对时间 / 写失败文案)。
|
||||
///
|
||||
/// 真源 `docs/plans/w6-project-git-panel.md` 与 web 参照实现
|
||||
/// `public/projects.ts:615-745 makeSyncBand` —— 本套用例逐条钉住那份"只有一种
|
||||
/// 状态可以显示为绿色"的规则(RED 清单 36–47,见 docs/plans/ios-completion.md §4)。
|
||||
@Suite("GitPanelPresentation")
|
||||
struct GitPanelPresentationTests {
|
||||
/// 固定"现在":2026-07-30T12:00:00Z 的毫秒值,避免用例依赖真实时钟。
|
||||
private static let nowMs: Double = 1_785_412_800_000
|
||||
|
||||
private static func minutesAgo(_ minutes: Double) -> Double {
|
||||
nowMs - minutes * 60 * 1000
|
||||
}
|
||||
|
||||
// MARK: - 同步带(RED 36–43)
|
||||
|
||||
@Test("非 git 目录(sync == nil)→ 无同步带")
|
||||
func nonGitHasNoBand() {
|
||||
#expect(GitSyncBand.make(sync: nil, dirtyCount: nil, nowMs: Self.nowMs) == nil)
|
||||
}
|
||||
|
||||
@Test("↑0 ↓0 + 新鲜 fetch → 唯一允许的「已同步」绿色态")
|
||||
func inSyncNeedsFreshFetch() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(
|
||||
upstream: "origin/develop", ahead: 0, behind: 0,
|
||||
lastFetchMs: Self.minutesAgo(5)
|
||||
),
|
||||
dirtyCount: 0, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(band.isInSync)
|
||||
#expect(band.isBehindVerified)
|
||||
#expect(band.canFetch)
|
||||
#expect(band.head == .tracking(
|
||||
upstream: "origin/develop", ahead: 0, behind: 0
|
||||
))
|
||||
}
|
||||
|
||||
@Test("↑0 ↓0 但 fetch 已过期(> FETCH_STALE_MS)→ 不绿,↓ 未核实")
|
||||
func staleFetchIsNeverGreen() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(
|
||||
upstream: "origin/develop", ahead: 0, behind: 0,
|
||||
lastFetchMs: Self.minutesAgo(61)
|
||||
),
|
||||
dirtyCount: 0, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(!band.isInSync)
|
||||
#expect(!band.isBehindVerified)
|
||||
}
|
||||
|
||||
@Test("FETCH_STALE_MS 就是 1 小时(镜像 public/projects.ts:615)")
|
||||
func staleThresholdMatchesWeb() {
|
||||
#expect(GitSyncBand.fetchStaleMs == 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
@Test("从未 fetch(lastFetchMs == nil)→ 视为过期,不绿")
|
||||
func neverFetchedIsStale() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(upstream: "origin/develop", ahead: 0, behind: 0, lastFetchMs: nil),
|
||||
dirtyCount: 0, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(!band.isInSync)
|
||||
#expect(!band.isBehindVerified)
|
||||
}
|
||||
|
||||
@Test("无上游 → 「无上游」,没有 ↑↓,绝不绿(最易犯的 bug)")
|
||||
func noUpstreamIsNeverGreen() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: Self.nowMs),
|
||||
dirtyCount: 0, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(band.head == .noUpstream)
|
||||
#expect(!band.isInSync)
|
||||
#expect(band.canFetch)
|
||||
}
|
||||
|
||||
@Test("分离 HEAD → 「分离 HEAD」,fetch 禁用,无 ↑↓")
|
||||
func detachedDisablesFetch() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: nil, detached: true),
|
||||
dirtyCount: nil, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(band.head == .detached)
|
||||
#expect(!band.canFetch)
|
||||
#expect(!band.isInSync)
|
||||
}
|
||||
|
||||
@Test("ahead 读不到(nil)→ 保留 nil(渲染 ↑ —),且不绿")
|
||||
func unknownAheadIsNotZero() throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(
|
||||
upstream: "origin/develop", ahead: nil, behind: 0,
|
||||
lastFetchMs: Self.minutesAgo(1)
|
||||
),
|
||||
dirtyCount: 0, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(band.head == .tracking(upstream: "origin/develop", ahead: nil, behind: 0))
|
||||
#expect(!band.isInSync)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"dirtyCount:nil → 未检查(不是 clean)、0 → clean、3 → changed(3)",
|
||||
arguments: [
|
||||
(Int?.none, GitSyncBand.Dirty.unknown),
|
||||
(Int?(0), GitSyncBand.Dirty.clean),
|
||||
(Int?(3), GitSyncBand.Dirty.changed(3)),
|
||||
] as [(Int?, GitSyncBand.Dirty)]
|
||||
)
|
||||
func dirtyTriState(dirtyCount: Int?, expected: GitSyncBand.Dirty) throws {
|
||||
let band = try #require(GitSyncBand.make(
|
||||
sync: SyncState(upstream: "origin/x", ahead: 0, behind: 0, lastFetchMs: Self.nowMs),
|
||||
dirtyCount: dirtyCount, nowMs: Self.nowMs
|
||||
))
|
||||
|
||||
#expect(band.dirty == expected)
|
||||
}
|
||||
|
||||
// MARK: - 未推送边界(RED 44–46)
|
||||
|
||||
@Test("边界画在最后一个 unpushed 之后,且只画一次")
|
||||
func boundaryAfterLastUnpushed() {
|
||||
let commits = [
|
||||
CommitLogEntry(hash: "aaa", at: 3, subject: "c", unpushed: true),
|
||||
CommitLogEntry(hash: "bbb", at: 2, subject: "b", unpushed: true),
|
||||
CommitLogEntry(hash: "ccc", at: 1, subject: "a", unpushed: false),
|
||||
]
|
||||
|
||||
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
|
||||
}
|
||||
|
||||
@Test("unpushed 只在严格 true 时生效(nil ≠ false ≠ true)")
|
||||
func nilUnpushedDrawsNoBoundary() {
|
||||
let commits = [
|
||||
CommitLogEntry(hash: "aaa", at: 2, subject: "c", unpushed: nil),
|
||||
CommitLogEntry(hash: "bbb", at: 1, subject: "b", unpushed: false),
|
||||
]
|
||||
|
||||
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == nil)
|
||||
}
|
||||
|
||||
@Test("无上游 → 完全不画边界(没有可比对的东西)")
|
||||
func noUpstreamDrawsNoBoundary() {
|
||||
let commits = [CommitLogEntry(hash: "aaa", at: 1, subject: "c", unpushed: true)]
|
||||
|
||||
#expect(GitLogBoundary.index(commits: commits, upstream: nil) == nil)
|
||||
}
|
||||
|
||||
@Test("未推送提交排在已推送之下(老日期 merge)→ 边界仍在最后一个 unpushed 之后")
|
||||
func interleavedUnpushedStillBounds() {
|
||||
let commits = [
|
||||
CommitLogEntry(hash: "aaa", at: 5, subject: "new pushed", unpushed: false),
|
||||
CommitLogEntry(hash: "bbb", at: 4, subject: "merge of old branch", unpushed: true),
|
||||
CommitLogEntry(hash: "ccc", at: 3, subject: "older pushed", unpushed: false),
|
||||
]
|
||||
|
||||
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
|
||||
}
|
||||
|
||||
// MARK: - 相对时间
|
||||
|
||||
@Test(
|
||||
"相对时间:秒/分/时/天各档非空且互不相同",
|
||||
arguments: [0.5, 5, 90, 60 * 26] as [Double]
|
||||
)
|
||||
func relativeTimeBuckets(minutes: Double) {
|
||||
let label = GitTimeFormat.relative(fromMs: Self.minutesAgo(minutes), nowMs: Self.nowMs)
|
||||
|
||||
#expect(!label.isEmpty)
|
||||
}
|
||||
|
||||
@Test("未来时间戳(主机时钟漂移)→ 退化为「刚刚」,不出现负数")
|
||||
func futureTimestampDegrades() {
|
||||
let label = GitTimeFormat.relative(fromMs: Self.nowMs + 60_000, nowMs: Self.nowMs)
|
||||
|
||||
#expect(!label.contains("-"))
|
||||
}
|
||||
|
||||
// MARK: - 写失败文案(RED 47)
|
||||
|
||||
@Test("服务器安全文案原样显示(绝不吞、绝不自造)")
|
||||
func serverMessageIsSurfacedVerbatim() {
|
||||
let text = GitWriteFeedback.message(
|
||||
status: 409, serverMessage: "Nothing staged to commit.", fallback: "兜底"
|
||||
)
|
||||
|
||||
#expect(text.contains("Nothing staged to commit."))
|
||||
}
|
||||
|
||||
@Test("服务器没给 message → 兜底中文文案(非空)")
|
||||
func missingServerMessageFallsBack() {
|
||||
let text = GitWriteFeedback.message(status: 500, serverMessage: nil, fallback: "提交失败")
|
||||
|
||||
#expect(!text.isEmpty)
|
||||
#expect(text.contains("提交失败"))
|
||||
}
|
||||
|
||||
@Test("抛出的 APIClientError → 其自带话术(.unauthorized 引导补令牌)")
|
||||
func thrownAPIErrorUsesItsOwnCopy() {
|
||||
let text = GitWriteFeedback.message(for: APIClientError.unauthorized, fallback: "兜底")
|
||||
|
||||
#expect(text == APIClientError.unauthorized.message)
|
||||
}
|
||||
|
||||
@Test("非 APIClientError(传输层)→ 兜底文案,不 crash")
|
||||
func transportErrorFallsBack() {
|
||||
let text = GitWriteFeedback.message(
|
||||
for: URLError(.notConnectedToInternet), fallback: "推送失败"
|
||||
)
|
||||
|
||||
#expect(text.contains("推送失败"))
|
||||
}
|
||||
}
|
||||
328
ios/App/WebTermTests/GitPanelViewModelTests.swift
Normal file
328
ios/App/WebTermTests/GitPanelViewModelTests.swift
Normal file
@@ -0,0 +1,328 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// C2 · git 面板 VM(RED 清单 47–51 + 分区独立降级)。
|
||||
///
|
||||
/// 依赖全部以闭包注入(与 ProjectDetailViewModel/DiffViewModel 同一纪律)——
|
||||
/// 路由构建/状态码映射已在 APIClient 包内测过,本套只测 VM 归约:
|
||||
/// 服务器安全文案原样上浮、写操作忙态去重、每个分区独立失败不拖垮整屏。
|
||||
@MainActor
|
||||
@Suite("GitPanelViewModel")
|
||||
struct GitPanelViewModelTests {
|
||||
private nonisolated static let path = "/repos/web-terminal"
|
||||
private nonisolated static let nowMs: Double = 1_785_412_800_000
|
||||
|
||||
private nonisolated static func detail(
|
||||
sync: SyncState? = SyncState(
|
||||
upstream: "origin/develop", ahead: 2, behind: 0, lastFetchMs: 1_785_412_500_000
|
||||
),
|
||||
dirtyCount: Int? = 3
|
||||
) -> ProjectDetail {
|
||||
ProjectDetail(
|
||||
name: "web-terminal", path: path, isGit: true, branch: "develop", dirty: true,
|
||||
worktrees: [], sessions: [], hasClaudeMd: false, claudeMd: nil,
|
||||
dirtyCount: dirtyCount, sync: sync
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 加载与分区降级
|
||||
|
||||
@Test("load 成功 → 同步带/分支/日志/改动列表就位")
|
||||
func loadPopulatesEverySection() async {
|
||||
let vm = Self.makeViewModel(
|
||||
log: { GitLogResult(commits: [
|
||||
CommitLogEntry(hash: "abc1234", at: 1_785_412_000_000, subject: "feat: x", unpushed: true),
|
||||
], truncated: false, upstream: "origin/develop") },
|
||||
changes: { staged in
|
||||
staged ? [] : [Self.changedFile("src/a.ts")]
|
||||
}
|
||||
)
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.isLoaded)
|
||||
#expect(vm.branch == "develop")
|
||||
#expect(vm.band?.head == .tracking(upstream: "origin/develop", ahead: 2, behind: 0))
|
||||
#expect(vm.band?.dirty == .changed(3))
|
||||
#expect(vm.log?.commits.count == 1)
|
||||
#expect(vm.unstaged.map(\.displayPath) == ["src/a.ts"])
|
||||
#expect(vm.staged.isEmpty)
|
||||
}
|
||||
|
||||
@Test("日志失败不拖垮面板:其余分区照常,日志区显示自己的错误")
|
||||
func logFailureIsContained() async {
|
||||
let vm = Self.makeViewModel(log: { throw APIClientError.gitDataUnavailable })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.isLoaded)
|
||||
#expect(vm.band != nil)
|
||||
#expect(vm.logErrorMessage == APIClientError.gitDataUnavailable.message)
|
||||
}
|
||||
|
||||
@Test("详情失败 → 状态区错误 + 无同步带(绝不编造 ↑↓)")
|
||||
func detailFailureLeavesNoBand() async {
|
||||
let vm = Self.makeViewModel(detail: { throw APIClientError.projectNotFound })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.band == nil)
|
||||
#expect(vm.stateErrorMessage == APIClientError.projectNotFound.message)
|
||||
}
|
||||
|
||||
@Test("PR 单独加载(gh 是一次外网调用,不阻塞面板首屏)")
|
||||
func prLoadsSeparately() async {
|
||||
let vm = Self.makeViewModel(pr: { PrStatus(availability: .ok, number: 7, title: "t") })
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.pr == nil)
|
||||
|
||||
await vm.loadPullRequest()
|
||||
#expect(vm.pr?.number == 7)
|
||||
}
|
||||
|
||||
@Test("gh 未安装/未登录是 200 + 非 ok availability,不是错误")
|
||||
func degradedGhIsNotAnError() async {
|
||||
let vm = Self.makeViewModel(pr: { PrStatus(availability: .notInstalled) })
|
||||
|
||||
await vm.loadPullRequest()
|
||||
|
||||
#expect(vm.pr?.availability == .notInstalled)
|
||||
#expect(vm.errorMessage == nil)
|
||||
}
|
||||
|
||||
// MARK: - 写操作文案(47–49)
|
||||
|
||||
@Test("stage 被拒 → 服务器安全文案原样显示")
|
||||
func stageRejectionSurfacesServerMessage() async {
|
||||
let vm = Self.makeViewModel(
|
||||
stage: { _, _ in .rejected(status: 403, message: "Git operations are disabled.") }
|
||||
)
|
||||
|
||||
await vm.setStaged(Self.changedFile("src/a.ts"), staged: true)
|
||||
|
||||
#expect(vm.errorMessage == "Git operations are disabled.")
|
||||
}
|
||||
|
||||
@Test("commit 成功 → 清空输入框 + 短 sha 提示")
|
||||
func commitSuccessClearsDraft() async {
|
||||
let vm = Self.makeViewModel(commit: { _ in .ok(CommitResult(commit: "abc1234")) })
|
||||
vm.commitMessage = "fix: 修一下"
|
||||
|
||||
await vm.commit()
|
||||
|
||||
#expect(vm.commitMessage.isEmpty)
|
||||
#expect(vm.noticeMessage?.contains("abc1234") == true)
|
||||
#expect(vm.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test("commit 409(无暂存)→ 保留输入框内容,显示服务器文案")
|
||||
func commitConflictKeepsDraft() async {
|
||||
let vm = Self.makeViewModel(
|
||||
commit: { _ in .rejected(status: 409, message: "Nothing staged to commit.") }
|
||||
)
|
||||
vm.commitMessage = "fix: 修一下"
|
||||
|
||||
await vm.commit()
|
||||
|
||||
#expect(vm.commitMessage == "fix: 修一下")
|
||||
#expect(vm.errorMessage == "Nothing staged to commit.")
|
||||
}
|
||||
|
||||
@Test("空提交信息 → 一次请求都不发")
|
||||
func blankCommitMessageSkipsNetwork() async {
|
||||
let spy = GitPanelSpy()
|
||||
let vm = Self.makeViewModel(spy: spy)
|
||||
vm.commitMessage = " "
|
||||
|
||||
await vm.commit()
|
||||
|
||||
#expect(await spy.commitCalls == 0)
|
||||
#expect(vm.errorMessage == GitPanelCopy.commitMessageRequired)
|
||||
}
|
||||
|
||||
@Test("push 401 是主机 git 凭据问题 → 用服务器文案,不与访问令牌 401 混淆")
|
||||
func pushAuthIsNotTheAccessTokenGate() async {
|
||||
let vm = Self.makeViewModel(
|
||||
push: { .rejected(status: 401, message: "Push authentication required on the host.") }
|
||||
)
|
||||
|
||||
await vm.push()
|
||||
|
||||
#expect(vm.errorMessage == "Push authentication required on the host.")
|
||||
#expect(vm.errorMessage != APIClientError.unauthorized.message)
|
||||
}
|
||||
|
||||
@Test("429 → 限流文案,且不自动重试")
|
||||
func rateLimitedIsNotRetried() async {
|
||||
let spy = GitPanelSpy()
|
||||
let vm = Self.makeViewModel(spy: spy, push: { .rateLimited })
|
||||
|
||||
await vm.push()
|
||||
|
||||
#expect(await spy.pushCalls == 1)
|
||||
#expect(vm.errorMessage == GitWriteFeedback.rateLimited)
|
||||
}
|
||||
|
||||
@Test("fetch 成功 → 重新读取详情(lastFetchMs 由服务器给,客户端绝不自己往前拨)")
|
||||
func fetchReloadsStateFromServer() async {
|
||||
let spy = GitPanelSpy()
|
||||
let vm = Self.makeViewModel(spy: spy, fetchRemote: { .ok(FetchResult(remote: "origin", lastFetchMs: Self.nowMs)) })
|
||||
|
||||
await vm.load()
|
||||
let loadsAfterFirstLoad = await spy.detailCalls
|
||||
await vm.fetchRemote()
|
||||
|
||||
#expect(await spy.detailCalls == loadsAfterFirstLoad + 1)
|
||||
}
|
||||
|
||||
// MARK: - 忙态去重(50)
|
||||
|
||||
@Test("写操作进行中重复点击 → 只发一次请求")
|
||||
func busyDeduplicatesWrites() async {
|
||||
let gate = GitPanelGate()
|
||||
let spy = GitPanelSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
push: {
|
||||
await gate.wait()
|
||||
return .ok(PushResult(branch: "develop", remote: "origin"))
|
||||
}
|
||||
)
|
||||
|
||||
let first = Task { await vm.push() }
|
||||
await Self.spin(until: { vm.busy == .push })
|
||||
await vm.push()
|
||||
#expect(await spy.pushCalls == 1)
|
||||
|
||||
await gate.release()
|
||||
await first.value
|
||||
#expect(vm.busy == nil)
|
||||
}
|
||||
|
||||
// MARK: - 重命名文件的暂存路径(51)
|
||||
|
||||
@Test("重命名 → 同时送 oldPath 与 newPath(否则删除侧不会被暂存)")
|
||||
func renameStagesBothPaths() {
|
||||
let file = DiffFile(
|
||||
oldPath: "src/old.ts", newPath: "src/new.ts", status: .renamed,
|
||||
added: 1, removed: 1, binary: false, hunks: []
|
||||
)
|
||||
|
||||
let changed = GitPanelViewModel.ChangedFile.make(from: file)
|
||||
|
||||
#expect(changed.stagePaths == ["src/old.ts", "src/new.ts"])
|
||||
#expect(changed.displayPath.contains("src/new.ts"))
|
||||
}
|
||||
|
||||
@Test("普通修改 → 只送 newPath")
|
||||
func modifiedStagesOnePath() {
|
||||
let file = DiffFile(
|
||||
oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified,
|
||||
added: 2, removed: 0, binary: false, hunks: []
|
||||
)
|
||||
|
||||
let changed = GitPanelViewModel.ChangedFile.make(from: file)
|
||||
|
||||
#expect(changed.stagePaths == ["src/a.ts"])
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private nonisolated static func changedFile(_ path: String) -> GitPanelViewModel.ChangedFile {
|
||||
GitPanelViewModel.ChangedFile(
|
||||
displayPath: path, stagePaths: [path], status: .modified, added: 1, removed: 0
|
||||
)
|
||||
}
|
||||
|
||||
private static func makeViewModel(
|
||||
spy: GitPanelSpy = GitPanelSpy(),
|
||||
detail: (@Sendable () async throws -> ProjectDetail)? = nil,
|
||||
log: (@Sendable () async throws -> GitLogResult)? = nil,
|
||||
pr: (@Sendable () async throws -> PrStatus)? = nil,
|
||||
changes: (@Sendable (Bool) async throws -> [GitPanelViewModel.ChangedFile])? = nil,
|
||||
stage: (@Sendable ([String], Bool) async throws -> GitWriteOutcome<StageResult>)? = nil,
|
||||
commit: (@Sendable (String) async throws -> GitWriteOutcome<CommitResult>)? = nil,
|
||||
push: (@Sendable () async throws -> GitWriteOutcome<PushResult>)? = nil,
|
||||
fetchRemote: (@Sendable () async throws -> GitWriteOutcome<FetchResult>)? = nil
|
||||
) -> GitPanelViewModel {
|
||||
GitPanelViewModel(
|
||||
path: path,
|
||||
dependencies: GitPanelViewModel.Dependencies(
|
||||
detail: {
|
||||
await spy.recordDetail()
|
||||
if let detail { return try await detail() }
|
||||
return Self.detail()
|
||||
},
|
||||
log: {
|
||||
if let log { return try await log() }
|
||||
return GitLogResult(commits: [], truncated: false, upstream: "origin/develop")
|
||||
},
|
||||
pr: {
|
||||
if let pr { return try await pr() }
|
||||
return PrStatus(availability: .noPr)
|
||||
},
|
||||
changes: { staged in
|
||||
if let changes { return try await changes(staged) }
|
||||
return []
|
||||
},
|
||||
stage: { files, isStaging in
|
||||
await spy.recordStage()
|
||||
if let stage { return try await stage(files, isStaging) }
|
||||
return .ok(StageResult(staged: isStaging, count: files.count))
|
||||
},
|
||||
commit: { message in
|
||||
await spy.recordCommit()
|
||||
if let commit { return try await commit(message) }
|
||||
return .ok(CommitResult(commit: "abc1234"))
|
||||
},
|
||||
push: {
|
||||
await spy.recordPush()
|
||||
if let push { return try await push() }
|
||||
return .ok(PushResult(branch: "develop", remote: "origin"))
|
||||
},
|
||||
fetchRemote: {
|
||||
if let fetchRemote { return try await fetchRemote() }
|
||||
return .ok(FetchResult(remote: "origin", lastFetchMs: nowMs))
|
||||
},
|
||||
nowMs: { nowMs }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func spin(
|
||||
until condition: @MainActor () -> Bool, maxYields: Int = 1_000
|
||||
) async {
|
||||
for _ in 0..<maxYields where !condition() {
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor GitPanelSpy {
|
||||
private(set) var detailCalls = 0
|
||||
private(set) var stageCalls = 0
|
||||
private(set) var commitCalls = 0
|
||||
private(set) var pushCalls = 0
|
||||
|
||||
func recordDetail() { detailCalls += 1 }
|
||||
func recordStage() { stageCalls += 1 }
|
||||
func recordCommit() { commitCalls += 1 }
|
||||
func recordPush() { pushCalls += 1 }
|
||||
}
|
||||
|
||||
private actor GitPanelGate {
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func wait() async {
|
||||
await withCheckedContinuation { waiters.append($0) }
|
||||
}
|
||||
|
||||
func release() {
|
||||
let pending = waiters
|
||||
waiters = []
|
||||
pending.forEach { $0.resume() }
|
||||
}
|
||||
}
|
||||
199
ios/App/WebTermTests/HostRemovalTests.swift
Normal file
199
ios/App/WebTermTests/HostRemovalTests.swift
Normal file
@@ -0,0 +1,199 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · 移除主机 — the path `PushRegistrar.handleHostRemoved` never had.
|
||||
///
|
||||
/// Two properties matter beyond "the row disappears":
|
||||
/// 1. the APNs de-registration actually happens, and happens BEFORE the record
|
||||
/// is deleted (the request needs the host's endpoint AND its access token,
|
||||
/// which live in that record);
|
||||
/// 2. no secret survives: the token is a field of the removed record, so the
|
||||
/// same write that drops the host drops the token.
|
||||
@MainActor
|
||||
@Suite("Host removal")
|
||||
struct HostRemovalTests {
|
||||
private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345"
|
||||
|
||||
/// Records the interleaving of the push hook and the store write.
|
||||
private actor Journal {
|
||||
enum Entry: Equatable {
|
||||
case unregistered(UUID)
|
||||
case removed(UUID)
|
||||
}
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
|
||||
func record(_ entry: Entry) {
|
||||
entries = entries + [entry]
|
||||
}
|
||||
}
|
||||
|
||||
/// `InMemoryHostStore` + journalling of `remove`, so ordering is observable.
|
||||
private actor JournallingStore: HostStore {
|
||||
private let base = InMemoryHostStore()
|
||||
private let journal: Journal
|
||||
private let removeFails: Bool
|
||||
|
||||
init(journal: Journal, removeFails: Bool = false) {
|
||||
self.journal = journal
|
||||
self.removeFails = removeFails
|
||||
}
|
||||
|
||||
func loadAll() async throws -> [HostRegistry.Host] { try await base.loadAll() }
|
||||
|
||||
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
|
||||
try await base.upsert(host)
|
||||
}
|
||||
|
||||
func remove(id: UUID) async throws -> [HostRegistry.Host] {
|
||||
await journal.record(.removed(id))
|
||||
if removeFails { throw StoreFailure() }
|
||||
return try await base.remove(id: id)
|
||||
}
|
||||
|
||||
func accessToken(host id: UUID) async throws -> AccessToken? {
|
||||
try await base.accessToken(host: id)
|
||||
}
|
||||
|
||||
func setAccessToken(
|
||||
_ token: AccessToken?, host id: UUID
|
||||
) async throws -> [HostRegistry.Host] {
|
||||
try await base.setAccessToken(token, host: id)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoreFailure: Error {}
|
||||
|
||||
private actor ThrowingHostStore: HostStore {
|
||||
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
|
||||
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
|
||||
throw StoreFailure()
|
||||
}
|
||||
func remove(id: UUID) async throws -> [HostRegistry.Host] { throw StoreFailure() }
|
||||
}
|
||||
|
||||
private func makeViewModel(store: any HostStore, journal: Journal) -> PairingViewModel {
|
||||
PairingViewModel(
|
||||
store: store,
|
||||
probe: PairingViewModel.Probe(
|
||||
verifyHost: { endpoint, _ in .success(endpoint) },
|
||||
unregisterPush: { host in await journal.record(.unregistered(host.id)) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeHost(
|
||||
name: String, port: Int, token: String? = nil
|
||||
) throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: "http://192.168.1.5:\(port)"))
|
||||
return HostRegistry.Host(
|
||||
id: UUID(),
|
||||
name: name,
|
||||
endpoint: try #require(HostEndpoint(baseURL: url)),
|
||||
accessToken: try token.map { try AccessToken(validating: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - The wiring
|
||||
|
||||
@Test("移除主机:先注销该主机上的推送 token,再删记录(顺序是硬要求)")
|
||||
func removalDeregistersPushBeforeDeletingTheRecord() async throws {
|
||||
let journal = Journal()
|
||||
let store = JournallingStore(journal: journal)
|
||||
let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken)
|
||||
_ = try await store.upsert(host)
|
||||
let viewModel = makeViewModel(store: store, journal: journal)
|
||||
await viewModel.loadPairedHosts()
|
||||
|
||||
await viewModel.removeHost(id: host.id)
|
||||
|
||||
#expect(await journal.entries == [.unregistered(host.id), .removed(host.id)])
|
||||
#expect(viewModel.pairedHosts.isEmpty)
|
||||
#expect(try await store.loadAll().isEmpty)
|
||||
}
|
||||
|
||||
@Test("移除主机后本机不留令牌(令牌是记录的一个字段,同一次写入一起消失)")
|
||||
func removalLeavesNoTokenBehind() async throws {
|
||||
let journal = Journal()
|
||||
let store = JournallingStore(journal: journal)
|
||||
let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken)
|
||||
_ = try await store.upsert(host)
|
||||
#expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken)
|
||||
let viewModel = makeViewModel(store: store, journal: journal)
|
||||
await viewModel.loadPairedHosts()
|
||||
|
||||
await viewModel.removeHost(id: host.id)
|
||||
|
||||
#expect(try await store.accessToken(host: host.id) == nil)
|
||||
#expect(try await store.loadAll().allSatisfy { $0.accessToken == nil })
|
||||
}
|
||||
|
||||
@Test("只移除指定主机,其它主机及其令牌不受影响")
|
||||
func removalIsScopedToOneHost() async throws {
|
||||
let journal = Journal()
|
||||
let store = JournallingStore(journal: journal)
|
||||
let doomed = try makeHost(name: "旧 Mac", port: 3000, token: Self.goodToken)
|
||||
let kept = try makeHost(name: "新 Mac", port: 3100, token: Self.goodToken)
|
||||
_ = try await store.upsert(doomed)
|
||||
_ = try await store.upsert(kept)
|
||||
let viewModel = makeViewModel(store: store, journal: journal)
|
||||
await viewModel.loadPairedHosts()
|
||||
|
||||
await viewModel.removeHost(id: doomed.id)
|
||||
|
||||
#expect(viewModel.pairedHosts.map(\.id) == [kept.id])
|
||||
#expect(try await store.accessToken(host: kept.id)?.rawValue == Self.goodToken)
|
||||
#expect(await journal.entries == [.unregistered(doomed.id), .removed(doomed.id)])
|
||||
}
|
||||
|
||||
@Test("未知 id → 完全 no-op(不注销、不写存储)")
|
||||
func unknownIdIsANoOp() async throws {
|
||||
let journal = Journal()
|
||||
let store = JournallingStore(journal: journal)
|
||||
let host = try makeHost(name: "书房 Mac", port: 3000)
|
||||
_ = try await store.upsert(host)
|
||||
let viewModel = makeViewModel(store: store, journal: journal)
|
||||
await viewModel.loadPairedHosts()
|
||||
|
||||
await viewModel.removeHost(id: UUID())
|
||||
|
||||
#expect(await journal.entries.isEmpty)
|
||||
#expect(try await store.loadAll().count == 1)
|
||||
}
|
||||
|
||||
@Test("存储写入失败 → 显式报错(不假装移除成功)")
|
||||
func storeFailureIsSurfaced() async throws {
|
||||
let journal = Journal()
|
||||
let store = JournallingStore(journal: journal, removeFails: true)
|
||||
let host = try makeHost(name: "书房 Mac", port: 3000)
|
||||
_ = try await store.upsert(host)
|
||||
let viewModel = makeViewModel(store: store, journal: journal)
|
||||
await viewModel.loadPairedHosts()
|
||||
|
||||
await viewModel.removeHost(id: host.id)
|
||||
|
||||
#expect(viewModel.hostsErrorMessage == PairingCopy.hostRemoveFailed)
|
||||
#expect(viewModel.pairedHosts.map(\.id) == [host.id]) // list unchanged
|
||||
}
|
||||
|
||||
// MARK: - Manage-section loading
|
||||
|
||||
@Test("加载已配对主机:成功清错,失败给出显式话术")
|
||||
func loadingPairedHostsSurfacesErrors() async throws {
|
||||
let journal = Journal()
|
||||
let failing = makeViewModel(store: ThrowingHostStore(), journal: journal)
|
||||
|
||||
await failing.loadPairedHosts()
|
||||
|
||||
#expect(failing.pairedHosts.isEmpty)
|
||||
#expect(failing.hostsErrorMessage == PairingCopy.hostsLoadFailed)
|
||||
|
||||
let working = makeViewModel(store: InMemoryHostStore(), journal: journal)
|
||||
await working.loadPairedHosts()
|
||||
#expect(working.hostsErrorMessage == nil)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ struct NewSessionInCwdTests {
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: http,
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) },
|
||||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
|
||||
unreadStore: unreadStore
|
||||
)
|
||||
)
|
||||
|
||||
176
ios/App/WebTermTests/PairingProbeUnauthorizedTests.swift
Normal file
176
ios/App/WebTermTests/PairingProbeUnauthorizedTests.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · The pairing probe against a token-gated host, driven through the REAL
|
||||
/// `runPairingProbe` over `FakeHTTPTransport` + `FakeTransport`.
|
||||
///
|
||||
/// Two things are pinned:
|
||||
/// 1. a 401 is no longer misreported as "Origin rejected" — with the token gate
|
||||
/// live, 401 has TWO causes and one status code, so the copy must name both;
|
||||
/// 2. the candidate token really travels: `Cookie: webterm_auth=<t>` on every
|
||||
/// HTTP leg (RO GET and guarded DELETE alike, §1.1 — the cookie is orthogonal
|
||||
/// to `Origin`, which only the DELETE carries).
|
||||
///
|
||||
/// These live in the App test target because APIClient's own test suite is B1's
|
||||
/// file ownership; the probe function under test is the package's public one.
|
||||
@Suite("Pairing probe · 401 and the token cookie")
|
||||
struct PairingProbeUnauthorizedTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
|
||||
private static let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
|
||||
|
||||
private func endpoint() throws -> HostEndpoint {
|
||||
let url = try #require(URL(string: Self.base))
|
||||
return try #require(HostEndpoint(baseURL: url))
|
||||
}
|
||||
|
||||
/// Script the whole happy path: RO list → WS attach → guarded kill.
|
||||
private func scriptHappyPath(
|
||||
http: FakeHTTPTransport, ws: FakeTransport
|
||||
) async throws {
|
||||
await http.queueSuccess(
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||||
body: Data("[]".utf8)
|
||||
)
|
||||
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#)
|
||||
await http.queueSuccess(
|
||||
method: "DELETE",
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")),
|
||||
status: 204
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 401 → both remedies
|
||||
|
||||
@Test("RO 探针收到 401 → 不是「不是 web-terminal」,而是给出令牌+ALLOWED_ORIGINS 两条补救")
|
||||
func readOnly401NamesBothRemedies() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
await http.queueSuccess(
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||||
status: 401,
|
||||
body: Data(#"{"error":"unauthorized"}"#.utf8)
|
||||
)
|
||||
|
||||
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||||
|
||||
guard case .failure(.originRejected(let hint)) = result else {
|
||||
Issue.record("expected originRejected(hint:), got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(hint.contains("401"))
|
||||
#expect(hint.contains("访问令牌"))
|
||||
#expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)"))
|
||||
// The WS leg is never reached, so no PTY is spawned on a host that
|
||||
// refuses us.
|
||||
#expect(await ws.connectAttempts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("WS 升级被拒(无法归类)→ 同一条两因两解的话术")
|
||||
func upgradeRejectionNamesBothRemedies() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
await http.queueSuccess(
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||||
body: Data("[]".utf8)
|
||||
)
|
||||
await ws.scriptConnectFailure()
|
||||
|
||||
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||||
|
||||
guard case .failure(.originRejected(let hint)) = result else {
|
||||
Issue.record("expected originRejected(hint:), got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(hint.contains("访问令牌"))
|
||||
#expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)"))
|
||||
#expect(!hint.contains(":443"))
|
||||
}
|
||||
|
||||
@Test("守卫 DELETE 收到 401(而非 403)→ 同样是两因两解,不报「主机不可达」")
|
||||
func guardedDelete401NamesBothRemedies() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
await http.queueSuccess(
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||||
body: Data("[]".utf8)
|
||||
)
|
||||
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#)
|
||||
await http.queueSuccess(
|
||||
method: "DELETE",
|
||||
url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")),
|
||||
status: 401
|
||||
)
|
||||
|
||||
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||||
|
||||
guard case .failure(.originRejected(let hint)) = result else {
|
||||
Issue.record("expected originRejected(hint:), got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(hint.contains("访问令牌"))
|
||||
}
|
||||
|
||||
// MARK: - The candidate token actually travels
|
||||
|
||||
@Test("带候选令牌的探针:两条 HTTP 腿都带 Cookie: webterm_auth,且只读腿仍不带 Origin")
|
||||
func tokenTravelsOnBothHTTPLegs() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
try await scriptHappyPath(http: http, ws: ws)
|
||||
|
||||
let result = await runPairingProbe(
|
||||
endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token
|
||||
)
|
||||
|
||||
guard case .success = result else {
|
||||
Issue.record("expected success, got \(result)")
|
||||
return
|
||||
}
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.count == 2)
|
||||
for request in requests {
|
||||
#expect(
|
||||
request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)",
|
||||
"every probe leg must carry the token cookie"
|
||||
)
|
||||
}
|
||||
// Origin-iff-G is untouched by the token (§1.1: orthogonal).
|
||||
let readOnly = try #require(requests.first)
|
||||
let guarded = try #require(requests.last)
|
||||
#expect(readOnly.value(forHTTPHeaderField: "Origin") == nil)
|
||||
#expect(guarded.value(forHTTPHeaderField: "Origin") == Self.base)
|
||||
}
|
||||
|
||||
@Test("没有候选令牌 → 一个 Cookie 头都不加(LAN 零配置逐字不变)")
|
||||
func noTokenMeansNoCookieHeader() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
try await scriptHappyPath(http: http, ws: ws)
|
||||
|
||||
_ = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||||
|
||||
for request in await http.recordedRequests {
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("令牌不出现在 URL 里(绝不进查询串/日志)")
|
||||
func tokenNeverAppearsInAURL() async throws {
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
try await scriptHappyPath(http: http, ws: ws)
|
||||
|
||||
_ = await runPairingProbe(
|
||||
endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token
|
||||
)
|
||||
|
||||
for request in await http.recordedRequests {
|
||||
#expect(request.url?.absoluteString.contains(Self.token) == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
430
ios/App/WebTermTests/PairingTokenViewModelTests.swift
Normal file
430
ios/App/WebTermTests/PairingTokenViewModelTests.swift
Normal file
@@ -0,0 +1,430 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · Access-token UX in the pairing flow (ios-completion §1.1).
|
||||
///
|
||||
/// Every branch of the frozen contract is pinned here, because three of the four
|
||||
/// `POST /auth` outcomes are NOT errors and must be told apart:
|
||||
/// 204+Set-Cookie = save it · 204 without = this host has no auth (save nothing)
|
||||
/// · 401 = wrong token · 429 = back off.
|
||||
@MainActor
|
||||
@Suite("Pairing access token")
|
||||
struct PairingTokenViewModelTests {
|
||||
// MARK: - Scripted probe (records what the VM asked for, in order)
|
||||
|
||||
private actor ProbeRecorder {
|
||||
/// `(endpoint, token)` per host-verification call — the token argument is
|
||||
/// what proves the candidate reaches the probe.
|
||||
private(set) var verifyCalls: [(origin: String, token: String?)] = []
|
||||
private(set) var validateCalls: [String] = []
|
||||
private var verifyResults: [Result<HostEndpoint, PairingError>]
|
||||
private var validateResults:
|
||||
[Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure>]
|
||||
|
||||
init(
|
||||
verifyResults: [Result<HostEndpoint, PairingError>] = [],
|
||||
validateResults: [Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure>]
|
||||
= []
|
||||
) {
|
||||
self.verifyResults = verifyResults
|
||||
self.validateResults = validateResults
|
||||
}
|
||||
|
||||
func verify(
|
||||
_ endpoint: HostEndpoint, _ token: AccessToken?
|
||||
) -> Result<HostEndpoint, PairingError> {
|
||||
verifyCalls = verifyCalls + [(endpoint.originHeader, token?.rawValue)]
|
||||
guard let next = verifyResults.first else { return .success(endpoint) }
|
||||
verifyResults = Array(verifyResults.dropFirst())
|
||||
return next
|
||||
}
|
||||
|
||||
func validate(
|
||||
_ token: AccessToken
|
||||
) -> Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
|
||||
validateCalls = validateCalls + [token.rawValue]
|
||||
guard let next = validateResults.first else { return .success(.valid) }
|
||||
validateResults = Array(validateResults.dropFirst())
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
/// 32 chars from the frozen charset — shape-valid, so any rejection in a test
|
||||
/// comes from the server outcome, not from validation.
|
||||
private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345"
|
||||
|
||||
private func makeViewModel(
|
||||
store: any HostStore,
|
||||
recorder: ProbeRecorder
|
||||
) -> PairingViewModel {
|
||||
PairingViewModel(
|
||||
store: store,
|
||||
probe: PairingViewModel.Probe(
|
||||
verifyHost: { endpoint, token in await recorder.verify(endpoint, token) },
|
||||
validateToken: { _, token in await recorder.validate(token) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Drive the VM to the token prompt the way the user gets there: the host
|
||||
/// answers 401, which the probe reports as `originRejected` with the
|
||||
/// both-remedies hint.
|
||||
private func viewModelAtTokenPrompt(
|
||||
store: any HostStore = InMemoryHostStore(),
|
||||
recorder: ProbeRecorder
|
||||
) async -> PairingViewModel {
|
||||
let viewModel = makeViewModel(store: store, recorder: recorder)
|
||||
viewModel.submitManualURL(Self.base)
|
||||
await viewModel.confirmConnect()
|
||||
viewModel.beginAccessTokenEntry()
|
||||
// Loud, not silent: a recorder whose first verification does NOT fail
|
||||
// with a 401 would leave the VM elsewhere and make every assertion below
|
||||
// vacuous.
|
||||
if case .awaitingToken = viewModel.phase {} else {
|
||||
Issue.record("harness expected .awaitingToken, got \(viewModel.phase)")
|
||||
}
|
||||
return viewModel
|
||||
}
|
||||
|
||||
// MARK: - 401 → prompt
|
||||
|
||||
@Test("401 配对失败 → 提供「输入访问令牌」,进入令牌输入态")
|
||||
func unauthorizedFailureOffersTokenPrompt() async throws {
|
||||
// The hint text itself is the probe's (pinned in
|
||||
// PairingProbeUnauthorizedTests against the REAL probe); here it stands
|
||||
// in verbatim, because the VM must surface it unchanged.
|
||||
let hint = "主机以 401 拒绝了这次配对……请输入访问令牌后重试;"
|
||||
+ "或在主机上设置 ALLOWED_ORIGINS=\(Self.base) 后重启 web-terminal。"
|
||||
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: hint))])
|
||||
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
|
||||
viewModel.submitManualURL(Self.base)
|
||||
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
guard case .failed(_, let failure) = viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(failure.action == .enterAccessToken)
|
||||
// The ambiguity is spelled out: BOTH remedies, since the server answers
|
||||
// 401 for a bad token and a rejected Origin alike.
|
||||
#expect(failure.message.contains("访问令牌"))
|
||||
#expect(failure.message.contains("ALLOWED_ORIGINS=\(Self.base)"))
|
||||
|
||||
viewModel.beginAccessTokenEntry()
|
||||
guard case .awaitingToken = viewModel.phase else {
|
||||
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("非 401 失败不给令牌入口(beginAccessTokenEntry 无副作用)")
|
||||
func nonUnauthorizedFailureHasNoTokenEntry() async throws {
|
||||
let recorder = ProbeRecorder(verifyResults: [.failure(.timeout)])
|
||||
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
|
||||
viewModel.submitManualURL(Self.base)
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
viewModel.beginAccessTokenEntry()
|
||||
|
||||
guard case .failed(_, let failure) = viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(failure.action == .retry)
|
||||
}
|
||||
|
||||
// MARK: - Shape validation happens BEFORE any network I/O
|
||||
|
||||
@Test("令牌太短 → 本地就拒(不发 /auth),话术只带长度不带令牌")
|
||||
func tooShortTokenIsRejectedLocally() async throws {
|
||||
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))])
|
||||
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken("short")
|
||||
|
||||
#expect(await recorder.validateCalls.isEmpty)
|
||||
let rejection = try #require(viewModel.tokenRejection)
|
||||
#expect(rejection.contains("\(5)"))
|
||||
#expect(!rejection.contains("short")) // never echo the value
|
||||
guard case .awaitingToken = viewModel.phase else {
|
||||
Issue.record("expected to stay in .awaitingToken, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("令牌含非法字符 → 本地就拒,不发 /auth")
|
||||
func invalidCharactersRejectedLocally() async throws {
|
||||
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))])
|
||||
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken("abcdefghijklmnop qrstuv")
|
||||
|
||||
#expect(await recorder.validateCalls.isEmpty)
|
||||
#expect(viewModel.tokenRejection?.isEmpty == false)
|
||||
}
|
||||
|
||||
// MARK: - The four §1.1 outcomes
|
||||
|
||||
@Test("204+Set-Cookie(valid)→ 带令牌重跑探针,主机连令牌一起入库")
|
||||
func validTokenIsSavedWithTheHost() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))], // first, unauthenticated
|
||||
validateResults: [.success(.valid)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
// The re-verification carried the candidate token (2nd verify call).
|
||||
let verifyCalls = await recorder.verifyCalls
|
||||
#expect(verifyCalls.count == 2)
|
||||
#expect(verifyCalls.first?.token == nil)
|
||||
#expect(verifyCalls.last?.token == Self.goodToken)
|
||||
// …and the persisted record has it (Keychain in production).
|
||||
guard case .paired(let host) = viewModel.phase else {
|
||||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(host.accessToken?.rawValue == Self.goodToken)
|
||||
let stored = try await store.loadAll()
|
||||
#expect(stored.count == 1)
|
||||
#expect(stored.first?.accessToken?.rawValue == Self.goodToken)
|
||||
#expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken)
|
||||
}
|
||||
|
||||
@Test("204 但没有 Set-Cookie(该主机未启用鉴权)→ 绝不保存令牌,也不声称已认证")
|
||||
func authDisabledNeverPersistsAToken() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.authDisabled)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
// Still in the prompt, told the truth, and NOTHING was stored.
|
||||
guard case .awaitingToken = viewModel.phase else {
|
||||
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(viewModel.tokenRejection == PairingCopy.tokenNotRequired)
|
||||
#expect(try await store.loadAll().isEmpty)
|
||||
// No second verification either — re-probing unauthenticated would just
|
||||
// reproduce the same failure (the cause is not the token).
|
||||
#expect(await recorder.verifyCalls.count == 1)
|
||||
}
|
||||
|
||||
@Test("authDisabled 之后即使配对成功也不带令牌入库(候选已被清掉)")
|
||||
func authDisabledClearsTheCandidate() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.authDisabled)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
// Back to confirm, then a (now succeeding) retry of the whole flow.
|
||||
viewModel.cancelAccessTokenEntry()
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
guard case .paired(let host) = viewModel.phase else {
|
||||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(host.accessToken == nil)
|
||||
#expect(await recorder.verifyCalls.last?.token == nil)
|
||||
}
|
||||
|
||||
@Test("401(令牌错)→ 留在输入态给出「令牌不正确」,不入库")
|
||||
func invalidTokenKeepsThePrompt() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.invalidToken)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
#expect(viewModel.tokenRejection == PairingCopy.tokenInvalid)
|
||||
#expect(try await store.loadAll().isEmpty)
|
||||
guard case .awaitingToken = viewModel.phase else {
|
||||
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("429 → 「稍后再试」话术,不入库")
|
||||
func rateLimitedKeepsThePrompt() async throws {
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.rateLimited)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
#expect(viewModel.tokenRejection == PairingCopy.tokenRateLimited)
|
||||
}
|
||||
|
||||
@Test("/auth 传输失败 → 网络话术(不吞错、不假装令牌错)")
|
||||
func transportFailureIsSurfaced() async throws {
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.failure(.unreachable("Connection refused"))]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
let rejection = try #require(viewModel.tokenRejection)
|
||||
#expect(rejection.contains("Connection refused"))
|
||||
#expect(rejection != PairingCopy.tokenInvalid)
|
||||
}
|
||||
|
||||
// MARK: - Recovering an already-paired host
|
||||
|
||||
@Test("对同一 origin 再配对一次 = 原地更新(复用 id、不重复添加、补上令牌)")
|
||||
func repairingSameOriginUpdatesInPlace() async throws {
|
||||
// Arrange: a host paired before the server enabled WEBTERM_TOKEN.
|
||||
let store = InMemoryHostStore()
|
||||
let url = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
let existing = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
_ = try await store.upsert(existing)
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.valid)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
|
||||
// Act
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
// Assert: ONE host, same id (every lastSessionId/watermark keyed by it
|
||||
// survives), original name kept, token now present.
|
||||
let hosts = try await store.loadAll()
|
||||
#expect(hosts.count == 1)
|
||||
#expect(hosts.first?.id == existing.id)
|
||||
#expect(hosts.first?.name == "书房 Mac")
|
||||
#expect(hosts.first?.accessToken?.rawValue == Self.goodToken)
|
||||
}
|
||||
|
||||
@Test("原地更新不会因为「用户没改名字」而把主机名覆盖成 IP")
|
||||
func repairingKeepsUserChosenName() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let url = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
_ = try await store.upsert(
|
||||
HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
)
|
||||
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
|
||||
viewModel.submitManualURL(Self.base)
|
||||
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
#expect(try await store.loadAll().first?.name == "书房 Mac")
|
||||
}
|
||||
|
||||
@Test("重新配对时用户改了名字 → 采用新名字")
|
||||
func repairingAdoptsEditedName() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let url = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
_ = try await store.upsert(
|
||||
HostRegistry.Host(id: UUID(), name: "旧名字", endpoint: endpoint)
|
||||
)
|
||||
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
|
||||
viewModel.submitManualURL(Self.base)
|
||||
viewModel.hostName = "客厅 Mac"
|
||||
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
#expect(try await store.loadAll().first?.name == "客厅 Mac")
|
||||
}
|
||||
|
||||
@Test("重新配对(未输新令牌)不会丢掉已保存的令牌")
|
||||
func repairingPreservesStoredToken() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let url = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
let token = try AccessToken(validating: Self.goodToken)
|
||||
_ = try await store.upsert(HostRegistry.Host(
|
||||
id: UUID(), name: "书房 Mac", endpoint: endpoint, accessToken: token
|
||||
))
|
||||
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
|
||||
viewModel.submitManualURL(Self.base)
|
||||
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
#expect(try await store.loadAll().first?.accessToken == token)
|
||||
}
|
||||
|
||||
// MARK: - Secret hygiene
|
||||
|
||||
@Test("取消令牌输入 → 回到确认页,候选令牌被丢弃")
|
||||
func cancellingTokenEntryDropsTheCandidate() async throws {
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.valid)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
|
||||
|
||||
viewModel.cancelAccessTokenEntry()
|
||||
|
||||
guard case .confirming = viewModel.phase else {
|
||||
Issue.record("expected .confirming, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(viewModel.tokenRejection == nil)
|
||||
await viewModel.confirmConnect()
|
||||
#expect(await recorder.verifyCalls.last?.token == nil)
|
||||
}
|
||||
|
||||
@Test("换一个配对目标不会继承上一个目标的令牌")
|
||||
func newTargetDoesNotInheritTheToken() async throws {
|
||||
let recorder = ProbeRecorder(validateResults: [.success(.valid)])
|
||||
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
|
||||
viewModel.submitManualURL(Self.base)
|
||||
await viewModel.confirmConnect() // succeeds → paired
|
||||
// A fresh VM state is what the sheet gives on re-entry; here we assert the
|
||||
// reset path itself.
|
||||
viewModel.cancel()
|
||||
viewModel.submitManualURL("http://192.168.1.9:3000")
|
||||
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
#expect(await recorder.verifyCalls.last?.token == nil)
|
||||
#expect(await recorder.verifyCalls.last?.origin == "http://192.168.1.9:3000")
|
||||
}
|
||||
|
||||
@Test("令牌绝不出现在任何可观察状态里(阶段/话术/主机描述)")
|
||||
func tokenNeverLeaksIntoObservableState() async throws {
|
||||
let store = InMemoryHostStore()
|
||||
let recorder = ProbeRecorder(
|
||||
verifyResults: [.failure(.originRejected(hint: "401"))],
|
||||
validateResults: [.success(.valid)]
|
||||
)
|
||||
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
|
||||
|
||||
await viewModel.submitAccessToken(Self.goodToken)
|
||||
|
||||
guard case .paired(let host) = viewModel.phase else {
|
||||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(!String(describing: viewModel.phase).contains(Self.goodToken))
|
||||
#expect(!host.description.contains(Self.goodToken))
|
||||
#expect(host.description.contains("hasAccessToken: true")) // presence only
|
||||
#expect(viewModel.tokenRejection == nil)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,14 @@ struct PairingViewModelTests {
|
||||
store: any HostStore = InMemoryHostStore(),
|
||||
script: ProbeScript
|
||||
) -> PairingViewModel {
|
||||
PairingViewModel(store: store, probe: { await script.invoke($0) })
|
||||
// C1 · `Probe` is now a value carrying the three capabilities; the
|
||||
// token/push ones keep their zero-config defaults for these tests.
|
||||
PairingViewModel(
|
||||
store: store,
|
||||
probe: PairingViewModel.Probe(verifyHost: { endpoint, _ in
|
||||
await script.invoke(endpoint)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private struct StoreFailure: Error {}
|
||||
@@ -67,7 +74,11 @@ struct PairingViewModelTests {
|
||||
let store = InMemoryHostStore()
|
||||
let viewModel = PairingViewModel(
|
||||
store: store,
|
||||
probe: { await runPairingProbe(endpoint: $0, http: http, ws: ws) }
|
||||
probe: PairingViewModel.Probe(verifyHost: { endpoint, token in
|
||||
await runPairingProbe(
|
||||
endpoint: endpoint, http: http, ws: ws, accessToken: token?.rawValue
|
||||
)
|
||||
})
|
||||
)
|
||||
let base = "http://192.168.1.5:3000"
|
||||
let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
|
||||
@@ -270,8 +281,10 @@ struct PairingViewModelTests {
|
||||
(PairingError.httpOkButNotWebTerminal,
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
["端口"]),
|
||||
// C1 · 401 is ambiguous (Origin OR access token, same status), so the
|
||||
// primary offered remedy is now the token prompt; retry stays on screen.
|
||||
(PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
PairingViewModel.RecoveryAction.enterAccessToken,
|
||||
["ALLOWED_ORIGINS=http://192.168.1.5:3000"]),
|
||||
(PairingError.atsBlocked(host: "198.18.0.1"),
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
|
||||
@@ -37,7 +37,7 @@ struct ProjectOpenWiringTests {
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: FakeHTTPTransport(),
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) }
|
||||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
99
ios/App/WebTermTests/ProjectResumeLaunchTests.swift
Normal file
99
ios/App/WebTermTests/ProjectResumeLaunchTests.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-32(C2)· 历史会话恢复的**发射端**:`ProjectsViewModel.requestResumeClaude`
|
||||
/// 必须走与"在此仓库开新会话"完全相同的 `attach(null, cwd)` 缝,只把 bootstrap 换成
|
||||
/// `claude --resume <id>\r`,并在铸造 request 之前拦下不可信的 cwd / 会话 id。
|
||||
@MainActor
|
||||
@Suite("ProjectResumeLaunch")
|
||||
struct ProjectResumeLaunchTests {
|
||||
private nonisolated static let base = "http://192.168.1.5:3000"
|
||||
|
||||
private func makeViewModel() throws -> ProjectsViewModel {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
return ProjectsViewModel(host: host, http: FakeHTTPTransport())
|
||||
}
|
||||
|
||||
@Test("合法 cwd + 合法 id → OpenRequest 携带 `claude --resume <id>\\r`")
|
||||
func resumeBuildsBootstrapInput() throws {
|
||||
let viewModel = try makeViewModel()
|
||||
let sessionId = "3f2b1c4d-0000-4000-8000-000000000001"
|
||||
|
||||
viewModel.requestResumeClaude(cwd: "/repos/web-terminal", sessionId: sessionId)
|
||||
|
||||
let request = try #require(viewModel.openRequest)
|
||||
#expect(request.cwd == "/repos/web-terminal")
|
||||
#expect(request.bootstrapInput == "claude --resume \(sessionId)\r")
|
||||
#expect(viewModel.openErrorMessage == nil)
|
||||
}
|
||||
|
||||
@Test("相对 cwd → 拒绝铸造(与 requestOpenClaude 同款纪律)")
|
||||
func relativeCwdIsRejected() throws {
|
||||
let viewModel = try makeViewModel()
|
||||
|
||||
viewModel.requestResumeClaude(cwd: "repos/web-terminal", sessionId: "abc")
|
||||
|
||||
#expect(viewModel.openRequest == nil)
|
||||
#expect(viewModel.openErrorMessage != nil)
|
||||
}
|
||||
|
||||
@Test("危险会话 id → 拒绝铸造(绝不把它送进 PTY 命令行)")
|
||||
func injectionIdIsRejected() throws {
|
||||
let viewModel = try makeViewModel()
|
||||
|
||||
viewModel.requestResumeClaude(
|
||||
cwd: "/repos/web-terminal", sessionId: "abc; rm -rf ~"
|
||||
)
|
||||
|
||||
#expect(viewModel.openRequest == nil)
|
||||
#expect(viewModel.openErrorMessage == ResumeCopy.notResumable)
|
||||
}
|
||||
|
||||
@Test("普通开会话仍是 `claude\\r`(恢复路径没有改动既有行为)")
|
||||
func plainOpenIsUnchanged() throws {
|
||||
let viewModel = try makeViewModel()
|
||||
|
||||
viewModel.requestOpenClaude(cwd: "/repos/web-terminal")
|
||||
|
||||
#expect(viewModel.openRequest?.bootstrapInput == ProjectLaunch.claudeBootstrapInput)
|
||||
}
|
||||
}
|
||||
|
||||
/// C2 · PR 链接白名单(`PrStatus.url` 是服务器字节 —— 交给 `openURL` 等于让远端
|
||||
/// 决定要唤起哪个 App)。
|
||||
@Suite("PrLink")
|
||||
struct PrLinkTests {
|
||||
@Test("https + github.com(含子域)→ 可点")
|
||||
func githubHttpsIsAllowed() {
|
||||
#expect(PrLink.url(from: "https://github.com/o/r/pull/7") != nil)
|
||||
#expect(PrLink.url(from: "https://www.github.com/o/r/pull/7") != nil)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"其余一律拒绝:http、自定义 scheme、非 github 主机、伪造后缀、nil",
|
||||
arguments: [
|
||||
"http://github.com/o/r/pull/7",
|
||||
"javascript:alert(1)",
|
||||
"webterm://join?id=1",
|
||||
"https://evil.com/o/r/pull/7",
|
||||
"https://github.com.evil.com/o/r/pull/7",
|
||||
"not a url at all",
|
||||
"",
|
||||
]
|
||||
)
|
||||
func everythingElseIsRejected(raw: String) {
|
||||
#expect(PrLink.url(from: raw) == nil, "\(raw) 不应被接受")
|
||||
}
|
||||
|
||||
@Test("nil URL(gh 降级时没有 url 字段)→ nil")
|
||||
func nilIsRejected() {
|
||||
#expect(PrLink.url(from: nil) == nil)
|
||||
}
|
||||
}
|
||||
187
ios/App/WebTermTests/ResumeHistoryViewModelTests.swift
Normal file
187
ios/App/WebTermTests/ResumeHistoryViewModelTests.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-32 · `claude --resume <id>` 历史(`GET /sessions`)的 App 层归约。
|
||||
///
|
||||
/// RED 清单见 `docs/plans/ios-completion.md` §4(E 28–35)。安全重点:会话 id 是
|
||||
/// **不可信服务器字节**,而它会被拼进一条真的送进 PTY 的命令行 —— 因此白名单校验
|
||||
/// 是本任务的核心用例(web 侧 `public/tabs.ts:918` 缺这一层)。
|
||||
@MainActor
|
||||
@Suite("ResumeHistoryViewModel")
|
||||
struct ResumeHistoryViewModelTests {
|
||||
private static let projectPath = "/repos/web-terminal"
|
||||
|
||||
private nonisolated static func session(
|
||||
id: String = "3f2b1c4d-0000-4000-8000-000000000001",
|
||||
cwd: String,
|
||||
mtimeMs: Double = 1_785_390_645_813.5
|
||||
) -> HistorySession {
|
||||
HistorySession(
|
||||
id: id, cwd: cwd, project: "web-terminal", mtimeMs: mtimeMs, preview: "修一下 CJK locale"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 过滤(28–30)
|
||||
|
||||
@Test("只保留 cwd 落在本项目内的会话(含仓库根与子目录/worktree)")
|
||||
func keepsOnlySessionsInsideProject() {
|
||||
let inside = Self.session(id: "a1", cwd: Self.projectPath)
|
||||
let nested = Self.session(id: "a2", cwd: Self.projectPath + "/.claude/worktrees/x")
|
||||
let outside = Self.session(id: "a3", cwd: "/repos/other")
|
||||
|
||||
let candidates = ResumeHistoryViewModel.candidates(
|
||||
from: [inside, nested, outside], projectPath: Self.projectPath
|
||||
)
|
||||
|
||||
#expect(candidates.map(\.id) == ["a1", "a2"])
|
||||
}
|
||||
|
||||
@Test("前缀不得半路匹配:/repos/web-terminal-old 不属于 /repos/web-terminal")
|
||||
func siblingPrefixIsNotInside() {
|
||||
let sibling = Self.session(id: "a1", cwd: "/repos/web-terminal-old")
|
||||
|
||||
let candidates = ResumeHistoryViewModel.candidates(
|
||||
from: [sibling], projectPath: Self.projectPath
|
||||
)
|
||||
|
||||
#expect(candidates.isEmpty)
|
||||
}
|
||||
|
||||
@Test("服务器顺序(mtime 新→旧)原样保留,客户端不重排")
|
||||
func serverOrderIsPreserved() {
|
||||
let older = Self.session(id: "old", cwd: Self.projectPath, mtimeMs: 1_000)
|
||||
let newer = Self.session(id: "new", cwd: Self.projectPath, mtimeMs: 9_000)
|
||||
|
||||
let candidates = ResumeHistoryViewModel.candidates(
|
||||
from: [newer, older], projectPath: Self.projectPath
|
||||
)
|
||||
|
||||
#expect(candidates.map(\.id) == ["new", "old"])
|
||||
}
|
||||
|
||||
@Test("cwd 非绝对路径 → 不可恢复(Validation.isAbsoluteCwd 纪律)")
|
||||
func relativeCwdIsFilteredOut() {
|
||||
let relative = Self.session(id: "a1", cwd: "repos/web-terminal")
|
||||
|
||||
let candidates = ResumeHistoryViewModel.candidates(
|
||||
from: [relative], projectPath: "repos/web-terminal"
|
||||
)
|
||||
|
||||
#expect(candidates.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - phase(31–32)
|
||||
|
||||
@Test("空结果 → .empty(不是 .failed:服务器无历史时正常回 [])")
|
||||
func emptyListIsNotFailure() async {
|
||||
let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: { [] })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .empty)
|
||||
}
|
||||
|
||||
@Test("过滤后为空(有历史但都不属于本仓库)→ 同样 .empty")
|
||||
func allFilteredOutIsEmpty() async {
|
||||
let vm = ResumeHistoryViewModel(
|
||||
projectPath: Self.projectPath,
|
||||
fetch: { [Self.session(id: "a1", cwd: "/repos/other")] }
|
||||
)
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .empty)
|
||||
}
|
||||
|
||||
@Test("加载失败 → .failed(可重试),重试成功 → .loaded")
|
||||
func failureIsRetryable() async {
|
||||
let flag = ResumeFailOnceFlag()
|
||||
let payload = Self.session(id: "a1", cwd: Self.projectPath)
|
||||
let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: {
|
||||
if await flag.consumeShouldFail() { throw APIClientError.gitDataUnavailable }
|
||||
return [payload]
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
guard case .failed = vm.phase else {
|
||||
Issue.record("应为 .failed,实际 \(vm.phase)")
|
||||
return
|
||||
}
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.phase == .loaded(ResumeHistoryViewModel.candidates(
|
||||
from: [payload], projectPath: Self.projectPath
|
||||
)))
|
||||
}
|
||||
|
||||
// MARK: - 命令合成(33–34,安全)
|
||||
|
||||
@Test(
|
||||
"危险 id 绝不拼进 PTY 命令行(注入白名单)",
|
||||
arguments: [
|
||||
"abc; rm -rf ~", "abc `id`", "abc$(id)", "abc\nwhoami", "abc id", "abc'x'",
|
||||
"abc\"x\"", "abc|x", "abc&x", "abc>x", "../../etc/passwd", "",
|
||||
]
|
||||
)
|
||||
func dangerousIdsAreRejected(sessionId: String) {
|
||||
#expect(
|
||||
ProjectResumeCommand.bootstrapInput(sessionId: sessionId) == nil,
|
||||
"\(sessionId) 不应被接受"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("超长 id(> 128)拒绝")
|
||||
func overlongIdRejected() {
|
||||
let long = String(repeating: "a", count: 129)
|
||||
|
||||
#expect(ProjectResumeCommand.bootstrapInput(sessionId: long) == nil)
|
||||
}
|
||||
|
||||
@Test("合法 id → `claude --resume <id>` 且以 \\r(0x0D)结尾,绝不是 \\n")
|
||||
func validIdBuildsCarriageReturnCommand() throws {
|
||||
let id = "3f2b1c4d-0000-4000-8000-000000000001"
|
||||
|
||||
let input = try #require(ProjectResumeCommand.bootstrapInput(sessionId: id))
|
||||
|
||||
#expect(input == "claude --resume \(id)\r")
|
||||
#expect(input.hasSuffix("\r"))
|
||||
#expect(!input.contains("\n"))
|
||||
}
|
||||
|
||||
@Test("非法 id 的历史行仍显示,但 canResume == false(不提供恢复按钮)")
|
||||
func rowWithBadIdIsNotResumable() {
|
||||
let bad = Self.session(id: "abc; rm -rf ~", cwd: Self.projectPath)
|
||||
|
||||
let candidates = ResumeHistoryViewModel.candidates(
|
||||
from: [bad], projectPath: Self.projectPath
|
||||
)
|
||||
|
||||
#expect(candidates.count == 1)
|
||||
#expect(!candidates[0].canResume)
|
||||
}
|
||||
|
||||
@Test("合法行 canResume == true,且携带会话自己的 cwd(worktree 会话回到 worktree)")
|
||||
func resumableRowCarriesItsOwnCwd() throws {
|
||||
let nestedCwd = Self.projectPath + "/.claude/worktrees/x"
|
||||
let session = Self.session(id: "a1", cwd: nestedCwd)
|
||||
|
||||
let candidate = try #require(ResumeHistoryViewModel.candidates(
|
||||
from: [session], projectPath: Self.projectPath
|
||||
).first)
|
||||
|
||||
#expect(candidate.canResume)
|
||||
#expect(candidate.cwd == nestedCwd)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次性失败开关(@Sendable fetch 闭包里的可变状态 → actor 隔离)。
|
||||
private actor ResumeFailOnceFlag {
|
||||
private var shouldFail = true
|
||||
|
||||
func consumeShouldFail() -> Bool {
|
||||
defer { shouldFail = false }
|
||||
return shouldFail
|
||||
}
|
||||
}
|
||||
@@ -304,7 +304,7 @@ struct SessionSwitcherTests {
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: http,
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) },
|
||||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
|
||||
unreadStore: unreadStore
|
||||
)
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ struct SidebarSelectionTests {
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: http,
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) },
|
||||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
|
||||
unreadStore: unreadStore
|
||||
)
|
||||
)
|
||||
|
||||
315
ios/App/WebTermTests/TerminalSearchTests.swift
Normal file
315
ios/App/WebTermTests/TerminalSearchTests.swift
Normal file
@@ -0,0 +1,315 @@
|
||||
import Foundation
|
||||
import SessionCore
|
||||
import SwiftTerm
|
||||
import TestSupport
|
||||
import Testing
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-33 · 终端内搜索(ios-completion §4 RED 清单 1–18)。
|
||||
///
|
||||
/// 契约来源:SwiftTerm 1.13.0 自带的搜索 API(`findNext`/`findPrevious`/
|
||||
/// `clearSearch`,`SearchOptions` 默认与 xterm.js search addon 一致)+ web 参照
|
||||
/// `public/search.ts`(Enter=next / Shift+Enter=prev / Esc=关闭并 clear)。
|
||||
///
|
||||
/// 铁律:搜索是**纯读** —— 整个流程不得产生一个 PTY 字节(byte-shuttle 不变式)。
|
||||
@MainActor
|
||||
@Suite("TerminalSearch (T-iOS-33)")
|
||||
struct TerminalSearchTests {
|
||||
// MARK: - Fakes
|
||||
|
||||
/// 记录调用序列的搜索引擎替身:命中与否由测试注入。
|
||||
@MainActor
|
||||
private final class FakeSearcher: TerminalSearching {
|
||||
enum Call: Equatable {
|
||||
case next(String)
|
||||
case previous(String)
|
||||
case clear
|
||||
}
|
||||
|
||||
private(set) var calls: [Call] = []
|
||||
var hitResult = true
|
||||
|
||||
func searchNext(term: String) -> Bool {
|
||||
calls = calls + [.next(term)]
|
||||
return hitResult
|
||||
}
|
||||
|
||||
func searchPrevious(term: String) -> Bool {
|
||||
calls = calls + [.previous(term)]
|
||||
return hitResult
|
||||
}
|
||||
|
||||
func searchClear() {
|
||||
calls = calls + [.clear]
|
||||
}
|
||||
}
|
||||
|
||||
private func model(_ searcher: FakeSearcher) -> TerminalSearchModel {
|
||||
let model = TerminalSearchModel()
|
||||
model.attach(searcher)
|
||||
return model
|
||||
}
|
||||
|
||||
// MARK: - A. 查询提交策略(1–3)
|
||||
|
||||
@Test("1 · 空串不可提交,且零引擎调用")
|
||||
func emptyQueryIsNotSubmittable() {
|
||||
#expect(!TerminalSearchQuery.isSubmittable(""))
|
||||
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = ""
|
||||
model.find(.next)
|
||||
|
||||
#expect(searcher.calls.isEmpty)
|
||||
#expect(model.outcome == .idle)
|
||||
}
|
||||
|
||||
@Test("2 · 单个空格可提交(镜像 web:空格是合法搜索词,绝不 trim 掉)")
|
||||
func singleSpaceIsSubmittable() {
|
||||
#expect(TerminalSearchQuery.isSubmittable(" "))
|
||||
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = " "
|
||||
model.find(.next)
|
||||
|
||||
#expect(searcher.calls == [.next(" ")])
|
||||
}
|
||||
|
||||
@Test("3 · 查询词逐字符原样下传(不 trim、不改大小写)")
|
||||
func queryIsPassedThroughVerbatim() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = " Foo "
|
||||
model.find(.next)
|
||||
|
||||
#expect(searcher.calls == [.next(" Foo ")])
|
||||
}
|
||||
|
||||
// MARK: - B. 方向与引擎调用(4–9)
|
||||
|
||||
@Test("4 · find(.next) → 恰一次 searchNext,零 searchPrevious")
|
||||
func findNextCallsNextOnly() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = "claude"
|
||||
model.find(.next)
|
||||
|
||||
#expect(searcher.calls == [.next("claude")])
|
||||
}
|
||||
|
||||
@Test("5 · find(.previous) → 恰一次 searchPrevious,零 searchNext")
|
||||
func findPreviousCallsPreviousOnly() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = "claude"
|
||||
model.find(.previous)
|
||||
|
||||
#expect(searcher.calls == [.previous("claude")])
|
||||
}
|
||||
|
||||
@Test("6 · 引擎命中 → outcome == .found")
|
||||
func hitProducesFoundOutcome() {
|
||||
let searcher = FakeSearcher()
|
||||
searcher.hitResult = true
|
||||
let model = model(searcher)
|
||||
model.query = "claude"
|
||||
model.find(.next)
|
||||
|
||||
#expect(model.outcome == .found)
|
||||
}
|
||||
|
||||
@Test("7 · 引擎未命中 → outcome == .notFound,且有非空文案")
|
||||
func missProducesNotFoundOutcomeWithCopy() {
|
||||
let searcher = FakeSearcher()
|
||||
searcher.hitResult = false
|
||||
let model = model(searcher)
|
||||
model.query = "zzz"
|
||||
model.find(.next)
|
||||
|
||||
#expect(model.outcome == .notFound)
|
||||
let status = model.statusText
|
||||
#expect(status != nil)
|
||||
#expect(!(status ?? "").isEmpty)
|
||||
}
|
||||
|
||||
@Test("8 · 连续三次 find(.next) → 三次引擎调用(游标由 SwiftTerm 推进)")
|
||||
func repeatedFindHitsEngineEachTime() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = "x"
|
||||
model.find(.next)
|
||||
model.find(.next)
|
||||
model.find(.next)
|
||||
|
||||
#expect(searcher.calls == [.next("x"), .next("x"), .next("x")])
|
||||
}
|
||||
|
||||
@Test("9 · 未 attach 引擎 → 不崩,outcome 保持 .idle")
|
||||
func noSearcherAttachedIsSafe() {
|
||||
let model = TerminalSearchModel()
|
||||
model.query = "claude"
|
||||
model.find(.next)
|
||||
|
||||
#expect(model.outcome == .idle)
|
||||
#expect(model.statusText == nil)
|
||||
}
|
||||
|
||||
// MARK: - C. 打开/关闭生命周期(10–14)
|
||||
|
||||
@Test("10 · 初始 isPresented == false 且 outcome == .idle")
|
||||
func initialStateIsClosedAndIdle() {
|
||||
let model = TerminalSearchModel()
|
||||
|
||||
#expect(!model.isPresented)
|
||||
#expect(model.outcome == .idle)
|
||||
}
|
||||
|
||||
@Test("11 · present()/dismiss() 切换 isPresented")
|
||||
func presentAndDismissTogglePresentation() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
|
||||
model.present()
|
||||
#expect(model.isPresented)
|
||||
|
||||
model.dismiss()
|
||||
#expect(!model.isPresented)
|
||||
}
|
||||
|
||||
@Test("12 · dismiss() → 恰一次 searchClear + 清空 query + outcome 复位")
|
||||
func dismissClearsHighlightAndQuery() {
|
||||
let searcher = FakeSearcher()
|
||||
searcher.hitResult = false
|
||||
let model = model(searcher)
|
||||
model.present()
|
||||
model.query = "zzz"
|
||||
model.find(.next)
|
||||
#expect(model.outcome == .notFound)
|
||||
|
||||
model.dismiss()
|
||||
|
||||
#expect(searcher.calls == [.next("zzz"), .clear])
|
||||
#expect(model.query.isEmpty)
|
||||
#expect(model.outcome == .idle)
|
||||
}
|
||||
|
||||
@Test("13 · present() 不调用任何引擎方法(开面板 ≠ 搜索)")
|
||||
func presentDoesNotTouchEngine() {
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.present()
|
||||
|
||||
#expect(searcher.calls.isEmpty)
|
||||
}
|
||||
|
||||
@Test("14 · 改 query → outcome 复位 .idle(旧「无匹配」不挂新词)")
|
||||
func editingQueryResetsOutcome() {
|
||||
let searcher = FakeSearcher()
|
||||
searcher.hitResult = false
|
||||
let model = model(searcher)
|
||||
model.query = "zzz"
|
||||
model.find(.next)
|
||||
#expect(model.outcome == .notFound)
|
||||
|
||||
model.query = "zz"
|
||||
|
||||
#expect(model.outcome == .idle)
|
||||
#expect(model.statusText == nil)
|
||||
}
|
||||
|
||||
// MARK: - D. 不变式:搜索是纯读(15–16)
|
||||
|
||||
@Test("15 · 整个搜索流程后零 PTY 字节(byte-shuttle 不变式)")
|
||||
func searchNeverWritesToThePty() async throws {
|
||||
// Arrange:真 SessionEngine over FakeTransport + 真 TerminalViewModel。
|
||||
let transport = FakeTransport()
|
||||
let clock = FakeClock()
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let engine = SessionEngine(
|
||||
transport: transport, clock: clock, endpoint: endpoint,
|
||||
eventsSource: { _ in [] }
|
||||
)
|
||||
let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events)
|
||||
terminalViewModel.start()
|
||||
|
||||
// Act:开面板 → 搜前后 → 关面板。
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.present()
|
||||
model.query = "claude"
|
||||
model.find(.next)
|
||||
model.find(.previous)
|
||||
model.dismiss()
|
||||
|
||||
// Assert:一个字节都没进 PTY。
|
||||
#expect(terminalViewModel.forwardedSendCount == 0)
|
||||
#expect(terminalViewModel.droppedReadOnlyInputCount == 0)
|
||||
}
|
||||
|
||||
@Test("16 · 终端已退出(read-only)时搜索照常可用")
|
||||
func searchWorksOnAnExitedTerminal() async throws {
|
||||
// Arrange:把 VM 推到 .exited。
|
||||
let transport = FakeTransport()
|
||||
let clock = FakeClock()
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let engine = SessionEngine(
|
||||
transport: transport, clock: clock, endpoint: endpoint,
|
||||
eventsSource: { _ in [] }
|
||||
)
|
||||
let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events)
|
||||
terminalViewModel.start()
|
||||
await engine.open(sessionId: nil, cwd: nil)
|
||||
await transport.emit(frame: #"{"type":"exit","code":0,"reason":"done"}"#)
|
||||
await terminalViewModel.waitUntilProcessed(eventCount: 3)
|
||||
#expect(terminalViewModel.isReadOnly)
|
||||
|
||||
// Act
|
||||
let searcher = FakeSearcher()
|
||||
let model = model(searcher)
|
||||
model.query = "claude"
|
||||
model.find(.next)
|
||||
|
||||
// Assert:搜索是纯读,退出后仍然可用。
|
||||
#expect(searcher.calls == [.next("claude")])
|
||||
#expect(model.outcome == .found)
|
||||
}
|
||||
|
||||
// MARK: - E. SwiftTerm 绑定(17–18,Accept:命中并高亮)
|
||||
|
||||
/// 真 `KeyCommandTerminalView`(非替身):喂一段输出,再用 `TerminalSearching`
|
||||
/// 的三个方法打 SwiftTerm 自带搜索,命中即选区高亮。
|
||||
@Test("17 · 真 SwiftTerm view:命中返回 true 并高亮选区;未命中返回 false")
|
||||
func realTerminalViewSearchHitsAndHighlights() {
|
||||
let terminal = KeyCommandTerminalView(
|
||||
frame: CGRect(x: 0, y: 0, width: 480, height: 480)
|
||||
)
|
||||
terminal.feed(text: "claude code cockpit\r\n")
|
||||
|
||||
let searcher: any TerminalSearching = terminal
|
||||
#expect(searcher.searchNext(term: "cockpit"))
|
||||
#expect(terminal.hasActiveSelection)
|
||||
|
||||
#expect(!searcher.searchNext(term: "nonexistent-needle"))
|
||||
}
|
||||
|
||||
@Test("18 · searchClear() 清掉高亮")
|
||||
func clearRemovesHighlight() {
|
||||
let terminal = KeyCommandTerminalView(
|
||||
frame: CGRect(x: 0, y: 0, width: 480, height: 480)
|
||||
)
|
||||
terminal.feed(text: "claude code cockpit\r\n")
|
||||
let searcher: any TerminalSearching = terminal
|
||||
#expect(searcher.searchNext(term: "cockpit"))
|
||||
#expect(terminal.hasActiveSelection)
|
||||
|
||||
searcher.searchClear()
|
||||
|
||||
#expect(!terminal.hasActiveSelection)
|
||||
}
|
||||
}
|
||||
95
ios/App/WebTermTests/TerminalUnauthorizedTests.swift
Normal file
95
ios/App/WebTermTests/TerminalUnauthorizedTests.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · The 401 terminal state end to end through the VM (B3 added
|
||||
/// `FailureReason.unauthorized`; this pins how the terminal presents it).
|
||||
///
|
||||
/// Driven through a REAL `SessionEngine` over `TestSupport.FakeTransport`, so
|
||||
/// the assertion covers the whole path a wrong token actually takes:
|
||||
/// upgrade 401 → `TermTransportError.unauthorized` → engine terminal state →
|
||||
/// `SessionEvent.connection(.failed(.unauthorized))` → VM phase + copy.
|
||||
@MainActor
|
||||
@Suite("Terminal unauthorized (401)")
|
||||
struct TerminalUnauthorizedTests {
|
||||
@MainActor
|
||||
private struct Harness {
|
||||
let transport = FakeTransport()
|
||||
let clock = FakeClock()
|
||||
let engine: SessionEngine
|
||||
let viewModel: TerminalViewModel
|
||||
|
||||
init() throws {
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
engine = SessionEngine(
|
||||
transport: transport, clock: clock, endpoint: endpoint,
|
||||
eventsSource: { _ in [] }
|
||||
)
|
||||
viewModel = TerminalViewModel(engine: engine, events: engine.events)
|
||||
viewModel.start()
|
||||
}
|
||||
|
||||
/// Upgrade rejected with 401 → `.connecting` + `.failed(.unauthorized)`.
|
||||
func openRejected() async {
|
||||
await transport.scriptConnectFailure(TermTransportError.unauthorized)
|
||||
await engine.open(sessionId: nil, cwd: nil)
|
||||
await viewModel.waitUntilProcessed(eventCount: 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("401 升级被拒 → 终态 failed(不是 reconnecting 转圈)")
|
||||
func unauthorizedIsTerminalNotRetried() async throws {
|
||||
let harness = try Harness()
|
||||
|
||||
await harness.openRejected()
|
||||
|
||||
guard case .failed = harness.viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(harness.viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(harness.viewModel.banner == .none) // spinner cleared
|
||||
#expect(harness.viewModel.isReadOnly)
|
||||
#expect(await harness.transport.connectAttempts.count == 1) // no backoff loop
|
||||
}
|
||||
|
||||
@Test("401 话术同时给出两条补救:令牌与 ALLOWED_ORIGINS(服务端两者都回 401)")
|
||||
func unauthorizedCopyOffersBothRemedies() async throws {
|
||||
let harness = try Harness()
|
||||
|
||||
await harness.openRejected()
|
||||
|
||||
guard case .failed(let message) = harness.viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(harness.viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("访问令牌"))
|
||||
#expect(message.contains("ALLOWED_ORIGINS"))
|
||||
#expect(message == TerminalViewModel.unauthorizedMessage)
|
||||
}
|
||||
|
||||
@Test("401 横幅模型走 failed 分支(供 ReconnectBanner 渲染)")
|
||||
func unauthorizedRendersFailedBanner() async throws {
|
||||
let harness = try Harness()
|
||||
|
||||
await harness.openRejected()
|
||||
|
||||
#expect(
|
||||
harness.viewModel.bannerModel
|
||||
== .failed(message: TerminalViewModel.unauthorizedMessage)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("401 终态下输入被丢弃(read-only,不再打向已死连接)")
|
||||
func unauthorizedDropsInput() async throws {
|
||||
let harness = try Harness()
|
||||
await harness.openRejected()
|
||||
|
||||
harness.viewModel.sendInput("ls\r")
|
||||
|
||||
#expect(harness.viewModel.droppedReadOnlyInputCount == 1)
|
||||
}
|
||||
}
|
||||
635
ios/App/WebTermTests/VoicePTTTests.swift
Normal file
635
ios/App/WebTermTests/VoicePTTTests.swift
Normal file
@@ -0,0 +1,635 @@
|
||||
import Foundation
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-31 · 语音 PTT + 确认(ios-completion §4 RED 清单 19–56)。
|
||||
///
|
||||
/// 三件套逐条移植 web:`public/voice.ts`(PTT 生命周期、`autoSend:false` ⇒ 绝不
|
||||
/// 自动回车)、`public/voice-commands.ts`(整句精确匹配器 + 否定守卫 + 0.6 置信度
|
||||
/// 门)、`public/voice-confirm.ts`(1500ms 可撤销窗口)。epoch 防误发沿用
|
||||
/// `SessionCore/GateState.canDecide(epoch:)` 的既有先例。
|
||||
///
|
||||
/// 识别器经协议注入 —— 真机口述属 DEFERRED(需硬件麦克风),本套用替身把
|
||||
/// **匹配器 / 撤销窗口 / epoch 闸门**全部钉死,零真睡(FakeClock)。
|
||||
@MainActor
|
||||
@Suite("VoicePTT (T-iOS-31)")
|
||||
struct VoicePTTTests {
|
||||
// MARK: - Fakes
|
||||
|
||||
/// 一次性闸门:让被测代码停在某个 `await` 上,测试侧确定性放行(零轮询、
|
||||
/// 零真睡 —— 全员在 MainActor 上,注册先于挂起,故顺序是确定的)。
|
||||
@MainActor
|
||||
private final class Gate {
|
||||
var isArmed = false
|
||||
private var didReach = false
|
||||
private var blocked: CheckedContinuation<Void, Never>?
|
||||
private var arrival: CheckedContinuation<Void, Never>?
|
||||
|
||||
/// 被测代码经过闸门;armed 时在此挂起。
|
||||
func passThrough() async {
|
||||
guard isArmed else { return }
|
||||
didReach = true
|
||||
arrival?.resume()
|
||||
arrival = nil
|
||||
await withCheckedContinuation { blocked = $0 }
|
||||
}
|
||||
|
||||
/// 等到被测代码真的到达闸门。
|
||||
func waitUntilReached() async {
|
||||
guard !didReach else { return }
|
||||
await withCheckedContinuation { arrival = $0 }
|
||||
}
|
||||
|
||||
func open() {
|
||||
isArmed = false
|
||||
let blocked = self.blocked
|
||||
self.blocked = nil
|
||||
blocked?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// 识别器替身:授权结果、抛错、最终转写全部由测试注入;两个闸门可确定性
|
||||
/// 复现「授权期间松手」与「录音启动期间松手」两种 PTT 竞态。
|
||||
@MainActor
|
||||
private final class FakeDictation: VoiceDictating {
|
||||
var authorization: VoiceAuthorization = .granted
|
||||
var startError: (any Error)?
|
||||
var result = VoiceDictationResult(transcript: "", confidence: nil)
|
||||
private(set) var startCount = 0
|
||||
private(set) var stopCount = 0
|
||||
private(set) var partialSink: (@MainActor (String) -> Void)?
|
||||
let authorizationGate = Gate()
|
||||
let startGate = Gate()
|
||||
|
||||
func requestAuthorization() async -> VoiceAuthorization {
|
||||
await authorizationGate.passThrough()
|
||||
return authorization
|
||||
}
|
||||
|
||||
func start(onPartial: @escaping @MainActor (String) -> Void) async throws {
|
||||
startCount += 1
|
||||
if let startError { throw startError }
|
||||
partialSink = onPartial
|
||||
await startGate.passThrough()
|
||||
}
|
||||
|
||||
func stop() async -> VoiceDictationResult {
|
||||
stopCount += 1
|
||||
partialSink = nil
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// 可变 epoch 源(测试侧模拟会话切换/重连)。
|
||||
@MainActor
|
||||
private final class EpochBox {
|
||||
var epoch: VoiceEpoch
|
||||
|
||||
init(_ epoch: VoiceEpoch = VoiceEpoch(sessionId: nil, generation: 0)) {
|
||||
self.epoch = epoch
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入出口记录仪:断言「零注入」/「恰一次注入」的唯一判据。
|
||||
@MainActor
|
||||
private final class InjectRecorder {
|
||||
private(set) var texts: [String] = []
|
||||
private(set) var decisions: [VoiceDecision] = []
|
||||
|
||||
func inject(_ text: String) { texts = texts + [text] }
|
||||
func decide(_ decision: VoiceDecision) { decisions = decisions + [decision] }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct Harness {
|
||||
let dictation = FakeDictation()
|
||||
let clock = FakeClock()
|
||||
let epochBox = EpochBox()
|
||||
let recorder = InjectRecorder()
|
||||
let viewModel: VoicePTTViewModel
|
||||
|
||||
init(isReadOnly: Bool = false, gate: VoiceGateSnapshot? = nil) {
|
||||
let dictation = self.dictation
|
||||
let clock = self.clock
|
||||
let epochBox = self.epochBox
|
||||
let recorder = self.recorder
|
||||
let bridge: VoiceGateBridge? = gate.map { snapshot in
|
||||
VoiceGateBridge(
|
||||
snapshot: { snapshot },
|
||||
decide: { decision in recorder.decide(decision) }
|
||||
)
|
||||
}
|
||||
viewModel = VoicePTTViewModel(
|
||||
dictation: dictation,
|
||||
clock: clock,
|
||||
epoch: { epochBox.epoch },
|
||||
isReadOnly: { isReadOnly },
|
||||
inject: { text in recorder.inject(text) },
|
||||
gate: bridge
|
||||
)
|
||||
}
|
||||
|
||||
/// 完整一次口述:按下 → 松手,转写由 `dictation.result` 提供。
|
||||
func dictate(_ transcript: String, confidence: Double? = nil) async {
|
||||
dictation.result = VoiceDictationResult(
|
||||
transcript: transcript, confidence: confidence
|
||||
)
|
||||
await viewModel.pressDown()
|
||||
await viewModel.pressUp()
|
||||
}
|
||||
|
||||
/// 推进到撤销窗口到期并等注入落地(零真睡)。
|
||||
func elapseUndoWindow() async {
|
||||
await clock.waitForSleepers(count: 1)
|
||||
clock.advance(by: VoicePTTViewModel.undoWindow)
|
||||
await viewModel.waitUntilWindowSettled()
|
||||
}
|
||||
}
|
||||
|
||||
private static let sessionA = UUID()
|
||||
private static let sessionB = UUID()
|
||||
|
||||
// MARK: - A. 转写清洗(19–24)
|
||||
|
||||
@Test("19 · 普通文本 → 首尾 trim 后原样")
|
||||
func sanitizeKeepsPlainText() {
|
||||
#expect(VoiceTranscript.sanitize(" git status ") == "git status")
|
||||
}
|
||||
|
||||
@Test("20 · \\r(0x0D)被剥除 —— 口述绝不自己回车执行命令")
|
||||
func sanitizeStripsCarriageReturn() {
|
||||
let sanitized = VoiceTranscript.sanitize("rm -rf /\r")
|
||||
#expect(!sanitized.contains("\r"))
|
||||
#expect(sanitized == "rm -rf /")
|
||||
}
|
||||
|
||||
@Test("21 · C0/C1 控制字符(\\n \\t ESC BEL DEL)全部剥除")
|
||||
func sanitizeStripsControlCharacters() {
|
||||
let raw = "ec\u{1b}ho\u{7}\t hi\u{7f}\u{0}\u{9b}"
|
||||
let sanitized = VoiceTranscript.sanitize(raw)
|
||||
#expect(sanitized == "echo hi")
|
||||
}
|
||||
|
||||
@Test("22 · 多行折成单行(换行→空格 + 合并连续空白)")
|
||||
func sanitizeCollapsesToOneLine() {
|
||||
#expect(VoiceTranscript.sanitize("git\ncommit -m\n\n test") == "git commit -m test")
|
||||
}
|
||||
|
||||
@Test("23 · 超长转写截断到上限")
|
||||
func sanitizeTruncatesOverlongTranscript() {
|
||||
let raw = String(repeating: "a", count: VoiceTranscript.maxLength + 500)
|
||||
#expect(VoiceTranscript.sanitize(raw).count == VoiceTranscript.maxLength)
|
||||
}
|
||||
|
||||
@Test("24 · 清洗后为空 → 不可注入")
|
||||
func sanitizeEmptyIsNotInjectable() {
|
||||
#expect(!VoiceTranscript.isInjectable(VoiceTranscript.sanitize("\u{1b}\r\n \t")))
|
||||
#expect(VoiceTranscript.isInjectable("ok"))
|
||||
}
|
||||
|
||||
// MARK: - B. 匹配器移植(25–33)
|
||||
|
||||
private func context(
|
||||
pendingApproval: Bool, gate: VoiceGateKind?, confidence: Double? = nil
|
||||
) -> VoiceMatchContext {
|
||||
VoiceMatchContext(pendingApproval: pendingApproval, gate: gate, confidence: confidence)
|
||||
}
|
||||
|
||||
@Test("25 · 无 held gate → 任何话语都是 .text(含「确认」)")
|
||||
func matcherFallsThroughWithoutHeldGate() {
|
||||
let ctx = context(pendingApproval: false, gate: .tool)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "确认", context: ctx) == .text)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "approve", context: ctx) == .text)
|
||||
}
|
||||
|
||||
@Test("26 · gate 是 .plan → .text(v1 只作用 tool gate)")
|
||||
func matcherIgnoresPlanGate() {
|
||||
let ctx = context(pendingApproval: true, gate: .plan)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "确认", context: ctx) == .text)
|
||||
}
|
||||
|
||||
@Test("27 · tool gate + 肯定短语 → .approve")
|
||||
func matcherApprovesAffirmativePhrases() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
for phrase in ["确认", "批准", "同意", "好的", "ok", "go ahead", "proceed"] {
|
||||
#expect(VoiceCommandMatcher.match(transcript: phrase, context: ctx) == .approve,
|
||||
"\(phrase) 应判 approve")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("28 · 归一化:大小写 + 去标点 + 合并空白(don't → dont)")
|
||||
func matcherNormalizesTranscript() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "OK.", context: ctx) == .approve)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "好的!", context: ctx) == .approve)
|
||||
#expect(VoiceCommandMatcher.normalize("Don't DO it") == "dont do it")
|
||||
}
|
||||
|
||||
@Test("29 · 整句精确匹配 —— 「确认一下这个」落 .text(绝不子串匹配)")
|
||||
func matcherIsWholeUtteranceExact() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "确认一下这个", context: ctx) == .text)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "ok let's ship it", context: ctx) == .text)
|
||||
}
|
||||
|
||||
@Test("30 · tool gate + 否定短语 → .reject")
|
||||
func matcherRejectsNegativePhrases() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
for phrase in ["拒绝", "取消", "stop", "abort", "no"] {
|
||||
#expect(VoiceCommandMatcher.match(transcript: phrase, context: ctx) == .reject,
|
||||
"\(phrase) 应判 reject")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("31 · 否定守卫:「不批准」→ reject;「不清楚」→ text;now 不被 no 否定")
|
||||
func matcherNegationGuard() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "不批准", context: ctx) == .reject)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "不清楚", context: ctx) == .text)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "now", context: ctx) == .text)
|
||||
#expect(VoiceCommandMatcher.startsWithNegation("不批准"))
|
||||
#expect(!VoiceCommandMatcher.startsWithNegation("now"))
|
||||
}
|
||||
|
||||
@Test("32 · 置信度门:approve 低于 0.6 降级 text;reject 不受影响")
|
||||
func matcherConfidenceGateAppliesToApproveOnly() {
|
||||
let low = context(pendingApproval: true, gate: .tool, confidence: 0.4)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "确认", context: low) == .text)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "取消", context: low) == .reject)
|
||||
|
||||
let atThreshold = context(
|
||||
pendingApproval: true, gate: .tool,
|
||||
confidence: VoiceCommandMatcher.minApproveConfidence
|
||||
)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "确认", context: atThreshold) == .approve)
|
||||
}
|
||||
|
||||
@Test("33 · 空/纯标点话语 → .text")
|
||||
func matcherEmptyUtteranceIsText() {
|
||||
let ctx = context(pendingApproval: true, gate: .tool)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "", context: ctx) == .text)
|
||||
#expect(VoiceCommandMatcher.match(transcript: "……", context: ctx) == .text)
|
||||
}
|
||||
|
||||
// MARK: - C. 确认 + 1.5s 撤销窗口(34–43)
|
||||
|
||||
@Test("34 · pressDown → .listening;partial 回调更新 partial 文本")
|
||||
func pressDownStartsListeningAndSurfacesPartials() async {
|
||||
let harness = Harness()
|
||||
await harness.viewModel.pressDown()
|
||||
|
||||
#expect(harness.viewModel.phase == .listening(partial: ""))
|
||||
#expect(harness.dictation.startCount == 1)
|
||||
|
||||
harness.dictation.partialSink?("git st")
|
||||
#expect(harness.viewModel.phase == .listening(partial: "git st"))
|
||||
}
|
||||
|
||||
@Test("35 · 空转写 → .idle + .empty + 零注入")
|
||||
func emptyTranscriptResolvesEmpty() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate(" \r\n ")
|
||||
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
#expect(harness.viewModel.lastResolution == .empty)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("36 · 有效转写 → .confirming,且此刻零注入(确认前绝不注入)")
|
||||
func validTranscriptWaitsForExplicitConfirm() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
|
||||
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
|
||||
intent: .text("git status"),
|
||||
epoch: VoiceEpoch(sessionId: nil, generation: 0),
|
||||
preview: "git status"
|
||||
)))
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("37 · .confirming 下 cancel() → .idle + .discarded + 零注入")
|
||||
func cancelDiscardsBeforeInjection() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
|
||||
harness.viewModel.cancel()
|
||||
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
#expect(harness.viewModel.lastResolution == .discarded)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("38 · confirm() → .undoWindow,此刻仍零注入")
|
||||
func confirmArmsUndoWindowWithoutInjecting() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
|
||||
harness.viewModel.confirm()
|
||||
|
||||
#expect(harness.viewModel.isUndoWindowOpen)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("39 · +1.4s 仍零注入;到 1.5s → 恰一次注入 + .committed")
|
||||
func injectionLandsOnlyAfterTheFullUndoWindow() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
harness.viewModel.confirm()
|
||||
|
||||
await harness.clock.waitForSleepers(count: 1)
|
||||
harness.clock.advance(by: .milliseconds(1400))
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
|
||||
harness.clock.advance(by: .milliseconds(100))
|
||||
await harness.viewModel.waitUntilWindowSettled()
|
||||
|
||||
#expect(harness.recorder.texts == ["git status"])
|
||||
#expect(harness.viewModel.lastResolution == .committed(.text("git status")))
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
}
|
||||
|
||||
@Test("40 · 撤销窗口内 undo() → 零注入 + .undone;再推进 10s 也不注入")
|
||||
func undoCancelsTheInjectionForGood() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
harness.viewModel.confirm()
|
||||
await harness.clock.waitForSleepers(count: 1)
|
||||
|
||||
harness.viewModel.undo()
|
||||
await harness.viewModel.waitUntilWindowSettled()
|
||||
harness.clock.advance(by: .seconds(10))
|
||||
await harness.viewModel.waitUntilWindowSettled()
|
||||
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(harness.viewModel.lastResolution == .undone)
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
}
|
||||
|
||||
@Test("41 · 注入内容不以 \\r 结尾(用户自己按 ⏎)")
|
||||
func injectedTextNeverEndsWithCarriageReturn() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("npm test")
|
||||
harness.viewModel.confirm()
|
||||
await harness.elapseUndoWindow()
|
||||
|
||||
#expect(harness.recorder.texts == ["npm test"])
|
||||
#expect(!(harness.recorder.texts.first ?? "\r").hasSuffix("\r"))
|
||||
}
|
||||
|
||||
@Test("42 · 非 .confirming 态调 confirm() → 无副作用")
|
||||
func confirmOutsideConfirmingIsNoop() async {
|
||||
let harness = Harness()
|
||||
|
||||
harness.viewModel.confirm()
|
||||
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
#expect(harness.viewModel.lastResolution == nil)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("43 · 重复 confirm() 只 arm 一次、只注入一次")
|
||||
func repeatedConfirmArmsOnce() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
|
||||
harness.viewModel.confirm()
|
||||
harness.viewModel.confirm()
|
||||
harness.viewModel.confirm()
|
||||
await harness.elapseUndoWindow()
|
||||
|
||||
#expect(harness.recorder.texts == ["git status"])
|
||||
}
|
||||
|
||||
// MARK: - D. epoch 防误发(44–48)
|
||||
|
||||
@Test("44 · 口述→确认之间 epoch 变了 → 零注入 + .staleEpoch")
|
||||
func epochChangeBeforeConfirmDiscards() async {
|
||||
let harness = Harness()
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
|
||||
await harness.dictate("git status")
|
||||
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionB, generation: 0)
|
||||
harness.viewModel.confirm()
|
||||
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(harness.viewModel.lastResolution == .staleEpoch)
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
}
|
||||
|
||||
@Test("45 · epoch 在撤销窗口内变 → 到期仍零注入 + .staleEpoch(第二道闸)")
|
||||
func epochChangeInsideUndoWindowDiscards() async {
|
||||
let harness = Harness()
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
|
||||
await harness.dictate("git status")
|
||||
harness.viewModel.confirm()
|
||||
await harness.clock.waitForSleepers(count: 1)
|
||||
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 1)
|
||||
harness.clock.advance(by: VoicePTTViewModel.undoWindow)
|
||||
await harness.viewModel.waitUntilWindowSettled()
|
||||
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(harness.viewModel.lastResolution == .staleEpoch)
|
||||
}
|
||||
|
||||
@Test("46 · epoch 不变 → 正常注入(防闸门过严的回归保护)")
|
||||
func stableEpochStillInjects() async {
|
||||
let harness = Harness()
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 3)
|
||||
await harness.dictate("ls -la")
|
||||
harness.viewModel.confirm()
|
||||
await harness.elapseUndoWindow()
|
||||
|
||||
#expect(harness.recorder.texts == ["ls -la"])
|
||||
}
|
||||
|
||||
@Test("47 · 口述时 sessionId 未 adopt、确认时已 adopt → 视为变化 → 零注入")
|
||||
func dictatingBeforeAdoptionDiscardsAfterAdoption() async {
|
||||
let harness = Harness()
|
||||
await harness.dictate("git status")
|
||||
|
||||
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
|
||||
harness.viewModel.confirm()
|
||||
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(harness.viewModel.lastResolution == .staleEpoch)
|
||||
}
|
||||
|
||||
@Test("48 · VoiceEpochPolicy:.reconnecting 作废 epoch;.connecting/.none 不作废")
|
||||
func epochPolicyInvalidatesOnReconnect() {
|
||||
#expect(VoiceEpochPolicy.invalidates(
|
||||
.reconnecting(attempt: 1, next: .seconds(1))
|
||||
))
|
||||
#expect(!VoiceEpochPolicy.invalidates(.connecting))
|
||||
#expect(!VoiceEpochPolicy.invalidates(.none))
|
||||
|
||||
let source = VoiceEpochSource()
|
||||
#expect(source.epoch == VoiceEpoch(sessionId: nil, generation: 0))
|
||||
source.noteBanner(.reconnecting(attempt: 1, next: .seconds(1)))
|
||||
source.noteBanner(.connecting)
|
||||
source.noteSession(Self.sessionA)
|
||||
#expect(source.epoch == VoiceEpoch(sessionId: Self.sessionA, generation: 1))
|
||||
}
|
||||
|
||||
// MARK: - E. 权限与失败路径(49–52)
|
||||
|
||||
@Test("49 · 麦克风授权被拒 → .denied(.microphone),零录音零注入")
|
||||
func microphoneDenialBlocksDictation() async {
|
||||
let harness = Harness()
|
||||
harness.dictation.authorization = .deniedMicrophone
|
||||
|
||||
await harness.viewModel.pressDown()
|
||||
|
||||
#expect(harness.viewModel.phase == .denied(.microphone))
|
||||
#expect(harness.dictation.startCount == 0)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(VoiceDenialReason.microphone.message.contains("麦克风"))
|
||||
}
|
||||
|
||||
@Test("50 · 语音识别授权被拒 → .denied(.speech),文案指向语音识别开关")
|
||||
func speechDenialBlocksDictation() async {
|
||||
let harness = Harness()
|
||||
harness.dictation.authorization = .deniedSpeech
|
||||
|
||||
await harness.viewModel.pressDown()
|
||||
|
||||
#expect(harness.viewModel.phase == .denied(.speech))
|
||||
#expect(harness.dictation.startCount == 0)
|
||||
#expect(VoiceDenialReason.speech.message.contains("语音识别"))
|
||||
}
|
||||
|
||||
@Test("51 · 识别器抛错 → .failed(message:),零注入,可再按重试")
|
||||
func recognizerFailureIsRecoverable() async {
|
||||
struct Boom: Error {}
|
||||
let harness = Harness()
|
||||
harness.dictation.startError = Boom()
|
||||
|
||||
await harness.viewModel.pressDown()
|
||||
guard case .failed(let message) = harness.viewModel.phase else {
|
||||
Issue.record("期望 .failed,实际 \(harness.viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(!message.isEmpty)
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
|
||||
harness.dictation.startError = nil
|
||||
await harness.viewModel.pressDown()
|
||||
#expect(harness.viewModel.phase == .listening(partial: ""))
|
||||
}
|
||||
|
||||
@Test("52 · 终端只读 → pressDown 拒绝,不启动录音")
|
||||
func readOnlyTerminalRefusesDictation() async {
|
||||
let harness = Harness(isReadOnly: true)
|
||||
|
||||
await harness.viewModel.pressDown()
|
||||
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
#expect(harness.viewModel.lastResolution == .readOnly)
|
||||
#expect(harness.dictation.startCount == 0)
|
||||
}
|
||||
|
||||
@Test("PTT 竞态 A:录音启动期间松手 → 启动完立刻收尾(麦克风绝不卡在开着)")
|
||||
func releaseDuringStartStillFinishes() async {
|
||||
let harness = Harness()
|
||||
harness.dictation.startGate.isArmed = true
|
||||
harness.dictation.result = VoiceDictationResult(
|
||||
transcript: "git status", confidence: nil
|
||||
)
|
||||
|
||||
let down = Task { await harness.viewModel.pressDown() }
|
||||
await harness.dictation.startGate.waitUntilReached()
|
||||
let up = Task { await harness.viewModel.pressUp() }
|
||||
await up.value
|
||||
harness.dictation.startGate.open()
|
||||
await down.value
|
||||
|
||||
#expect(harness.dictation.stopCount == 1)
|
||||
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
|
||||
intent: .text("git status"),
|
||||
epoch: VoiceEpoch(sessionId: nil, generation: 0),
|
||||
preview: "git status"
|
||||
)))
|
||||
}
|
||||
|
||||
@Test("PTT 竞态 B:授权期间就松手 → 麦克风一次都不开(零 start)")
|
||||
func releaseDuringAuthorizationNeverOpensTheMic() async {
|
||||
let harness = Harness()
|
||||
harness.dictation.authorizationGate.isArmed = true
|
||||
|
||||
let down = Task { await harness.viewModel.pressDown() }
|
||||
await harness.dictation.authorizationGate.waitUntilReached()
|
||||
let up = Task { await harness.viewModel.pressUp() }
|
||||
await up.value
|
||||
harness.dictation.authorizationGate.open()
|
||||
await down.value
|
||||
|
||||
#expect(harness.dictation.startCount == 0)
|
||||
#expect(harness.dictation.stopCount == 0)
|
||||
#expect(harness.viewModel.phase == .idle)
|
||||
}
|
||||
|
||||
// MARK: - F. 键栏集成 + gate 桥(53–56)
|
||||
|
||||
@Test("53 · 不提供语音闭包 → 无麦克风键,按钮数不变(零回归)")
|
||||
func keyBarWithoutVoiceHandlersIsUnchanged() {
|
||||
let bar = KeyBarView()
|
||||
|
||||
#expect(bar.voiceButton == nil)
|
||||
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
|
||||
}
|
||||
|
||||
@Test("54 · 提供语音闭包 → 末尾多一个 🎤 键,前 17 键顺序/标签完全不变")
|
||||
func keyBarAppendsMicWithoutDisturbingExistingKeys() {
|
||||
let bar = KeyBarView(voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}))
|
||||
|
||||
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
|
||||
#expect(bar.keyButtons.map(\.accessibilityLabel)
|
||||
== KeyBarLayout.buttons.map(\.title))
|
||||
let mic = bar.voiceButton
|
||||
#expect(mic != nil)
|
||||
#expect(mic?.accessibilityLabel == KeyBarLayout.voiceTitle)
|
||||
}
|
||||
|
||||
@Test("55 · 麦克风键 touchDown/touchUpInside 各触发一次,绝不经 onKey")
|
||||
func micButtonRoutesPressAndReleaseOnly() throws {
|
||||
var down = 0
|
||||
var up = 0
|
||||
var keys: [KeyByteMap.Key] = []
|
||||
let bar = KeyBarView(voice: KeyBarVoiceHandlers(
|
||||
onDown: { down += 1 },
|
||||
onUp: { up += 1 }
|
||||
))
|
||||
bar.onKey = { key in keys = keys + [key] }
|
||||
let mic = try #require(bar.voiceButton)
|
||||
|
||||
mic.sendActions(for: .touchDown)
|
||||
#expect((down, up) == (1, 0))
|
||||
|
||||
mic.sendActions(for: .touchUpInside)
|
||||
#expect((down, up) == (1, 1))
|
||||
#expect(keys.isEmpty)
|
||||
}
|
||||
|
||||
@Test("56 · 匹配到 approve → 同一 1.5s 窗口,到期调 decide(.approve) 而不注入文本")
|
||||
func approveIntentGoesThroughTheSameWindow() async {
|
||||
let harness = Harness(gate: VoiceGateSnapshot(pendingApproval: true, gate: .tool))
|
||||
await harness.dictate("确认", confidence: 0.9)
|
||||
|
||||
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
|
||||
intent: .decision(.approve),
|
||||
epoch: VoiceEpoch(sessionId: nil, generation: 0),
|
||||
preview: "确认"
|
||||
)))
|
||||
|
||||
harness.viewModel.confirm()
|
||||
await harness.elapseUndoWindow()
|
||||
|
||||
#expect(harness.recorder.decisions == [.approve])
|
||||
#expect(harness.recorder.texts.isEmpty)
|
||||
#expect(harness.viewModel.lastResolution == .committed(.decision(.approve)))
|
||||
}
|
||||
}
|
||||
391
ios/App/WebTermTests/WorktreeViewModelTests.swift
Normal file
391
ios/App/WebTermTests/WorktreeViewModelTests.swift
Normal file
@@ -0,0 +1,391 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-32 · worktree 生命周期(create / prune / remove)的 App 层归约。
|
||||
///
|
||||
/// RED 清单见 `docs/plans/ios-completion.md` §4(A 1–9、B 10–17、C 18–21、D 22–27)。
|
||||
/// APIClient 侧的 builder/请求体/状态码映射已由 B1 单测覆盖(125 tests),本套只测
|
||||
/// VM:校验先行、破坏性操作必须显式确认、服务器安全文案原样上浮。
|
||||
@MainActor
|
||||
@Suite("WorktreeViewModel")
|
||||
struct WorktreeViewModelTests {
|
||||
|
||||
// MARK: - A. 分支名校验(逐条镜像 public/projects.ts:982 validateBranchNameClient)
|
||||
|
||||
@Test("空分支名 → 报错")
|
||||
func emptyBranchRejected() {
|
||||
#expect(WorktreeBranchRule.validate("") != nil)
|
||||
}
|
||||
|
||||
@Test("长度:250 通过、251 报错")
|
||||
func lengthBoundary() {
|
||||
let ok = String(repeating: "a", count: 250)
|
||||
|
||||
#expect(WorktreeBranchRule.validate(ok) == nil)
|
||||
#expect(WorktreeBranchRule.validate(ok + "a") != nil)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"非法形态一律报错(空格/控制字符/前导-/../.lock/特殊字符/@{/斜杠误用)",
|
||||
arguments: [
|
||||
"feat x", "feat\tx", "feat\u{0}x", "feat\u{7f}x",
|
||||
"-feat", "feat..x", "feat.lock",
|
||||
"feat~x", "feat^x", "feat:x", "feat?x", "feat*x", "feat[x", "feat\\x",
|
||||
"feat@{1}", "/feat", "feat/", "feat//x",
|
||||
]
|
||||
)
|
||||
func invalidShapesRejected(branch: String) {
|
||||
#expect(WorktreeBranchRule.validate(branch) != nil, "\(branch) 应被拒绝")
|
||||
}
|
||||
|
||||
@Test("合法分支名通过", arguments: ["feat/x", "v1.2-fix", "worktree-ios_32"])
|
||||
func validNamesAccepted(branch: String) {
|
||||
#expect(WorktreeBranchRule.validate(branch) == nil)
|
||||
}
|
||||
|
||||
// MARK: - B. 创建
|
||||
|
||||
@Test("非法分支名 → 一次请求都不发(校验在边界,先于网络)")
|
||||
func invalidBranchSkipsNetwork() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(spy: spy)
|
||||
vm.branchText = "-bad"
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(await spy.createCalls == 0)
|
||||
#expect(vm.createPhase != .creating)
|
||||
if case .failed(let message) = vm.createPhase {
|
||||
#expect(!message.isEmpty)
|
||||
} else {
|
||||
Issue.record("应为 .failed,实际 \(vm.createPhase)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("成功 → 采用服务器返回的 canonical path/branch(不回显本地输入)")
|
||||
func createAdoptsServerTruth() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
create: { _, _ in
|
||||
.ok(CreateWorktreeResult(path: "/repos/wt/feat-x", branch: "feat/x"))
|
||||
}
|
||||
)
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(vm.createPhase == .created(path: "/repos/wt/feat-x", branch: "feat/x"))
|
||||
}
|
||||
|
||||
@Test("base 留空 → 请求体 base 为 nil(服务器读作「从 HEAD 切」,绝不送空串)")
|
||||
func blankBaseBecomesNil() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(spy: spy)
|
||||
vm.branchText = "feat/x"
|
||||
vm.baseText = " "
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(await spy.lastBase == nil)
|
||||
}
|
||||
|
||||
@Test("填了 base → 去空白后原样送出")
|
||||
func trimmedBaseIsSent() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(spy: spy)
|
||||
vm.branchText = "feat/x"
|
||||
vm.baseText = " develop "
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(await spy.lastBase == "develop")
|
||||
}
|
||||
|
||||
@Test("403(kill-switch 或 Origin)→ 原样显示服务器安全文案")
|
||||
func rejectionSurfacesServerMessage() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
create: { _, _ in .rejected(status: 403, message: "Worktrees are disabled.") }
|
||||
)
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(vm.createPhase == .failed("Worktrees are disabled."))
|
||||
}
|
||||
|
||||
@Test("500 且服务器没给 message → 非空兜底中文文案")
|
||||
func rejectionWithoutMessageFallsBack() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
create: { _, _ in .rejected(status: 500, message: nil) }
|
||||
)
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
await vm.create()
|
||||
|
||||
if case .failed(let message) = vm.createPhase {
|
||||
#expect(!message.isEmpty)
|
||||
#expect(message.contains("500"))
|
||||
} else {
|
||||
Issue.record("应为 .failed,实际 \(vm.createPhase)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("429 → 限流专用文案,且不自动重试(只发一次)")
|
||||
func rateLimitedIsNotRetried() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(spy: spy, create: { _, _ in .rateLimited })
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(await spy.createCalls == 1)
|
||||
#expect(vm.createPhase == .failed(GitWriteFeedback.rateLimited))
|
||||
}
|
||||
|
||||
@Test("抛出 .unauthorized → 引导补令牌的话术(与 500 区分)")
|
||||
func unauthorizedUsesTokenCopy() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
create: { _, _ in throw APIClientError.unauthorized }
|
||||
)
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
await vm.create()
|
||||
|
||||
#expect(vm.createPhase == .failed(APIClientError.unauthorized.message))
|
||||
}
|
||||
|
||||
@Test("创建进行中重复提交 → 只发一次请求")
|
||||
func duplicateCreateSendsOneRequest() async {
|
||||
let gate = WorktreeGate()
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
create: { _, _ in
|
||||
await gate.wait()
|
||||
return .ok(CreateWorktreeResult(path: "/repos/wt", branch: "feat/x"))
|
||||
}
|
||||
)
|
||||
vm.branchText = "feat/x"
|
||||
|
||||
let first = Task { await vm.create() }
|
||||
await Self.spin(until: { vm.createPhase == .creating })
|
||||
await vm.create() // 忙态:应被吞掉
|
||||
#expect(await spy.createCalls == 1)
|
||||
|
||||
await gate.release()
|
||||
await first.value
|
||||
}
|
||||
|
||||
// MARK: - C. prune(破坏性 → 调用方确认后才进 VM)
|
||||
|
||||
@Test("pruned 为空 → 幂等文案,非错误态")
|
||||
func pruneEmptyIsIdempotent() async {
|
||||
let vm = Self.makeViewModel(spy: WorktreeCallSpy(), prune: { .ok(PruneWorktreesResult(pruned: [])) })
|
||||
|
||||
await vm.prune()
|
||||
|
||||
#expect(vm.prunePhase == .done(WorktreeCopy.pruneNothing))
|
||||
}
|
||||
|
||||
@Test("pruned 有 2 条 → 文案带数量")
|
||||
func pruneReportsCount() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
prune: { .ok(PruneWorktreesResult(pruned: ["worktrees/a", "worktrees/b"])) }
|
||||
)
|
||||
|
||||
await vm.prune()
|
||||
|
||||
#expect(vm.prunePhase == .done(WorktreeCopy.pruneDone(2)))
|
||||
}
|
||||
|
||||
@Test("prune 404 → 显示服务器文案")
|
||||
func pruneRejectionSurfacesMessage() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
prune: { .rejected(status: 404, message: "Not a git repository.") }
|
||||
)
|
||||
|
||||
await vm.prune()
|
||||
|
||||
#expect(vm.prunePhase == .failed("Not a git repository."))
|
||||
}
|
||||
|
||||
// MARK: - D. remove(两级确认)
|
||||
|
||||
@Test("主 worktree / 已锁定 worktree → 不提供删除入口")
|
||||
func mainAndLockedAreNotRemovable() {
|
||||
let main = WorktreeInfo(
|
||||
path: "/repos/r", branch: "develop", head: "a", isMain: true,
|
||||
isCurrent: true, locked: nil, prunable: nil
|
||||
)
|
||||
let locked = WorktreeInfo(
|
||||
path: "/repos/wt", branch: "feat/x", head: "b", isMain: false,
|
||||
isCurrent: false, locked: true, prunable: nil
|
||||
)
|
||||
let plain = WorktreeInfo(
|
||||
path: "/repos/wt2", branch: "feat/y", head: "c", isMain: false,
|
||||
isCurrent: false, locked: false, prunable: true
|
||||
)
|
||||
|
||||
#expect(!WorktreeViewModel.canRemove(main))
|
||||
#expect(!WorktreeViewModel.canRemove(locked))
|
||||
#expect(WorktreeViewModel.canRemove(plain))
|
||||
}
|
||||
|
||||
@Test("第一次确认 → force: false")
|
||||
func firstRemoveIsNotForced() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(spy: spy, remove: { _, _ in .ok(RemoveWorktreeResult(path: "/repos/wt")) })
|
||||
|
||||
await vm.remove(worktreePath: "/repos/wt")
|
||||
|
||||
#expect(await spy.removeForceFlags == [false])
|
||||
#expect(vm.removePhase == .removed(path: "/repos/wt"))
|
||||
}
|
||||
|
||||
@Test("409(脏工作树)→ 进入二次确认,绝不自动 force")
|
||||
func dirtyTreeAsksBeforeForcing() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
remove: { _, force in
|
||||
force
|
||||
? .ok(RemoveWorktreeResult(path: "/repos/wt"))
|
||||
: .rejected(status: 409, message: "Worktree has uncommitted changes — force required.")
|
||||
}
|
||||
)
|
||||
|
||||
await vm.remove(worktreePath: "/repos/wt")
|
||||
|
||||
#expect(await spy.removeForceFlags == [false])
|
||||
#expect(vm.removePhase == .forceConfirming(
|
||||
path: "/repos/wt",
|
||||
message: "Worktree has uncommitted changes — force required."
|
||||
))
|
||||
}
|
||||
|
||||
@Test("二次确认取消 → 不发第二次请求")
|
||||
func cancellingForceSendsNothing() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
remove: { _, _ in .rejected(status: 409, message: "dirty") }
|
||||
)
|
||||
|
||||
await vm.remove(worktreePath: "/repos/wt")
|
||||
vm.cancelForceRemove()
|
||||
|
||||
#expect(await spy.removeForceFlags == [false])
|
||||
#expect(vm.removePhase == .idle)
|
||||
}
|
||||
|
||||
@Test("二次确认通过 → 第二次请求 force: true")
|
||||
func confirmingForceRetriesWithForce() async {
|
||||
let spy = WorktreeCallSpy()
|
||||
let vm = Self.makeViewModel(
|
||||
spy: spy,
|
||||
remove: { _, force in
|
||||
force
|
||||
? .ok(RemoveWorktreeResult(path: "/repos/wt"))
|
||||
: .rejected(status: 409, message: "dirty")
|
||||
}
|
||||
)
|
||||
|
||||
await vm.remove(worktreePath: "/repos/wt")
|
||||
await vm.confirmForceRemove()
|
||||
|
||||
#expect(await spy.removeForceFlags == [false, true])
|
||||
#expect(vm.removePhase == .removed(path: "/repos/wt"))
|
||||
}
|
||||
|
||||
@Test("400(非 409)→ 直接失败,不进 force 分支")
|
||||
func nonConflictRejectionNeverOffersForce() async {
|
||||
let vm = Self.makeViewModel(
|
||||
spy: WorktreeCallSpy(),
|
||||
remove: { _, _ in .rejected(status: 400, message: "Cannot remove the main worktree.") }
|
||||
)
|
||||
|
||||
await vm.remove(worktreePath: "/repos/r")
|
||||
|
||||
#expect(vm.removePhase == .failed("Cannot remove the main worktree."))
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static func makeViewModel(
|
||||
spy: WorktreeCallSpy,
|
||||
create: (@Sendable (String, String?) async throws -> GitWriteOutcome<CreateWorktreeResult>)? = nil,
|
||||
prune: (@Sendable () async throws -> GitWriteOutcome<PruneWorktreesResult>)? = nil,
|
||||
remove: (@Sendable (String, Bool) async throws -> GitWriteOutcome<RemoveWorktreeResult>)? = nil
|
||||
) -> WorktreeViewModel {
|
||||
WorktreeViewModel(dependencies: WorktreeViewModel.Dependencies(
|
||||
create: { branch, base in
|
||||
await spy.recordCreate(base: base)
|
||||
if let create { return try await create(branch, base) }
|
||||
return .ok(CreateWorktreeResult(path: "/repos/wt", branch: branch))
|
||||
},
|
||||
prune: {
|
||||
await spy.recordPrune()
|
||||
if let prune { return try await prune() }
|
||||
return .ok(PruneWorktreesResult(pruned: []))
|
||||
},
|
||||
remove: { path, force in
|
||||
await spy.recordRemove(force: force)
|
||||
if let remove { return try await remove(path, force) }
|
||||
return .ok(RemoveWorktreeResult(path: path))
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
/// 有界自旋:等一个 MainActor 状态成立(永不无限挂起)。
|
||||
private static func spin(
|
||||
until condition: @MainActor () -> Bool, maxYields: Int = 1_000
|
||||
) async {
|
||||
for _ in 0..<maxYields where !condition() {
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 依赖调用记录(@Sendable 闭包里的可变状态 → actor 隔离)。
|
||||
private actor WorktreeCallSpy {
|
||||
private(set) var createCalls = 0
|
||||
private(set) var lastBase: String?
|
||||
private(set) var pruneCalls = 0
|
||||
private(set) var removeForceFlags: [Bool] = []
|
||||
|
||||
func recordCreate(base: String?) {
|
||||
createCalls += 1
|
||||
lastBase = base
|
||||
}
|
||||
|
||||
func recordPrune() {
|
||||
pruneCalls += 1
|
||||
}
|
||||
|
||||
func recordRemove(force: Bool) {
|
||||
removeForceFlags.append(force)
|
||||
}
|
||||
}
|
||||
|
||||
/// 让一次依赖调用停在半空中,以便测试忙态去重。
|
||||
private actor WorktreeGate {
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func wait() async {
|
||||
await withCheckedContinuation { waiters.append($0) }
|
||||
}
|
||||
|
||||
func release() {
|
||||
let pending = waiters
|
||||
waiters = []
|
||||
pending.forEach { $0.resume() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user