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

@@ -0,0 +1,635 @@
import Foundation
import SessionCore
import TestSupport
import Testing
import UIKit
@testable import WebTerm
/// T-iOS-31 · PTT + ios-completion §4 RED 1956
///
/// web`public/voice.ts`PTT `autoSend:false`
/// `public/voice-commands.ts` + + 0.6
/// `public/voice-confirm.ts`1500ms epoch 沿
/// `SessionCore/GateState.canDecide(epoch:)`
///
/// DEFERRED
/// ** / / epoch **FakeClock
@MainActor
@Suite("VoicePTT (T-iOS-31)")
struct VoicePTTTests {
// MARK: - Fakes
/// `await`
/// MainActor
@MainActor
private final class Gate {
var isArmed = false
private var didReach = false
private var blocked: CheckedContinuation<Void, Never>?
private var arrival: CheckedContinuation<Void, Never>?
/// armed
func passThrough() async {
guard isArmed else { return }
didReach = true
arrival?.resume()
arrival = nil
await withCheckedContinuation { blocked = $0 }
}
///
func waitUntilReached() async {
guard !didReach else { return }
await withCheckedContinuation { arrival = $0 }
}
func open() {
isArmed = false
let blocked = self.blocked
self.blocked = nil
blocked?.resume()
}
}
///
/// PTT
@MainActor
private final class FakeDictation: VoiceDictating {
var authorization: VoiceAuthorization = .granted
var startError: (any Error)?
var result = VoiceDictationResult(transcript: "", confidence: nil)
private(set) var startCount = 0
private(set) var stopCount = 0
private(set) var partialSink: (@MainActor (String) -> Void)?
let authorizationGate = Gate()
let startGate = Gate()
func requestAuthorization() async -> VoiceAuthorization {
await authorizationGate.passThrough()
return authorization
}
func start(onPartial: @escaping @MainActor (String) -> Void) async throws {
startCount += 1
if let startError { throw startError }
partialSink = onPartial
await startGate.passThrough()
}
func stop() async -> VoiceDictationResult {
stopCount += 1
partialSink = nil
return result
}
}
/// epoch /
@MainActor
private final class EpochBox {
var epoch: VoiceEpoch
init(_ epoch: VoiceEpoch = VoiceEpoch(sessionId: nil, generation: 0)) {
self.epoch = epoch
}
}
/// /
@MainActor
private final class InjectRecorder {
private(set) var texts: [String] = []
private(set) var decisions: [VoiceDecision] = []
func inject(_ text: String) { texts = texts + [text] }
func decide(_ decision: VoiceDecision) { decisions = decisions + [decision] }
}
@MainActor
private struct Harness {
let dictation = FakeDictation()
let clock = FakeClock()
let epochBox = EpochBox()
let recorder = InjectRecorder()
let viewModel: VoicePTTViewModel
init(isReadOnly: Bool = false, gate: VoiceGateSnapshot? = nil) {
let dictation = self.dictation
let clock = self.clock
let epochBox = self.epochBox
let recorder = self.recorder
let bridge: VoiceGateBridge? = gate.map { snapshot in
VoiceGateBridge(
snapshot: { snapshot },
decide: { decision in recorder.decide(decision) }
)
}
viewModel = VoicePTTViewModel(
dictation: dictation,
clock: clock,
epoch: { epochBox.epoch },
isReadOnly: { isReadOnly },
inject: { text in recorder.inject(text) },
gate: bridge
)
}
/// `dictation.result`
func dictate(_ transcript: String, confidence: Double? = nil) async {
dictation.result = VoiceDictationResult(
transcript: transcript, confidence: confidence
)
await viewModel.pressDown()
await viewModel.pressUp()
}
///
func elapseUndoWindow() async {
await clock.waitForSleepers(count: 1)
clock.advance(by: VoicePTTViewModel.undoWindow)
await viewModel.waitUntilWindowSettled()
}
}
private static let sessionA = UUID()
private static let sessionB = UUID()
// MARK: - A. 1924
@Test("19 · 普通文本 → 首尾 trim 后原样")
func sanitizeKeepsPlainText() {
#expect(VoiceTranscript.sanitize(" git status ") == "git status")
}
@Test("20 · \\r0x0D被剥除 —— 口述绝不自己回车执行命令")
func sanitizeStripsCarriageReturn() {
let sanitized = VoiceTranscript.sanitize("rm -rf /\r")
#expect(!sanitized.contains("\r"))
#expect(sanitized == "rm -rf /")
}
@Test("21 · C0/C1 控制字符(\\n \\t ESC BEL DEL全部剥除")
func sanitizeStripsControlCharacters() {
let raw = "ec\u{1b}ho\u{7}\t hi\u{7f}\u{0}\u{9b}"
let sanitized = VoiceTranscript.sanitize(raw)
#expect(sanitized == "echo hi")
}
@Test("22 · 多行折成单行(换行→空格 + 合并连续空白)")
func sanitizeCollapsesToOneLine() {
#expect(VoiceTranscript.sanitize("git\ncommit -m\n\n test") == "git commit -m test")
}
@Test("23 · 超长转写截断到上限")
func sanitizeTruncatesOverlongTranscript() {
let raw = String(repeating: "a", count: VoiceTranscript.maxLength + 500)
#expect(VoiceTranscript.sanitize(raw).count == VoiceTranscript.maxLength)
}
@Test("24 · 清洗后为空 → 不可注入")
func sanitizeEmptyIsNotInjectable() {
#expect(!VoiceTranscript.isInjectable(VoiceTranscript.sanitize("\u{1b}\r\n \t")))
#expect(VoiceTranscript.isInjectable("ok"))
}
// MARK: - B. 2533
private func context(
pendingApproval: Bool, gate: VoiceGateKind?, confidence: Double? = nil
) -> VoiceMatchContext {
VoiceMatchContext(pendingApproval: pendingApproval, gate: gate, confidence: confidence)
}
@Test("25 · 无 held gate → 任何话语都是 .text含「确认」")
func matcherFallsThroughWithoutHeldGate() {
let ctx = context(pendingApproval: false, gate: .tool)
#expect(VoiceCommandMatcher.match(transcript: "确认", context: ctx) == .text)
#expect(VoiceCommandMatcher.match(transcript: "approve", context: ctx) == .text)
}
@Test("26 · gate 是 .plan → .textv1 只作用 tool gate")
func matcherIgnoresPlanGate() {
let ctx = context(pendingApproval: true, gate: .plan)
#expect(VoiceCommandMatcher.match(transcript: "确认", context: ctx) == .text)
}
@Test("27 · tool gate + 肯定短语 → .approve")
func matcherApprovesAffirmativePhrases() {
let ctx = context(pendingApproval: true, gate: .tool)
for phrase in ["确认", "批准", "同意", "好的", "ok", "go ahead", "proceed"] {
#expect(VoiceCommandMatcher.match(transcript: phrase, context: ctx) == .approve,
"\(phrase) 应判 approve")
}
}
@Test("28 · 归一化:大小写 + 去标点 + 合并空白don't → dont")
func matcherNormalizesTranscript() {
let ctx = context(pendingApproval: true, gate: .tool)
#expect(VoiceCommandMatcher.match(transcript: "OK.", context: ctx) == .approve)
#expect(VoiceCommandMatcher.match(transcript: "好的!", context: ctx) == .approve)
#expect(VoiceCommandMatcher.normalize("Don't DO it") == "dont do it")
}
@Test("29 · 整句精确匹配 —— 「确认一下这个」落 .text绝不子串匹配")
func matcherIsWholeUtteranceExact() {
let ctx = context(pendingApproval: true, gate: .tool)
#expect(VoiceCommandMatcher.match(transcript: "确认一下这个", context: ctx) == .text)
#expect(VoiceCommandMatcher.match(transcript: "ok let's ship it", context: ctx) == .text)
}
@Test("30 · tool gate + 否定短语 → .reject")
func matcherRejectsNegativePhrases() {
let ctx = context(pendingApproval: true, gate: .tool)
for phrase in ["拒绝", "取消", "stop", "abort", "no"] {
#expect(VoiceCommandMatcher.match(transcript: phrase, context: ctx) == .reject,
"\(phrase) 应判 reject")
}
}
@Test("31 · 否定守卫:「不批准」→ reject「不清楚」→ textnow 不被 no 否定")
func matcherNegationGuard() {
let ctx = context(pendingApproval: true, gate: .tool)
#expect(VoiceCommandMatcher.match(transcript: "不批准", context: ctx) == .reject)
#expect(VoiceCommandMatcher.match(transcript: "不清楚", context: ctx) == .text)
#expect(VoiceCommandMatcher.match(transcript: "now", context: ctx) == .text)
#expect(VoiceCommandMatcher.startsWithNegation("不批准"))
#expect(!VoiceCommandMatcher.startsWithNegation("now"))
}
@Test("32 · 置信度门approve 低于 0.6 降级 textreject 不受影响")
func matcherConfidenceGateAppliesToApproveOnly() {
let low = context(pendingApproval: true, gate: .tool, confidence: 0.4)
#expect(VoiceCommandMatcher.match(transcript: "确认", context: low) == .text)
#expect(VoiceCommandMatcher.match(transcript: "取消", context: low) == .reject)
let atThreshold = context(
pendingApproval: true, gate: .tool,
confidence: VoiceCommandMatcher.minApproveConfidence
)
#expect(VoiceCommandMatcher.match(transcript: "确认", context: atThreshold) == .approve)
}
@Test("33 · 空/纯标点话语 → .text")
func matcherEmptyUtteranceIsText() {
let ctx = context(pendingApproval: true, gate: .tool)
#expect(VoiceCommandMatcher.match(transcript: "", context: ctx) == .text)
#expect(VoiceCommandMatcher.match(transcript: "……", context: ctx) == .text)
}
// MARK: - C. + 1.5s 3443
@Test("34 · pressDown → .listeningpartial 回调更新 partial 文本")
func pressDownStartsListeningAndSurfacesPartials() async {
let harness = Harness()
await harness.viewModel.pressDown()
#expect(harness.viewModel.phase == .listening(partial: ""))
#expect(harness.dictation.startCount == 1)
harness.dictation.partialSink?("git st")
#expect(harness.viewModel.phase == .listening(partial: "git st"))
}
@Test("35 · 空转写 → .idle + .empty + 零注入")
func emptyTranscriptResolvesEmpty() async {
let harness = Harness()
await harness.dictate(" \r\n ")
#expect(harness.viewModel.phase == .idle)
#expect(harness.viewModel.lastResolution == .empty)
#expect(harness.recorder.texts.isEmpty)
}
@Test("36 · 有效转写 → .confirming且此刻零注入确认前绝不注入")
func validTranscriptWaitsForExplicitConfirm() async {
let harness = Harness()
await harness.dictate("git status")
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
intent: .text("git status"),
epoch: VoiceEpoch(sessionId: nil, generation: 0),
preview: "git status"
)))
#expect(harness.recorder.texts.isEmpty)
}
@Test("37 · .confirming 下 cancel() → .idle + .discarded + 零注入")
func cancelDiscardsBeforeInjection() async {
let harness = Harness()
await harness.dictate("git status")
harness.viewModel.cancel()
#expect(harness.viewModel.phase == .idle)
#expect(harness.viewModel.lastResolution == .discarded)
#expect(harness.recorder.texts.isEmpty)
}
@Test("38 · confirm() → .undoWindow此刻仍零注入")
func confirmArmsUndoWindowWithoutInjecting() async {
let harness = Harness()
await harness.dictate("git status")
harness.viewModel.confirm()
#expect(harness.viewModel.isUndoWindowOpen)
#expect(harness.recorder.texts.isEmpty)
}
@Test("39 · +1.4s 仍零注入;到 1.5s → 恰一次注入 + .committed")
func injectionLandsOnlyAfterTheFullUndoWindow() async {
let harness = Harness()
await harness.dictate("git status")
harness.viewModel.confirm()
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: .milliseconds(1400))
#expect(harness.recorder.texts.isEmpty)
harness.clock.advance(by: .milliseconds(100))
await harness.viewModel.waitUntilWindowSettled()
#expect(harness.recorder.texts == ["git status"])
#expect(harness.viewModel.lastResolution == .committed(.text("git status")))
#expect(harness.viewModel.phase == .idle)
}
@Test("40 · 撤销窗口内 undo() → 零注入 + .undone再推进 10s 也不注入")
func undoCancelsTheInjectionForGood() async {
let harness = Harness()
await harness.dictate("git status")
harness.viewModel.confirm()
await harness.clock.waitForSleepers(count: 1)
harness.viewModel.undo()
await harness.viewModel.waitUntilWindowSettled()
harness.clock.advance(by: .seconds(10))
await harness.viewModel.waitUntilWindowSettled()
#expect(harness.recorder.texts.isEmpty)
#expect(harness.viewModel.lastResolution == .undone)
#expect(harness.viewModel.phase == .idle)
}
@Test("41 · 注入内容不以 \\r 结尾(用户自己按 ⏎)")
func injectedTextNeverEndsWithCarriageReturn() async {
let harness = Harness()
await harness.dictate("npm test")
harness.viewModel.confirm()
await harness.elapseUndoWindow()
#expect(harness.recorder.texts == ["npm test"])
#expect(!(harness.recorder.texts.first ?? "\r").hasSuffix("\r"))
}
@Test("42 · 非 .confirming 态调 confirm() → 无副作用")
func confirmOutsideConfirmingIsNoop() async {
let harness = Harness()
harness.viewModel.confirm()
#expect(harness.viewModel.phase == .idle)
#expect(harness.viewModel.lastResolution == nil)
#expect(harness.recorder.texts.isEmpty)
}
@Test("43 · 重复 confirm() 只 arm 一次、只注入一次")
func repeatedConfirmArmsOnce() async {
let harness = Harness()
await harness.dictate("git status")
harness.viewModel.confirm()
harness.viewModel.confirm()
harness.viewModel.confirm()
await harness.elapseUndoWindow()
#expect(harness.recorder.texts == ["git status"])
}
// MARK: - D. epoch 4448
@Test("44 · 口述→确认之间 epoch 变了 → 零注入 + .staleEpoch")
func epochChangeBeforeConfirmDiscards() async {
let harness = Harness()
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
await harness.dictate("git status")
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionB, generation: 0)
harness.viewModel.confirm()
#expect(harness.recorder.texts.isEmpty)
#expect(harness.viewModel.lastResolution == .staleEpoch)
#expect(harness.viewModel.phase == .idle)
}
@Test("45 · epoch 在撤销窗口内变 → 到期仍零注入 + .staleEpoch第二道闸")
func epochChangeInsideUndoWindowDiscards() async {
let harness = Harness()
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
await harness.dictate("git status")
harness.viewModel.confirm()
await harness.clock.waitForSleepers(count: 1)
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 1)
harness.clock.advance(by: VoicePTTViewModel.undoWindow)
await harness.viewModel.waitUntilWindowSettled()
#expect(harness.recorder.texts.isEmpty)
#expect(harness.viewModel.lastResolution == .staleEpoch)
}
@Test("46 · epoch 不变 → 正常注入(防闸门过严的回归保护)")
func stableEpochStillInjects() async {
let harness = Harness()
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 3)
await harness.dictate("ls -la")
harness.viewModel.confirm()
await harness.elapseUndoWindow()
#expect(harness.recorder.texts == ["ls -la"])
}
@Test("47 · 口述时 sessionId 未 adopt、确认时已 adopt → 视为变化 → 零注入")
func dictatingBeforeAdoptionDiscardsAfterAdoption() async {
let harness = Harness()
await harness.dictate("git status")
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
harness.viewModel.confirm()
#expect(harness.recorder.texts.isEmpty)
#expect(harness.viewModel.lastResolution == .staleEpoch)
}
@Test("48 · VoiceEpochPolicy.reconnecting 作废 epoch.connecting/.none 不作废")
func epochPolicyInvalidatesOnReconnect() {
#expect(VoiceEpochPolicy.invalidates(
.reconnecting(attempt: 1, next: .seconds(1))
))
#expect(!VoiceEpochPolicy.invalidates(.connecting))
#expect(!VoiceEpochPolicy.invalidates(.none))
let source = VoiceEpochSource()
#expect(source.epoch == VoiceEpoch(sessionId: nil, generation: 0))
source.noteBanner(.reconnecting(attempt: 1, next: .seconds(1)))
source.noteBanner(.connecting)
source.noteSession(Self.sessionA)
#expect(source.epoch == VoiceEpoch(sessionId: Self.sessionA, generation: 1))
}
// MARK: - E. 4952
@Test("49 · 麦克风授权被拒 → .denied(.microphone),零录音零注入")
func microphoneDenialBlocksDictation() async {
let harness = Harness()
harness.dictation.authorization = .deniedMicrophone
await harness.viewModel.pressDown()
#expect(harness.viewModel.phase == .denied(.microphone))
#expect(harness.dictation.startCount == 0)
#expect(harness.recorder.texts.isEmpty)
#expect(VoiceDenialReason.microphone.message.contains("麦克风"))
}
@Test("50 · 语音识别授权被拒 → .denied(.speech),文案指向语音识别开关")
func speechDenialBlocksDictation() async {
let harness = Harness()
harness.dictation.authorization = .deniedSpeech
await harness.viewModel.pressDown()
#expect(harness.viewModel.phase == .denied(.speech))
#expect(harness.dictation.startCount == 0)
#expect(VoiceDenialReason.speech.message.contains("语音识别"))
}
@Test("51 · 识别器抛错 → .failed(message:),零注入,可再按重试")
func recognizerFailureIsRecoverable() async {
struct Boom: Error {}
let harness = Harness()
harness.dictation.startError = Boom()
await harness.viewModel.pressDown()
guard case .failed(let message) = harness.viewModel.phase else {
Issue.record("期望 .failed实际 \(harness.viewModel.phase)")
return
}
#expect(!message.isEmpty)
#expect(harness.recorder.texts.isEmpty)
harness.dictation.startError = nil
await harness.viewModel.pressDown()
#expect(harness.viewModel.phase == .listening(partial: ""))
}
@Test("52 · 终端只读 → pressDown 拒绝,不启动录音")
func readOnlyTerminalRefusesDictation() async {
let harness = Harness(isReadOnly: true)
await harness.viewModel.pressDown()
#expect(harness.viewModel.phase == .idle)
#expect(harness.viewModel.lastResolution == .readOnly)
#expect(harness.dictation.startCount == 0)
}
@Test("PTT 竞态 A录音启动期间松手 → 启动完立刻收尾(麦克风绝不卡在开着)")
func releaseDuringStartStillFinishes() async {
let harness = Harness()
harness.dictation.startGate.isArmed = true
harness.dictation.result = VoiceDictationResult(
transcript: "git status", confidence: nil
)
let down = Task { await harness.viewModel.pressDown() }
await harness.dictation.startGate.waitUntilReached()
let up = Task { await harness.viewModel.pressUp() }
await up.value
harness.dictation.startGate.open()
await down.value
#expect(harness.dictation.stopCount == 1)
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
intent: .text("git status"),
epoch: VoiceEpoch(sessionId: nil, generation: 0),
preview: "git status"
)))
}
@Test("PTT 竞态 B授权期间就松手 → 麦克风一次都不开(零 start")
func releaseDuringAuthorizationNeverOpensTheMic() async {
let harness = Harness()
harness.dictation.authorizationGate.isArmed = true
let down = Task { await harness.viewModel.pressDown() }
await harness.dictation.authorizationGate.waitUntilReached()
let up = Task { await harness.viewModel.pressUp() }
await up.value
harness.dictation.authorizationGate.open()
await down.value
#expect(harness.dictation.startCount == 0)
#expect(harness.dictation.stopCount == 0)
#expect(harness.viewModel.phase == .idle)
}
// MARK: - F. + gate 5356
@Test("53 · 不提供语音闭包 → 无麦克风键,按钮数不变(零回归)")
func keyBarWithoutVoiceHandlersIsUnchanged() {
let bar = KeyBarView()
#expect(bar.voiceButton == nil)
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
}
@Test("54 · 提供语音闭包 → 末尾多一个 🎤 键,前 17 键顺序/标签完全不变")
func keyBarAppendsMicWithoutDisturbingExistingKeys() {
let bar = KeyBarView(voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}))
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
#expect(bar.keyButtons.map(\.accessibilityLabel)
== KeyBarLayout.buttons.map(\.title))
let mic = bar.voiceButton
#expect(mic != nil)
#expect(mic?.accessibilityLabel == KeyBarLayout.voiceTitle)
}
@Test("55 · 麦克风键 touchDown/touchUpInside 各触发一次,绝不经 onKey")
func micButtonRoutesPressAndReleaseOnly() throws {
var down = 0
var up = 0
var keys: [KeyByteMap.Key] = []
let bar = KeyBarView(voice: KeyBarVoiceHandlers(
onDown: { down += 1 },
onUp: { up += 1 }
))
bar.onKey = { key in keys = keys + [key] }
let mic = try #require(bar.voiceButton)
mic.sendActions(for: .touchDown)
#expect((down, up) == (1, 0))
mic.sendActions(for: .touchUpInside)
#expect((down, up) == (1, 1))
#expect(keys.isEmpty)
}
@Test("56 · 匹配到 approve → 同一 1.5s 窗口,到期调 decide(.approve) 而不注入文本")
func approveIntentGoesThroughTheSameWindow() async {
let harness = Harness(gate: VoiceGateSnapshot(pendingApproval: true, gate: .tool))
await harness.dictate("确认", confidence: 0.9)
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
intent: .decision(.approve),
epoch: VoiceEpoch(sessionId: nil, generation: 0),
preview: "确认"
)))
harness.viewModel.confirm()
await harness.elapseUndoWindow()
#expect(harness.recorder.decisions == [.approve])
#expect(harness.recorder.texts.isEmpty)
#expect(harness.viewModel.lastResolution == .committed(.decision(.approve)))
}
}