Files
web-terminal/ios/App/WebTerm/Screens/TerminalScreen.swift
Yaojia Wang 284cfd193a 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.
2026-07-30 15:58:01 +02:00

464 lines
21 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import GameController
import SessionCore
import SwiftTerm
import SwiftUI
import UIKit
/// T-iOS-11 · The terminal screen: SwiftTerm view + key bar + status banner.
///
/// Data flow (byte-shuttle preserved end to end):
/// - inbound: `SessionEvent.output` `TerminalViewModel` sink `feed(text:)`
/// (opaque ANSI/UTF-8 the app NEVER parses terminal semantics);
/// - outbound: SwiftTerm delegate `send` `viewModel.sendInput`,
/// `sizeChanged` `viewModel.sendResize`; KeyBar taps and hardware
/// `UIKeyCommand`s go through `viewModel.send(key:)` every labelbytes
/// lookup via `KeyByteMap`, bypassing SwiftTerm's text path so the soft
/// keyboard never pops (mirrors the web bar's ws.send bypass).
/// - IME: NO custom keydown/composition interception SwiftTerm manages
/// composition itself (CLAUDE.md gotcha; plan §7 T-iOS-11).
///
/// Navigation/lifecycle wiring (engine open/close, scenePhase) lands in
/// T-iOS-15 this screen only renders and routes.
struct TerminalScreen: View {
let viewModel: TerminalViewModel
/// T-iOS-29 · ""toolbar + exit
/// "" closeopen fresh spawncwd
/// `AppCoordinator.openNewSessionInCurrentCwd`nil =
/// / coordinator
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
/// T-iPad-3 · wiring
/// `APIClient.killSession` Origin G nil = kill
/// /线
var onKillSession: (@MainActor () -> Void)? = nil
/// T-iPad-3 · `GCKeyboard.coalesced != nil`
/// / `KeyBarVisibility`
@State private var hasHardwareKeyboard = GCKeyboard.coalesced != nil
/// 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
private enum Copy {
static let newSessionInCwd = "在当前目录开新会话"
static let showKeyBar = "显示快捷键栏"
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(
hardwareKeyboardPresent: hasHardwareKeyboard,
userOverride: keyBarUserOverride
)
}
var body: some View {
TerminalHostView(
viewModel: viewModel,
keyBarVisible: isKeyBarVisible,
searchModel: searchModel,
voiceModel: voiceModel,
onNewSessionInCwd: onNewSessionInCwd,
onKillSession: onKillSession
)
.ignoresSafeArea(.container, edges: .bottom)
.overlay(alignment: .top) {
if let model = viewModel.bannerModel {
ReconnectBanner(model: model, onNewSession: onNewSessionInCwd)
.padding(.horizontal, DS.Space.md12)
.padding(.top, DS.Space.sm8)
.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
}
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in
hasHardwareKeyboard = true
}
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
hasHardwareKeyboard = GCKeyboard.coalesced != nil
}
// 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
/// iPhone
/// /
@ToolbarContentBuilder private var keyBarToggleToolbarItem: some ToolbarContent {
if hasHardwareKeyboard {
ToolbarItem(placement: .topBarTrailing) {
Button {
keyBarUserOverride = !isKeyBarVisible
} label: {
Label(
isKeyBarVisible ? Copy.hideKeyBar : Copy.showKeyBar,
systemImage: isKeyBarVisible
? "keyboard.chevron.compact.down" : "keyboard"
)
}
.accessibilityIdentifier("terminal.keyBarToggleButton")
}
}
}
/// Mirrors web `tabs.ts newTab()` (M6): the + affordance opens a fresh
/// session in the active session's cwd, if known.
@ToolbarContentBuilder private var newSessionToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
if let onNewSessionInCwd {
Button {
onNewSessionInCwd()
} label: {
Label(Copy.newSessionInCwd, systemImage: "plus.rectangle.on.folder")
}
.accessibilityIdentifier("terminal.newInCwdButton")
}
}
}
}
// MARK: - Terminal theme
/// Refined terminal theme () matching the desktop: a fixed warm-dark
/// canvas (#100F0D bg / #ECE9E3 fg, web --bg/--text) so the terminal reads the
/// same on iOS and desktop regardless of app appearance, with caret/selection
/// in the one gold accent. Every value routes through `DS` (no literals); the
/// glyph font is Dynamic-Type-aware SF Mono so terminal text respects the
/// user's text-size choice at launch.
private enum TerminalTheme {
@MainActor
static func apply(to terminal: TerminalView) {
terminal.font = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
weight: .regular
)
terminal.nativeBackgroundColor = UIColor(DS.Palette.terminalBackground)
terminal.nativeForegroundColor = UIColor(DS.Palette.terminalForeground)
terminal.caretColor = DS.Palette.accentUIColor()
terminal.selectedTextBackgroundColor = DS.Palette.accentUIColor()
}
}
// MARK: - SwiftTerm bridge
/// `UIViewRepresentable` around `SwiftTerm.TerminalView` (plan §3.5). The
/// KeyBar is installed as `inputAccessoryView`; hardware chords come from the
/// `KeyCommandTerminalView` subclass. Both route through the ViewModel.
private struct TerminalHostView: UIViewRepresentable {
let viewModel: TerminalViewModel
/// 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
func makeCoordinator() -> Coordinator {
Coordinator(viewModel: viewModel)
}
func makeUIView(context: Context) -> KeyCommandTerminalView {
let terminal = KeyCommandTerminalView(frame: .zero)
terminal.terminalDelegate = context.coordinator
TerminalTheme.apply(to: terminal)
let viewModel = viewModel
terminal.onKeyCommand = { key in viewModel.send(key: key) }
// 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)
// T-iPad-3 · / iPad iPhone
// SwiftTerm
terminal.installPointerContextMenuIfSupported(
onNewInCwd: onNewSessionInCwd,
onKill: onKillSession
)
// Output sink: buffered replay flushes now, live bytes follow.
// @MainActor-typed closure feeding off the main actor cannot compile.
viewModel.attachTerminalSink { [weak terminal] text in
terminal?.feed(text: text)
}
return terminal
}
func updateUIView(_ uiView: KeyCommandTerminalView, context: Context) {
// State-driven UI lives in SwiftUI (banner overlay); the terminal view
// itself is driven by the sink/delegate. The only push is the KeyBar
// visibility (hardware-keyboard aware / user toggle) a no-op unless it
// actually changed, so iPhone (always-visible) never reloads input views.
uiView.setKeyBarVisible(keyBarVisible)
}
/// SwiftTerm's delegate is a pre-concurrency protocol; the conformance is
/// `@preconcurrency` SwiftTerm only calls it from the main thread (the
/// view itself is `@MainActor`), which the runtime check enforces.
@MainActor
final class Coordinator: NSObject, @preconcurrency TerminalViewDelegate {
private let viewModel: TerminalViewModel
init(viewModel: TerminalViewModel) {
self.viewModel = viewModel
}
/// User typed into SwiftTerm (soft/hardware keyboard, IME result):
/// raw bytes, passed through verbatim (invariant #9).
func send(source: TerminalView, data: ArraySlice<UInt8>) {
viewModel.sendInput(String(decoding: data, as: UTF8.self))
}
/// Layout produced a new grid server `resize` (SIGWINCH). This is
/// also the latest-writer-wins size claim (v0.4 sizing model).
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
guard newCols > 0, newRows > 0 else { return } // pre-layout noise
viewModel.sendResize(cols: newCols, rows: newRows)
}
/// Tapped link (OSC 8 / detected URL mirrors web M2 WebLinksAddon).
/// The link string is untrusted terminal output: http(s) only.
func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {
guard let url = URL(string: link),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https"
else { return }
UIApplication.shared.open(url)
}
/// OSC 0/2 title (T-iOS-23): hostile input the VM sanitizes
/// (TitleSanitizer) before any UI/registry use, then the wiring
/// surfaces it on the session-list row.
func setTerminalTitle(source: TerminalView, title: String) {
viewModel.setTerminalTitle(title)
}
// cwd surfaces in the session list via the server (T-iOS-13), not
// from the local emulator deliberate no-op.
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
func scrolled(source: TerminalView, position: Double) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
/// OSC 52 lets the HOST write the device clipboard silently declined
/// (server output is untrusted input; plan §4).
func clipboardCopy(source: TerminalView, content: Data) {}
}
}
/// `TerminalView` subclass adding hardware-keyboard `UIKeyCommand`s with the
/// SAME `KeyByteMap` mapping as the key bar (scope decision documented on
/// `HardwareKeyCommands`). Everything else rendering, selection, IME
/// is stock SwiftTerm.
final class KeyCommandTerminalView: TerminalView {
/// Chord outlet; the screen routes it to `TerminalViewModel.send(key:)`.
var onKeyCommand: (@MainActor (KeyByteMap.Key) -> Void)?
/// Retained KeyBar so visibility can toggle it in/out of `inputAccessoryView`
/// (T-iPad-3). The last-applied value avoids reloading input views when the
/// visibility did not change (iPhone stays byte-identical).
private var keyBar: KeyBarView?
private var appliedKeyBarVisible = true
/// Retained so the interaction's delegate outlives menu presentation.
private var contextMenuDelegate: TerminalContextMenuInteractionDelegate?
override var keyCommands: [UIKeyCommand]? {
(super.keyCommands ?? [])
+ HardwareKeyCommands.build(action: #selector(runHardwareKeyCommand(_:)))
}
@objc private func runHardwareKeyCommand(_ sender: UIKeyCommand) {
guard let key = HardwareKeyCommands.key(matching: sender) else { return }
onKeyCommand?(key)
}
// MARK: - KeyBar install / visibility (T-iPad-3)
/// Install the KeyBar as `inputAccessoryView`, honoring the initial
/// visibility (hidden when a hardware keyboard makes it redundant).
func installKeyBar(_ bar: KeyBarView, visible: Bool) {
keyBar = bar
appliedKeyBarVisible = visible
inputAccessoryView = visible ? bar : nil
}
/// Apply a visibility change; a no-op when unchanged so no needless
/// `reloadInputViews()` (the KeyByteMap routing on `keyBar.onKey` is
/// untouched the same bar is only detached/re-attached).
func setKeyBarVisible(_ visible: Bool) {
guard visible != appliedKeyBarVisible else { return }
appliedKeyBarVisible = visible
inputAccessoryView = visible ? keyBar : nil
reloadInputViews()
}
// MARK: - Pointer context menu (T-iPad-3, iPad only)
/// Install the secondary-click / long-press context menu iPad only, so
/// iPhone long-press keeps SwiftTerm's native selection gesture (zero
/// regression). The delegate builds a fresh model per presentation so the
/// copy item reflects the live selection.
func installPointerContextMenuIfSupported(
onNewInCwd: (@MainActor () -> Void)?,
onKill: (@MainActor () -> Void)?
) {
// `UIDevice.current.userInterfaceIdiom` (not `traitCollection`, which can
// be `.unspecified` before the view joins a window at makeUIView time).
guard TerminalContextMenu.isPointerMenuEnabled(
idiom: UIDevice.current.userInterfaceIdiom
) else { return }
let delegate = TerminalContextMenuInteractionDelegate { [weak self] in
TerminalContextMenuModel(
onNewInCwd: onNewInCwd,
onKill: onKill,
onCopySelection: { [weak self] in self?.copySelectionToPasteboard() },
hasSelection: { [weak self] in self?.hasActiveSelection ?? false }
)
}
contextMenuDelegate = delegate
addInteraction(UIContextMenuInteraction(delegate: delegate))
}
// 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
/// is untouched (invariant preserved same as pointer hover highlight).
func copySelectionToPasteboard() {
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()
}
}