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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user