feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
145
ios/App/WebTerm/Screens/TerminalScreen.swift
Normal file
145
ios/App/WebTerm/Screens/TerminalScreen.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
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
|
||||
|
||||
private enum Metrics {
|
||||
static let bannerHorizontalPadding: CGFloat = 12
|
||||
static let bannerTopPadding: CGFloat = 8
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TerminalHostView(viewModel: viewModel)
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
.overlay(alignment: .top) {
|
||||
if let model = viewModel.bannerModel {
|
||||
ReconnectBanner(model: model)
|
||||
.padding(.horizontal, Metrics.bannerHorizontalPadding)
|
||||
.padding(.top, Metrics.bannerTopPadding)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.default, value: viewModel.bannerModel)
|
||||
.onAppear { viewModel.start() }
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Title/cwd surface in the session list via the server (T-iOS-13/23),
|
||||
// not from the local emulator — deliberate no-ops.
|
||||
func setTerminalTitle(source: TerminalView, title: String) {}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user