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:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

View File

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

View 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 **** 12
@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)
}
}
}
}

View 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()
}
/// webEnter = nextShift+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)
}
}

View File

@@ -0,0 +1,579 @@
import Foundation
import Observation
/// T-iOS-31 · PTT+ + 1.5s + epoch
///
/// = + + 800
/// `VoicePTTBanner.swift`SwiftUI `SpeechDictation.swift`
///
/// webplan §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'tdont 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
}
}

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View 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 "未跟踪"
}
}
}

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

View 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 =
"访问令牌格式不符合要求16512 位,且只能包含 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或反馈该网段。"
}
}

View File

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

View File

@@ -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 / removecreate 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: - WorktreesT-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。"

View File

@@ -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-32C2
onResumeClaude: { cwd, sessionId in
viewModel.requestResumeClaude(cwd: cwd, sessionId: sessionId)
}
)
}
// T-iPad-4 · iPhone sheet iPad

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

View File

@@ -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 = "主机"

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

View File

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

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

View 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
)
}
/// fetchnil " 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
}

View 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. ****PRgh
/// ****""
/// 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)" }
}

View File

@@ -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或反馈该网段。"
}
}

View File

@@ -178,6 +178,28 @@ final class ProjectsViewModel {
)
}
/// T-iOS-32C2· **** `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 {

View 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
/// cwdworktree 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) 的会话" }
}

View File

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

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

View File

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

View File

@@ -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 = "设备证书自动续期失败,将在下次前台重试"

View File

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

View File

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