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

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