T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
177 lines
7.6 KiB
Swift
177 lines
7.6 KiB
Swift
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 label→bytes
|
||
/// 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 横幅上的
|
||
/// "开新会话"(同一动作 —— close→open fresh spawn,cwd 解析在
|
||
/// `AppCoordinator.openNewSessionInCurrentCwd`)。nil = 两处入口都隐藏
|
||
/// (预览/无 coordinator 的测试环境)。
|
||
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
||
|
||
private enum Metrics {
|
||
static let bannerHorizontalPadding: CGFloat = 12
|
||
static let bannerTopPadding: CGFloat = 8
|
||
}
|
||
|
||
private enum Copy {
|
||
static let newSessionInCwd = "在当前目录开新会话"
|
||
}
|
||
|
||
var body: some View {
|
||
TerminalHostView(viewModel: viewModel)
|
||
.ignoresSafeArea(.container, edges: .bottom)
|
||
.overlay(alignment: .top) {
|
||
if let model = viewModel.bannerModel {
|
||
ReconnectBanner(model: model, onNewSession: onNewSessionInCwd)
|
||
.padding(.horizontal, Metrics.bannerHorizontalPadding)
|
||
.padding(.top, Metrics.bannerTopPadding)
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
}
|
||
.animation(.default, value: viewModel.bannerModel)
|
||
.toolbar { newSessionToolbarItem }
|
||
.onAppear { viewModel.start() }
|
||
}
|
||
|
||
/// 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: - 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
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(viewModel: viewModel)
|
||
}
|
||
|
||
func makeUIView(context: Context) -> KeyCommandTerminalView {
|
||
let terminal = KeyCommandTerminalView(frame: .zero)
|
||
terminal.terminalDelegate = context.coordinator
|
||
|
||
let viewModel = viewModel
|
||
terminal.onKeyCommand = { key in viewModel.send(key: key) }
|
||
|
||
let keyBar = KeyBarView()
|
||
keyBar.onKey = { key in viewModel.send(key: key) }
|
||
terminal.inputAccessoryView = keyBar
|
||
|
||
// 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, nothing to push here.
|
||
}
|
||
|
||
/// 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)?
|
||
|
||
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)
|
||
}
|
||
}
|