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