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