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:
350
ios/App/WebTerm/Screens/PairingScreen.swift
Normal file
350
ios/App/WebTerm/Screens/PairingScreen.swift
Normal file
@@ -0,0 +1,350 @@
|
||||
import HostRegistry
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
#if !targetEnvironment(simulator)
|
||||
import VisionKit
|
||||
#endif
|
||||
|
||||
/// T-iOS-12 · Pairing screen: QR scan (real device only) + manual URL entry +
|
||||
/// probe UI. Pure presentation over `PairingViewModel` — every rule (input
|
||||
/// validation, the zero-network-before-confirm gate, §5.4 warning tiers,
|
||||
/// error copy/actions) lives in the VM where it is unit-tested.
|
||||
///
|
||||
/// Presentation-agnostic on purpose: T-iOS-15 pushes it as the first-run
|
||||
/// screen, and the session list header presents the SAME screen as a sheet to
|
||||
/// add/switch hosts (the "多 host 切换入口" of the task's step list).
|
||||
///
|
||||
/// Scanner: VisionKit `DataScannerViewController` — compiled out for the
|
||||
/// simulator (`#if targetEnvironment(simulator)`), where manual entry is the
|
||||
/// pairing path; on device the entry also hides when scanning is unsupported.
|
||||
/// `NSCameraUsageDescription` is already declared (project.yml, plan §5.2).
|
||||
struct PairingScreen: View {
|
||||
@Bindable var viewModel: PairingViewModel
|
||||
/// Navigate-on-paired hook for the T-iOS-15 wiring.
|
||||
var onPaired: (HostRegistry.Host) -> Void = { _ in }
|
||||
|
||||
@State private var manualURLText = ""
|
||||
@State private var isShowingScanner = false
|
||||
@State private var scannerError: String?
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle(ScreenCopy.title)
|
||||
.onChange(of: viewModel.pairedHost) { _, paired in
|
||||
guard let paired else { return }
|
||||
onPaired(paired)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch viewModel.phase {
|
||||
case .idle:
|
||||
idleView
|
||||
case .confirming(let pending):
|
||||
ConfirmHostView(pending: pending, viewModel: viewModel)
|
||||
case .probing(let pending):
|
||||
probingView(pending)
|
||||
case .failed(let pending, let failure):
|
||||
FailureView(pending: pending, failure: failure, viewModel: viewModel)
|
||||
case .paired(let host):
|
||||
pairedView(host)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idle: manual entry + scan entry
|
||||
|
||||
private var idleView: some View {
|
||||
Form {
|
||||
Section(ScreenCopy.manualSectionTitle) {
|
||||
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button(ScreenCopy.manualSubmit) {
|
||||
viewModel.submitManualURL(manualURLText)
|
||||
}
|
||||
}
|
||||
if let rejection = viewModel.inputRejection {
|
||||
Section {
|
||||
Text(rejection).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
if PairingScanAvailability.isAvailable {
|
||||
Section {
|
||||
Button {
|
||||
scannerError = nil
|
||||
isShowingScanner = true
|
||||
} label: {
|
||||
Label(ScreenCopy.scanButton, systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Text(ScreenCopy.qrHint)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingScanner) { scannerSheet }
|
||||
}
|
||||
|
||||
@ViewBuilder private var scannerSheet: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
// Unreachable: the entry is hidden on the simulator. Kept total.
|
||||
Text(ScreenCopy.scanUnavailable)
|
||||
#else
|
||||
ZStack(alignment: .bottom) {
|
||||
QRScannerView(
|
||||
onCode: { payload in
|
||||
isShowingScanner = false
|
||||
viewModel.handleScannedCode(payload)
|
||||
},
|
||||
onError: { message in scannerError = message }
|
||||
)
|
||||
if let scannerError {
|
||||
Text(scannerError)
|
||||
.foregroundStyle(.red)
|
||||
.padding()
|
||||
.background(.thinMaterial, in: RoundedRectangle(
|
||||
cornerRadius: Metrics.errorCornerRadius
|
||||
))
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Probing / paired
|
||||
|
||||
private func probingView(_ pending: PairingViewModel.PendingHost) -> some View {
|
||||
VStack(spacing: Metrics.stackSpacing) {
|
||||
ProgressView()
|
||||
Text(ScreenCopy.probing(pending.displayAddress))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func pairedView(_ host: HostRegistry.Host) -> some View {
|
||||
VStack(spacing: Metrics.stackSpacing) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.green)
|
||||
Text(ScreenCopy.paired(host.name))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Confirm page (parsed address + §5.4 warning tier + name)
|
||||
|
||||
private struct ConfirmHostView: View {
|
||||
let pending: PairingViewModel.PendingHost
|
||||
@Bindable var viewModel: PairingViewModel
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(ScreenCopy.confirmSectionTitle) {
|
||||
// Single-point-derived scheme://host[:port] — never hand-built.
|
||||
Text(pending.displayAddress)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName)
|
||||
}
|
||||
warningSection
|
||||
Section {
|
||||
Button(ScreenCopy.connect) {
|
||||
Task { await viewModel.confirmConnect() }
|
||||
}
|
||||
Button(ScreenCopy.cancel, role: .cancel) {
|
||||
viewModel.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var warningSection: some View {
|
||||
switch pending.warning {
|
||||
case .none:
|
||||
EmptyView()
|
||||
case .tailscaleEncrypted:
|
||||
Section {
|
||||
Label(ScreenCopy.tailscaleBadge, systemImage: "lock.shield")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
case .plaintextLAN:
|
||||
Section {
|
||||
Label(ScreenCopy.plaintextNotice, systemImage: "eye")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
case .publicHostBlocking:
|
||||
Section {
|
||||
Label(ScreenCopy.publicWarning, systemImage: "exclamationmark.octagon.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.headline)
|
||||
Toggle(ScreenCopy.publicAcknowledge,
|
||||
isOn: $viewModel.hasAcknowledgedPublicRisk)
|
||||
if viewModel.needsPublicRiskAcknowledgement {
|
||||
Text(ScreenCopy.publicAckRequired)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Failure page (inline copy + recovery action)
|
||||
|
||||
private struct FailureView: View {
|
||||
let pending: PairingViewModel.PendingHost
|
||||
let failure: PairingViewModel.FailureDisplay
|
||||
let viewModel: PairingViewModel
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(pending.displayAddress) {
|
||||
Label(failure.message, systemImage: "xmark.octagon")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Section {
|
||||
if failure.action == .openLocalNetworkSettings {
|
||||
Button(ScreenCopy.openSettings) { openAppSettings() }
|
||||
}
|
||||
Button(ScreenCopy.retry) {
|
||||
Task { await viewModel.retry() }
|
||||
}
|
||||
Button(ScreenCopy.back, role: .cancel) {
|
||||
viewModel.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's Settings pane hosts its 本地网络 toggle — there is no public
|
||||
/// deep link straight to 隐私 → 本地网络 (plan §5.2 guidance).
|
||||
private func openAppSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scan availability
|
||||
|
||||
enum PairingScanAvailability {
|
||||
/// Simulator: no camera → entry hidden, manual entry is the pairing path
|
||||
/// (task ruling). Device: also requires VisionKit support.
|
||||
/// (`DataScannerViewController.isSupported` is MainActor-isolated.)
|
||||
@MainActor static var isAvailable: Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
return false
|
||||
#else
|
||||
return DataScannerViewController.isSupported
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VisionKit scanner (device only)
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
/// Thin `DataScannerViewController` wrapper: QR symbology only; the FIRST
|
||||
/// recognized code wins (the web QR fills the screen — no tap-to-pick needed).
|
||||
/// The payload is handed to the VM untouched — validation is the VM's job.
|
||||
private struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onCode: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onCode: onCode)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> DataScannerViewController {
|
||||
let scanner = DataScannerViewController(
|
||||
recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
||||
qualityLevel: .balanced,
|
||||
isHighlightingEnabled: true
|
||||
)
|
||||
scanner.delegate = context.coordinator
|
||||
return scanner
|
||||
}
|
||||
|
||||
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
|
||||
guard !scanner.isScanning else { return }
|
||||
do {
|
||||
try scanner.startScanning()
|
||||
} catch {
|
||||
// Explicit surfacing, never a silent swallow (plan §4): camera
|
||||
// denied/busy shows inline in the sheet; manual entry remains.
|
||||
onError(ScreenCopy.scannerStartFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||||
private let onCode: (String) -> Void
|
||||
private var hasDelivered = false
|
||||
|
||||
init(onCode: @escaping (String) -> Void) {
|
||||
self.onCode = onCode
|
||||
}
|
||||
|
||||
func dataScanner(
|
||||
_ dataScanner: DataScannerViewController,
|
||||
didAdd addedItems: [RecognizedItem],
|
||||
allItems: [RecognizedItem]
|
||||
) {
|
||||
guard !hasDelivered else { return }
|
||||
for item in addedItems {
|
||||
if case .barcode(let barcode) = item,
|
||||
let payload = barcode.payloadStringValue {
|
||||
hasDelivered = true
|
||||
onCode(payload)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Screen constants
|
||||
|
||||
private enum Metrics {
|
||||
static let stackSpacing: CGFloat = 12
|
||||
static let errorCornerRadius: CGFloat = 8
|
||||
}
|
||||
|
||||
private enum ScreenCopy {
|
||||
static let title = "配对主机"
|
||||
static let manualSectionTitle = "输入地址"
|
||||
static let manualPlaceholder = "http://192.168.1.5:3000"
|
||||
static let manualSubmit = "连接"
|
||||
static let scanButton = "扫描二维码"
|
||||
static let scanUnavailable = "模拟器不支持扫码,请手输地址。"
|
||||
static let qrHint = "在电脑的 web 终端工具栏点「Connect a device」可显示配对二维码;也可直接输入它的地址。"
|
||||
static let confirmSectionTitle = "确认要连接的主机"
|
||||
static let namePlaceholder = "主机名称"
|
||||
static let connect = "连接"
|
||||
static let cancel = "取消"
|
||||
static let retry = "重试"
|
||||
static let back = "返回"
|
||||
static let openSettings = "去设置"
|
||||
static let tailscaleBadge = "经 Tailscale 加密(WireGuard 网络层)"
|
||||
static let plaintextNotice =
|
||||
"ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN,推荐 tailscale serve(wss)。"
|
||||
static let publicWarning =
|
||||
"这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
|
||||
static let publicAcknowledge = "我已了解风险,仍要连接"
|
||||
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
|
||||
|
||||
static func probing(_ address: String) -> String {
|
||||
"正在验证 \(address) …"
|
||||
}
|
||||
|
||||
static func paired(_ name: String) -> String {
|
||||
"已配对:\(name)"
|
||||
}
|
||||
|
||||
static func scannerStartFailed(_ reason: String) -> String {
|
||||
"无法启动相机扫描:\(reason)。可改用手输地址。"
|
||||
}
|
||||
}
|
||||
235
ios/App/WebTerm/Screens/SessionListScreen.swift
Normal file
235
ios/App/WebTerm/Screens/SessionListScreen.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
import HostRegistry
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-13 · Session list screen (merged chooser + dashboard). Pure
|
||||
/// presentation over `SessionListViewModel` — polling cadence, badge priority,
|
||||
/// staleness, optimistic kill and the navigation signal are all VM logic,
|
||||
/// unit-tested in `SessionListViewModelTests`.
|
||||
///
|
||||
/// The T-iOS-15 wiring provides `onOpen` (push `TerminalScreen`, open with
|
||||
/// `request.sessionId` — nil = new session) and `onAddHost` (present the
|
||||
/// pairing sheet; call `viewModel.reloadHosts()` when it completes).
|
||||
struct SessionListScreen: View {
|
||||
var viewModel: SessionListViewModel
|
||||
/// Navigation hook: fired once per `OpenRequest` (unique id per tap).
|
||||
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
|
||||
/// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15).
|
||||
var onAddHost: () -> Void = {}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle(ScreenCopy.title)
|
||||
.toolbar { hostMenu }
|
||||
.onAppear { viewModel.appeared() }
|
||||
.onDisappear { viewModel.disappeared() }
|
||||
.onChange(of: viewModel.openRequest) { _, request in
|
||||
guard let request else { return }
|
||||
onOpen(request)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch viewModel.emptyState {
|
||||
case .notPaired:
|
||||
notPairedView
|
||||
case .noSessions:
|
||||
noSessionsView
|
||||
case nil:
|
||||
sessionList
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var sessionList: some View {
|
||||
List {
|
||||
if let message = viewModel.fetchErrorMessage {
|
||||
errorRow(message)
|
||||
}
|
||||
if let message = viewModel.killErrorMessage {
|
||||
errorRow(message)
|
||||
}
|
||||
Button {
|
||||
viewModel.requestNewSession()
|
||||
} label: {
|
||||
Label(ScreenCopy.newSession, systemImage: "plus.circle.fill")
|
||||
}
|
||||
ForEach(viewModel.rows) { row in
|
||||
Button {
|
||||
viewModel.openSession(id: row.id)
|
||||
} label: {
|
||||
SessionRowView(row: row)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.kill(sessionId: row.id) }
|
||||
} label: {
|
||||
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable { await viewModel.refresh() }
|
||||
}
|
||||
|
||||
private func errorRow(_ message: String) -> some View {
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
// MARK: - Empty states
|
||||
|
||||
private var notPairedView: some View {
|
||||
ContentUnavailableView {
|
||||
Label(ScreenCopy.notPairedTitle, systemImage: "personalhotspot")
|
||||
} description: {
|
||||
Text(ScreenCopy.notPairedHint)
|
||||
} actions: {
|
||||
Button(ScreenCopy.addHost) { onAddHost() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
private var noSessionsView: some View {
|
||||
ContentUnavailableView {
|
||||
Label(ScreenCopy.noSessionsTitle, systemImage: "terminal")
|
||||
} description: {
|
||||
Text(ScreenCopy.noSessionsHint)
|
||||
} actions: {
|
||||
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Host-switch header (multi-host from HostStore)
|
||||
|
||||
private var hostMenu: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
ForEach(viewModel.hosts) { host in
|
||||
Button {
|
||||
Task { await viewModel.selectHost(id: host.id) }
|
||||
} label: {
|
||||
if host.id == viewModel.activeHost?.id {
|
||||
Label(host.name, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(host.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
onAddHost()
|
||||
} label: {
|
||||
Label(ScreenCopy.addHost, systemImage: "plus")
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
||||
systemImage: "desktopcomputer"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
private struct SessionRowView: View {
|
||||
let row: SessionListViewModel.SessionRow
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: Metrics.rowSpacing) {
|
||||
indicator
|
||||
.frame(width: Metrics.indicatorWidth)
|
||||
VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
Text(meta)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let telemetry = row.telemetry, !telemetry.isEmpty {
|
||||
TelemetryChips(model: telemetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(row.info.exited ? Metrics.exitedOpacity : 1)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// ⚠ badge outranks the status dot (VM decides via `indicator`).
|
||||
@ViewBuilder private var indicator: some View {
|
||||
switch row.indicator {
|
||||
case .pendingApproval:
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.accessibilityLabel(ScreenCopy.pendingBadgeLabel)
|
||||
case .status(let status):
|
||||
Circle()
|
||||
.fill(Self.dotColor(status))
|
||||
.frame(width: Metrics.dotSize, height: Metrics.dotSize)
|
||||
.padding(.top, Metrics.dotTopPadding)
|
||||
.accessibilityLabel(Text(status.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// `cwd` is server-supplied display text (untrusted → plain Text only).
|
||||
private var title: String {
|
||||
guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory }
|
||||
return URL(fileURLWithPath: cwd).lastPathComponent
|
||||
}
|
||||
|
||||
private var meta: String {
|
||||
var parts = [
|
||||
ScreenCopy.clientCount(row.info.clientCount),
|
||||
"\(row.info.cols)×\(row.info.rows)",
|
||||
]
|
||||
if row.info.exited {
|
||||
parts.append(ScreenCopy.exitedTag)
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private static func dotColor(_ status: ClaudeStatus) -> Color {
|
||||
switch status {
|
||||
case .working: return .green
|
||||
case .waiting: return .orange
|
||||
case .idle: return .blue
|
||||
case .stuck: return .red
|
||||
case .unknown: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private enum Metrics {
|
||||
static let rowSpacing: CGFloat = 10
|
||||
static let rowInnerSpacing: CGFloat = 3
|
||||
static let indicatorWidth: CGFloat = 18
|
||||
static let dotSize: CGFloat = 10
|
||||
static let dotTopPadding: CGFloat = 5
|
||||
static let exitedOpacity = 0.55
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Screen copy
|
||||
|
||||
private enum ScreenCopy {
|
||||
static let title = "会话"
|
||||
static let newSession = "新建会话"
|
||||
static let kill = "结束"
|
||||
static let addHost = "配对新主机"
|
||||
static let hostMenuFallback = "主机"
|
||||
static let notPairedTitle = "还没有配对的主机"
|
||||
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
||||
static let noSessionsTitle = "主机上没有运行中的会话"
|
||||
static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。"
|
||||
static let unknownDirectory = "未知目录"
|
||||
static let exitedTag = "已退出"
|
||||
static let pendingBadgeLabel = "等待审批"
|
||||
|
||||
static func clientCount(_ count: Int) -> String {
|
||||
"\(count) 台设备在看"
|
||||
}
|
||||
}
|
||||
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