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:
143
ios/App/WebTerm/Components/VoicePTTBanner.swift
Normal file
143
ios/App/WebTerm/Components/VoicePTTBanner.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-31 · 语音 PTT 的 SwiftUI 状态卡。逻辑全在 `VoicePTT.swift` 的
|
||||
/// `VoicePTTViewModel` 里 —— 本文件只渲染,从 `VoicePTT.swift` 拆出是为了守住
|
||||
/// 800 行硬上限(plan §4)。
|
||||
|
||||
/// PTT 状态卡:听写中 / 待确认 / 撤销窗口 / 被拒 / 失败 / 结果提示。
|
||||
/// 底部对齐(贴着麦克风键所在的键栏);nil 状态不渲染任何东西。
|
||||
struct VoicePTTBanner: View {
|
||||
let model: VoicePTTViewModel
|
||||
|
||||
/// `.idle` 且没有结果提示 ⇒ 整张卡不渲染(终端全屏不被占)。
|
||||
private var hasCard: Bool {
|
||||
if case .idle = model.phase { return model.noticeText != nil }
|
||||
return true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if hasCard {
|
||||
card
|
||||
.padding(DS.Space.md12)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
.accessibilityIdentifier("terminal.voice.card")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var card: some View {
|
||||
switch model.phase {
|
||||
case .requestingPermission:
|
||||
label(VoicePTTViewModel.Copy.requesting, systemImage: "mic.badge.plus")
|
||||
case .listening(let partial):
|
||||
listening(partial: partial)
|
||||
case .confirming(let pending):
|
||||
confirming(pending)
|
||||
case .undoWindow(let pending):
|
||||
undoWindow(pending)
|
||||
case .denied(let reason):
|
||||
notice(reason.message, systemImage: "mic.slash")
|
||||
case .failed(let message):
|
||||
notice(message, systemImage: "exclamationmark.triangle")
|
||||
case .idle:
|
||||
if let text = model.noticeText {
|
||||
notice(text, systemImage: "info.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func label(_ text: String, systemImage: String) -> some View {
|
||||
Label(text, systemImage: systemImage)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
|
||||
private func listening(partial: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
label(VoicePTTViewModel.Copy.listening, systemImage: "waveform")
|
||||
if !partial.isEmpty {
|
||||
Text(partial)
|
||||
.font(DS.Typography.mono(.callout))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.accessibilityIdentifier("terminal.voice.partial")
|
||||
}
|
||||
Text(VoicePTTViewModel.Copy.listeningHint)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private func confirming(_ pending: VoicePendingAction) -> some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||||
Text(title(for: pending.intent))
|
||||
.font(DS.Typography.callout.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(pending.preview)
|
||||
.font(DS.Typography.mono(.callout))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.accessibilityIdentifier("terminal.voice.preview")
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
Button(VoicePTTViewModel.Copy.cancel) { model.cancel() }
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("terminal.voice.cancelButton")
|
||||
Button(VoicePTTViewModel.Copy.send) { model.confirm() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.accessibilityIdentifier("terminal.voice.confirmButton")
|
||||
}
|
||||
.frame(minHeight: DS.Layout.minHitTarget)
|
||||
}
|
||||
}
|
||||
|
||||
private func undoWindow(_ pending: VoicePendingAction) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
Text(pending.intent.isDecision
|
||||
? VoicePTTViewModel.Copy.deciding
|
||||
: VoicePTTViewModel.Copy.sending)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
Text(pending.preview)
|
||||
.font(DS.Typography.mono(.callout))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
Button(VoicePTTViewModel.Copy.undo) { model.undo() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(minHeight: DS.Layout.minHitTarget)
|
||||
.accessibilityIdentifier("terminal.voice.undoButton")
|
||||
}
|
||||
}
|
||||
|
||||
private func notice(_ text: String, systemImage: String) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
label(text, systemImage: systemImage)
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
Button {
|
||||
model.clearNotice()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(VoicePTTViewModel.Copy.dismissNotice)
|
||||
}
|
||||
}
|
||||
|
||||
private func title(for intent: VoiceIntent) -> String {
|
||||
switch intent {
|
||||
case .text: VoicePTTViewModel.Copy.confirmTitle
|
||||
case .decision(.approve): VoicePTTViewModel.Copy.approveTitle
|
||||
case .decision(.reject): VoicePTTViewModel.Copy.rejectTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VoiceIntent {
|
||||
var isDecision: Bool {
|
||||
if case .decision = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user