Files
web-terminal/ios/App/WebTerm/Screens/TerminalScreen.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
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
2026-07-05 16:15:57 +02:00

177 lines
7.6 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 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
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)
}
}