feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut (engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle (.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active, spike screen deleted, cold-start routing Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→ attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral) Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active → notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner) Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode (self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv) Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
This commit is contained in:
@@ -1,89 +0,0 @@
|
||||
//
|
||||
// SpikeTerminalScreen.swift — T-iOS-2 Day-1 spike ②【临时文件,W4/T-iOS-15 删除】
|
||||
//
|
||||
// 目的:SwiftTerm TerminalView 的真机键盘/IME smoke 载体(plan §7 T-iOS-2):
|
||||
// - 键盘弹出 / first-responder 行为
|
||||
// - 中文 IME 组合输入(不自加 keydown 监听,组合事件全交系统栈)
|
||||
// - inputAccessoryView 上 Esc/Ctrl-C 可发 —— SwiftTerm iOS 端自带
|
||||
// TerminalAccessory(含 esc/ctrl/tab 键,iOSAccessoryView.swift),
|
||||
// spike 直接用它验证;正式 key-bar(逐字节复刻 public/keybar.ts)归 T-iOS-11
|
||||
// - 文本选择不崩(SwiftTerm 自绘选择是历史坑点)
|
||||
//
|
||||
// 接线为【纯本地回显】(无服务器依赖):typed bytes → send() → feed() 原样喂回,
|
||||
// CR 扩成 CRLF。真实 SessionEngine 接线是 W2/W3 的事。
|
||||
//
|
||||
// 2026-07-04:无可用真机 —— 真机清单各项记 DEFERRED(真机不可用)(见 PROGRESS_LOG
|
||||
// 条目)。本文件当下的验收是:作为 App target 源码在模拟器 xcodebuild 构建通过。
|
||||
|
||||
import SwiftTerm
|
||||
import SwiftUI
|
||||
|
||||
/// Day-1 spike 的最小 SwiftUI 壳。不接入 WebTermApp 导航(WebTermApp.swift 归
|
||||
/// T-iOS-1/T-iOS-15 所有);存在本身即证明 App target 在 Swift 6 strict
|
||||
/// concurrency 下能链接并承载 SwiftTerm 的 TerminalView。
|
||||
struct SpikeTerminalScreen: View {
|
||||
var body: some View {
|
||||
LocalEchoTerminal()
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
.navigationTitle("SwiftTerm spike")
|
||||
}
|
||||
}
|
||||
|
||||
/// 承载 SwiftTerm UIKit `TerminalView` 的 UIViewRepresentable,接本地回显。
|
||||
/// 形状即 T-iOS-11 正式 TerminalScreen 的雏形(去掉 SessionEngine)。
|
||||
private struct LocalEchoTerminal: UIViewRepresentable {
|
||||
func makeCoordinator() -> LocalEchoCoordinator {
|
||||
LocalEchoCoordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> TerminalView {
|
||||
let view = TerminalView(frame: .zero)
|
||||
view.terminalDelegate = context.coordinator
|
||||
view.feed(text: SpikeGreeting.text)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: TerminalView, context: Context) {
|
||||
// 本地回显无外部状态可同步。
|
||||
}
|
||||
}
|
||||
|
||||
private enum SpikeGreeting {
|
||||
/// 行尾用 CRLF:PTY 语义下裸 LF 只下移一行不回车。
|
||||
static let text = "SwiftTerm local-echo spike (T-iOS-2)\r\n"
|
||||
+ "Typed bytes are echoed locally; CR -> CRLF.\r\n"
|
||||
+ "真机清单: 键盘弹出 / 中文 IME / accessory esc·ctrl / 文本选择\r\n\r\n> "
|
||||
}
|
||||
|
||||
/// 本地回显 delegate:SwiftTerm 要"发给宿主"的每个字节原样喂回终端。
|
||||
/// Enter 产生的 CR(0x0D)补一个 LF,光标才会回行首并换行。
|
||||
@MainActor
|
||||
private final class LocalEchoCoordinator: NSObject, @preconcurrency TerminalViewDelegate {
|
||||
private static let carriageReturn: UInt8 = 0x0D
|
||||
private static let lineFeed: UInt8 = 0x0A
|
||||
|
||||
func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
||||
var echoed: [UInt8] = []
|
||||
echoed.reserveCapacity(data.count + 1)
|
||||
for byte in data {
|
||||
echoed.append(byte)
|
||||
if byte == Self.carriageReturn {
|
||||
echoed.append(Self.lineFeed)
|
||||
}
|
||||
}
|
||||
source.feed(byteArray: echoed[...])
|
||||
}
|
||||
|
||||
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {}
|
||||
func setTerminalTitle(source: TerminalView, title: String) {}
|
||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
||||
func scrolled(source: TerminalView, position: Double) {}
|
||||
func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {}
|
||||
func clipboardCopy(source: TerminalView, content: Data) {}
|
||||
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
|
||||
// bell / iTermContent 用 SwiftTerm 的默认实现。
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SpikeTerminalScreen()
|
||||
}
|
||||
@@ -43,12 +43,25 @@ final class TerminalViewModel {
|
||||
case exited(code: Int, reason: String?)
|
||||
}
|
||||
|
||||
/// Terminal geometry snapshot (Equatable for test assertions; the frozen
|
||||
/// engine API takes a tuple, so `asTuple` bridges).
|
||||
struct TerminalDims: Equatable, Sendable {
|
||||
let cols: Int
|
||||
let rows: Int
|
||||
var asTuple: (cols: Int, rows: Int) { (cols, rows) }
|
||||
}
|
||||
|
||||
/// Actionable copy for `.failed(.replayTooLarge)` (plan §3.2 / §3.2.1
|
||||
/// coupling warning): reconnecting would deterministically fail forever,
|
||||
/// so the user must change a knob, not wait.
|
||||
static let replayTooLargeMessage =
|
||||
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
|
||||
|
||||
/// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
|
||||
/// Read by the wiring layer for `notifyForegrounded(dims:)` — the frozen
|
||||
/// §3.2 signature needs real cols/rows and this is their single source.
|
||||
private(set) var lastSentDims: TerminalDims?
|
||||
|
||||
private(set) var banner: ConnectionBanner = .none
|
||||
private(set) var phase: TerminalPhase = .live
|
||||
/// Server-adopted session id (ALWAYS the server-issued one — persisting it
|
||||
@@ -182,8 +195,14 @@ final class TerminalViewModel {
|
||||
}
|
||||
|
||||
/// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded —
|
||||
/// the engine validates bounds and owns terminal-state dropping.
|
||||
/// the engine validates bounds and owns terminal-state dropping. Valid
|
||||
/// dims are remembered in `lastSentDims` so the T-iOS-15 wiring can feed
|
||||
/// `engine.notifyForegrounded(dims:)` on scenePhase reactivation (closes
|
||||
/// the W4 documented deviation; invalid dims never overwrite a good pair).
|
||||
func sendResize(cols: Int, rows: Int) {
|
||||
if Validation.isValidResize(cols: cols, rows: rows) {
|
||||
lastSentDims = TerminalDims(cols: cols, rows: rows)
|
||||
}
|
||||
enqueueSend(.resize(cols: cols, rows: rows))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-1 scaffold: empty-window placeholder. Navigation assembly
|
||||
/// (Pairing → SessionList → Terminal) is wired in T-iOS-15.
|
||||
/// T-iOS-15 · App entry: assemble the production dependency graph once and
|
||||
/// hand it to the coordinator (Pairing → SessionList → Terminal). All wiring
|
||||
/// lives under `Wiring/`; this file stays a thin `@main`.
|
||||
@main
|
||||
struct WebTermApp: App {
|
||||
@State private var coordinator = AppCoordinator(environment: .production())
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
HelloPlaceholderView()
|
||||
RootView(coordinator: coordinator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HelloPlaceholderView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "terminal")
|
||||
.font(.largeTitle)
|
||||
Text("Hello WebTerm")
|
||||
.font(.title2)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
146
ios/App/WebTerm/Wiring/AppCoordinator.swift
Normal file
146
ios/App/WebTerm/Wiring/AppCoordinator.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-15 · Navigation + lifecycle owner: Pairing → SessionList → Terminal
|
||||
/// with the production dependency graph (plan §7 T-iOS-15 step 1).
|
||||
///
|
||||
/// - Cold start: read the host store once — no paired host → Pairing, else
|
||||
/// SessionList (`ColdStartPolicy.initialRoute`).
|
||||
/// - `SessionListScreen.onOpen` → build ONE `TerminalSessionController`
|
||||
/// (single foreground session, plan §1) and push the terminal.
|
||||
/// - Back → `closeTerminal()` → `engine.close()` (detach; PTY keeps running).
|
||||
/// - scenePhase is forwarded to the open controller (`.background` →
|
||||
/// suspend/close, `.active` → resume/rebuild). The privacy shade is view
|
||||
/// layer (RootView + PrivacyShadePolicy), not coordinator state.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppCoordinator {
|
||||
private(set) var route: ColdStartPolicy.RootRoute = .loading
|
||||
private(set) var terminalController: TerminalSessionController?
|
||||
/// First-run pairing VM (root route). Recreated per entry to keep pairing
|
||||
/// state machines single-shot.
|
||||
private(set) var rootPairingViewModel: PairingViewModel?
|
||||
/// Add-host pairing VM (sheet from the list header).
|
||||
private(set) var addHostPairingViewModel: PairingViewModel?
|
||||
var isAddHostPresented = false
|
||||
|
||||
let sessionList: SessionListViewModel
|
||||
@ObservationIgnored let environment: AppEnvironment
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
sessionList = SessionListViewModel(
|
||||
hostStore: environment.hostStore,
|
||||
http: environment.http,
|
||||
clock: ContinuousClock()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Cold start
|
||||
|
||||
/// Decide the boot route from the host store. A store READ failure routes
|
||||
/// to the session list, whose own `reloadHosts` surfaces the explicit
|
||||
/// error copy (never a silent empty pairing screen hiding a broken store).
|
||||
func bootstrap() async {
|
||||
guard route == .loading else { return }
|
||||
do {
|
||||
let hosts = try await environment.hostStore.loadAll()
|
||||
route = ColdStartPolicy.initialRoute(pairedHostCount: hosts.count)
|
||||
} catch {
|
||||
route = .sessions
|
||||
}
|
||||
if route == .pairing {
|
||||
rootPairingViewModel = makePairingViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
/// First-run pairing done → move to the list (the paired host is already
|
||||
/// in the store — PairingViewModel upserts before signalling).
|
||||
func completeFirstPairing(_ host: HostRegistry.Host) {
|
||||
rootPairingViewModel = nil
|
||||
route = .sessions
|
||||
Task { await sessionList.reloadHosts() }
|
||||
}
|
||||
|
||||
// MARK: - Add-host sheet (list header hook)
|
||||
|
||||
func presentAddHost() {
|
||||
addHostPairingViewModel = makePairingViewModel()
|
||||
isAddHostPresented = true
|
||||
}
|
||||
|
||||
func completeAddHost(_ host: HostRegistry.Host) {
|
||||
isAddHostPresented = false
|
||||
addHostDismissed()
|
||||
}
|
||||
|
||||
/// Sheet gone (paired OR cancelled): drop the VM and refresh hosts — the
|
||||
/// list VM keeps the active host if it still exists.
|
||||
func addHostDismissed() {
|
||||
addHostPairingViewModel = nil
|
||||
Task { await sessionList.reloadHosts() }
|
||||
}
|
||||
|
||||
// MARK: - Terminal open/close
|
||||
|
||||
/// `SessionListScreen.onOpen` (one navigation signal per tap) and the
|
||||
/// "继续上次" banner both land here. `sessionId == nil` = new session.
|
||||
func open(_ request: SessionListViewModel.OpenRequest) {
|
||||
guard terminalController == nil else { return } // one foreground session
|
||||
let controller = TerminalSessionController(
|
||||
host: request.host,
|
||||
sessionId: request.sessionId,
|
||||
environment: environment,
|
||||
onPendingChanged: { [weak self] sessionId, pending in
|
||||
self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending)
|
||||
}
|
||||
)
|
||||
terminalController = controller
|
||||
controller.start()
|
||||
}
|
||||
|
||||
/// Back navigation popped the terminal: explicit detach.
|
||||
func closeTerminal() {
|
||||
terminalController?.teardown()
|
||||
terminalController = nil
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" (cold start step 5)
|
||||
|
||||
var continueLastSessionId: UUID? {
|
||||
ColdStartPolicy.continueLastSessionId(
|
||||
activeHost: sessionList.activeHost,
|
||||
store: environment.lastSessionStore
|
||||
)
|
||||
}
|
||||
|
||||
func openContinueLast() {
|
||||
guard let host = sessionList.activeHost, let sessionId = continueLastSessionId else {
|
||||
return
|
||||
}
|
||||
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
|
||||
}
|
||||
|
||||
// MARK: - scenePhase (plan §7 T-iOS-15 step 3)
|
||||
|
||||
func handleScenePhase(_ phase: ScenePhase) {
|
||||
switch phase {
|
||||
case .background:
|
||||
terminalController?.suspend()
|
||||
case .active:
|
||||
terminalController?.resumeIfNeeded()
|
||||
case .inactive:
|
||||
break // transient; shade covers it at the view layer
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makePairingViewModel() -> PairingViewModel {
|
||||
PairingViewModel(store: environment.hostStore, probe: environment.probe)
|
||||
}
|
||||
}
|
||||
56
ios/App/WebTerm/Wiring/AppEnvironment.swift
Normal file
56
ios/App/WebTerm/Wiring/AppEnvironment.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Production dependency graph (composition root). One immutable
|
||||
/// value assembled at launch; every screen/VM receives its dependencies from
|
||||
/// here — no ad-hoc URLSession/keychain access anywhere else in the App layer.
|
||||
///
|
||||
/// Assembly security audit (task 安全注, verified at this single point):
|
||||
/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via
|
||||
/// SessionListViewModel's client, probe's kill round-trip inside
|
||||
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own.
|
||||
/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport;
|
||||
/// HTTP: APIClient's route builder) — nothing here hand-assembles one.
|
||||
/// - Secrets: hosts in `KeychainHostStore`
|
||||
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
|
||||
/// UserDefaults carries only the non-secret per-host lastSessionId.
|
||||
/// - No debug ATS overrides exist (project.yml declares NO
|
||||
/// NSAllowsArbitraryLoads in any configuration — only the five §5.2 CIDR
|
||||
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
|
||||
/// `UIScreen.isCaptured` detection is SKIPPED per plan (accepted residual
|
||||
/// risk, local trust model).
|
||||
struct AppEnvironment: Sendable {
|
||||
let hostStore: any HostStore
|
||||
let lastSessionStore: any LastSessionStore
|
||||
let http: any HTTPTransport
|
||||
let termTransport: any TermTransport
|
||||
/// Injected into `PairingViewModel` — production is `runPairingProbe`
|
||||
/// over the real transports (two-step: RO GET, then WS attach + guarded
|
||||
/// kill; only runs after the user's explicit confirm, T-iOS-12).
|
||||
let probe: PairingViewModel.Probe
|
||||
|
||||
static func production() -> AppEnvironment {
|
||||
let http = URLSessionHTTPTransport()
|
||||
let termTransport = URLSessionTermTransport()
|
||||
return AppEnvironment(
|
||||
hostStore: KeychainHostStore(),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(),
|
||||
http: http,
|
||||
termTransport: termTransport,
|
||||
probe: { endpoint in
|
||||
await runPairingProbe(endpoint: endpoint, http: http, ws: termTransport)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Away-digest source for a session engine: wraps `APIClient.events` per
|
||||
/// host (the engine never holds an HTTP client — plan §3.2).
|
||||
func makeEventsSource(endpoint: HostEndpoint)
|
||||
-> @Sendable (UUID) async throws -> [TimelineEvent] {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return { id in try await client.events(id: id) }
|
||||
}
|
||||
}
|
||||
32
ios/App/WebTerm/Wiring/ColdStartPolicy.swift
Normal file
32
ios/App/WebTerm/Wiring/ColdStartPolicy.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
|
||||
/// T-iOS-15 · Cold-start selection logic, kept pure for unit tests
|
||||
/// (ColdStartPolicyTests): which root screen boots, and whether the session
|
||||
/// list highlights "继续上次" for the active host.
|
||||
enum ColdStartPolicy {
|
||||
enum RootRoute: Equatable {
|
||||
/// Host store not read yet.
|
||||
case loading
|
||||
/// No paired host → pairing is the only next step (plan §7 step 1).
|
||||
case pairing
|
||||
/// At least one paired host → the merged chooser/dashboard list.
|
||||
case sessions
|
||||
}
|
||||
|
||||
static func initialRoute(pairedHostCount: Int) -> RootRoute {
|
||||
pairedHostCount == 0 ? .pairing : .sessions
|
||||
}
|
||||
|
||||
/// The "继续上次" target for the list's highlight banner: the last
|
||||
/// server-adopted session persisted for the ACTIVE host (nil = no banner).
|
||||
/// Persistence side: `SessionActivityBridge` (adopted → set, exited →
|
||||
/// cleared), so a returned id was live when last seen.
|
||||
static func continueLastSessionId(
|
||||
activeHost: HostRegistry.Host?,
|
||||
store: any LastSessionStore
|
||||
) -> UUID? {
|
||||
guard let activeHost else { return nil }
|
||||
return store.lastSessionId(host: activeHost.id)
|
||||
}
|
||||
}
|
||||
53
ios/App/WebTerm/Wiring/EventFanOut.swift
Normal file
53
ios/App/WebTerm/Wiring/EventFanOut.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
/// T-iOS-15 · Broadcast adapter for a single-consumer `AsyncStream`.
|
||||
///
|
||||
/// `SessionEngine.events` has exactly ONE consumer (engine contract), but the
|
||||
/// wiring needs three: `TerminalViewModel` (output/connection/exit),
|
||||
/// `GateViewModel` (gate/digest) and `SessionActivityBridge` (adopted /
|
||||
/// pending-⚠ / last-session persistence). Both VMs were designed for this —
|
||||
/// their `events` parameter is injected separately from the engine precisely
|
||||
/// so a fan-out branch can be passed instead (their type docs say so).
|
||||
///
|
||||
/// Shape: `branchCount` child streams are created up front (immutable `let`s
|
||||
/// — no late subscription, so no missed-element semantics to define); ONE pump
|
||||
/// task consumes the source and yields to every branch in order. AsyncStream's
|
||||
/// default unbounded buffering means a slow branch never drops or blocks the
|
||||
/// others. Source finish — which `SessionEngine.close()` guarantees — finishes
|
||||
/// every branch; `cancel()` is the teardown belt-and-braces for rebuilds.
|
||||
final class EventFanOut<Element: Sendable>: Sendable {
|
||||
let branches: [AsyncStream<Element>]
|
||||
private let continuations: [AsyncStream<Element>.Continuation]
|
||||
private let pumpTask: Task<Void, Never>
|
||||
|
||||
init(source: AsyncStream<Element>, branchCount: Int) {
|
||||
var branches: [AsyncStream<Element>] = []
|
||||
var continuations: [AsyncStream<Element>.Continuation] = []
|
||||
for _ in 0..<branchCount {
|
||||
let (stream, continuation) = AsyncStream<Element>.makeStream()
|
||||
branches.append(stream)
|
||||
continuations.append(continuation)
|
||||
}
|
||||
self.branches = branches
|
||||
self.continuations = continuations
|
||||
let sinks = continuations
|
||||
pumpTask = Task {
|
||||
for await element in source {
|
||||
for sink in sinks {
|
||||
sink.yield(element)
|
||||
}
|
||||
}
|
||||
for sink in sinks {
|
||||
sink.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop pumping and finish every branch immediately (idempotent — finish
|
||||
/// on a finished continuation is a no-op). Used when a suspended terminal
|
||||
/// stack is torn down and rebuilt.
|
||||
func cancel() {
|
||||
pumpTask.cancel()
|
||||
for sink in continuations {
|
||||
sink.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
47
ios/App/WebTerm/Wiring/PrivacyShade.swift
Normal file
47
ios/App/WebTerm/Wiring/PrivacyShade.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-15 · Privacy shade (security-critical, plan §7 T-iOS-15).
|
||||
///
|
||||
/// iOS writes the app-switcher snapshot to DISK the moment the scene reaches
|
||||
/// `.background` — but the switcher is usually ENTERED at `.inactive`. The
|
||||
/// shade therefore covers whenever `scenePhase != .active` (the exact rule;
|
||||
/// `.inactive`-only would let the `.background` snapshot capture terminal
|
||||
/// content — API keys, tokens, source — into the on-disk switcher image).
|
||||
/// Restored on `.active`.
|
||||
///
|
||||
/// Pure mapping split from the view so the rule is unit-testable for all
|
||||
/// three phases (PrivacyShadeTests).
|
||||
enum PrivacyShadePolicy {
|
||||
static func isShadeVisible(for scenePhase: ScenePhase) -> Bool {
|
||||
scenePhase != .active
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque cover rendered ABOVE the whole navigation tree (RootView ZStack) —
|
||||
/// deliberately no animation: the snapshot moment must never race a fade.
|
||||
///
|
||||
/// Scope note (documented): sheets present in a separate presentation layer a
|
||||
/// root overlay cannot cover — but no terminal bytes are ever rendered in a
|
||||
/// sheet (pairing / plan-gate only), so the root-level shade covers every
|
||||
/// surface that shows PTY content.
|
||||
struct PrivacyShadeView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemBackground)
|
||||
.ignoresSafeArea()
|
||||
VStack(spacing: ShadeMetrics.spacing) {
|
||||
Image(systemName: "terminal.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("WebTerm")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private enum ShadeMetrics {
|
||||
static let spacing: CGFloat = 12
|
||||
}
|
||||
}
|
||||
126
ios/App/WebTerm/Wiring/RootView.swift
Normal file
126
ios/App/WebTerm/Wiring/RootView.swift
Normal file
@@ -0,0 +1,126 @@
|
||||
import HostRegistry
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-15 · Root of the app: route switch (Pairing / SessionList), terminal
|
||||
/// push, add-host sheet, scenePhase forwarding and the privacy shade.
|
||||
///
|
||||
/// The shade is the TOPMOST layer of this ZStack and appears whenever
|
||||
/// `scenePhase != .active` (exact rule — PrivacyShadePolicy + tests); it
|
||||
/// covers the whole navigation tree, terminal included. Sheets live above any
|
||||
/// overlay, but no sheet renders terminal bytes (pairing / plan gate only).
|
||||
struct RootView: View {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
NavigationStack {
|
||||
rootContent
|
||||
.navigationDestination(isPresented: terminalBinding) {
|
||||
terminalDestination
|
||||
}
|
||||
}
|
||||
if PrivacyShadePolicy.isShadeVisible(for: scenePhase) {
|
||||
PrivacyShadeView()
|
||||
}
|
||||
}
|
||||
.task { await coordinator.bootstrap() }
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
coordinator.handleScenePhase(phase)
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $coordinator.isAddHostPresented,
|
||||
onDismiss: { coordinator.addHostDismissed() }
|
||||
) {
|
||||
addHostSheet
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route switch
|
||||
|
||||
@ViewBuilder private var rootContent: some View {
|
||||
switch coordinator.route {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
case .pairing:
|
||||
firstRunPairing
|
||||
case .sessions:
|
||||
sessionList
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var firstRunPairing: some View {
|
||||
if let viewModel = coordinator.rootPairingViewModel {
|
||||
PairingScreen(viewModel: viewModel) { host in
|
||||
coordinator.completeFirstPairing(host)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionList: some View {
|
||||
SessionListScreen(
|
||||
viewModel: coordinator.sessionList,
|
||||
onOpen: { coordinator.open($0) },
|
||||
onAddHost: { coordinator.presentAddHost() }
|
||||
)
|
||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" highlight (cold start step 5)
|
||||
|
||||
@ViewBuilder private var continueLastBanner: some View {
|
||||
if coordinator.continueLastSessionId != nil {
|
||||
Button {
|
||||
coordinator.openContinueLast()
|
||||
} label: {
|
||||
Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal, RootMetrics.bannerHorizontalPadding)
|
||||
.padding(.vertical, RootMetrics.bannerVerticalPadding)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal push
|
||||
|
||||
private var terminalBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.terminalController != nil },
|
||||
set: { presented in
|
||||
guard !presented else { return }
|
||||
coordinator.closeTerminal() // back → engine.close() (detach)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private var terminalDestination: some View {
|
||||
if let controller = coordinator.terminalController {
|
||||
TerminalContainerView(controller: controller)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add-host sheet (multi-host entry, list header)
|
||||
|
||||
@ViewBuilder private var addHostSheet: some View {
|
||||
if let viewModel = coordinator.addHostPairingViewModel {
|
||||
NavigationStack {
|
||||
PairingScreen(viewModel: viewModel) { host in
|
||||
coordinator.completeAddHost(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum RootMetrics {
|
||||
static let bannerHorizontalPadding: CGFloat = 16
|
||||
static let bannerVerticalPadding: CGFloat = 8
|
||||
}
|
||||
|
||||
private enum RootCopy {
|
||||
static let continueLast = "继续上次会话"
|
||||
}
|
||||
129
ios/App/WebTerm/Wiring/SessionActivityBridge.swift
Normal file
129
ios/App/WebTerm/Wiring/SessionActivityBridge.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
|
||||
/// T-iOS-15 · Third fan-out branch: session-level side effects that belong to
|
||||
/// neither terminal nor gate UI.
|
||||
///
|
||||
/// - `.adopted` → remember the SERVER-issued id (always adopt it — attaching
|
||||
/// an unknown UUID yields a brand-new session) and persist it per host via
|
||||
/// `LastSessionStore` (TerminalViewModel's doc assigns this persistence to
|
||||
/// the T-iOS-15 wiring). This id is also what a background→foreground
|
||||
/// rebuild re-attaches to.
|
||||
/// - `.gate` → forward pending (gate != nil) into
|
||||
/// `SessionListViewModel.setPendingApproval` — the ⚠ overlay hook the list
|
||||
/// VM documents ("The T-iOS-15 wiring forwards gate/status events into
|
||||
/// setPendingApproval"). Gates arriving before adoption have no session to
|
||||
/// attribute and are dropped (cannot happen on the real wire: `attached` is
|
||||
/// always first — defensive only).
|
||||
/// - `.exited` → clear the ⚠ overlay AND the persisted lastSessionId: a dead
|
||||
/// session must not stay flagged nor be offered as "继续上次" (documented
|
||||
/// decision — otherwise cold start would silently spawn a NEW session via
|
||||
/// the unknown-UUID attach path).
|
||||
///
|
||||
/// A detach (stream finish without exit) deliberately KEEPS the ⚠ overlay:
|
||||
/// the server still holds the gate; the list poll prunes it if the session
|
||||
/// disappears.
|
||||
@MainActor
|
||||
final class SessionActivityBridge {
|
||||
private(set) var adoptedSessionId: UUID?
|
||||
|
||||
private let events: AsyncStream<SessionEvent>
|
||||
private let hostId: UUID
|
||||
private let lastSessionStore: any LastSessionStore
|
||||
private let onPendingChanged: @MainActor (UUID, Bool) -> Void
|
||||
private var consumeTask: Task<Void, Never>?
|
||||
|
||||
// Deterministic test barrier (same pattern as the W3 ViewModels).
|
||||
private(set) var processedEventCount = 0
|
||||
private var eventWaiters: [CountWaiter] = []
|
||||
|
||||
private struct CountWaiter {
|
||||
let target: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
|
||||
init(
|
||||
events: AsyncStream<SessionEvent>,
|
||||
hostId: UUID,
|
||||
lastSessionStore: any LastSessionStore,
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||
) {
|
||||
self.events = events
|
||||
self.hostId = hostId
|
||||
self.lastSessionStore = lastSessionStore
|
||||
self.onPendingChanged = onPendingChanged
|
||||
}
|
||||
|
||||
/// Begin consuming. Idempotent — the branch has exactly one consumer.
|
||||
func start() {
|
||||
guard consumeTask == nil else { return }
|
||||
consumeTask = Task { [weak self] in
|
||||
guard let events = self?.events else { return }
|
||||
for await event in events {
|
||||
guard let self else { return }
|
||||
self.apply(event)
|
||||
}
|
||||
self?.releaseAllWaiters()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
consumeTask?.cancel()
|
||||
consumeTask = nil
|
||||
releaseAllWaiters()
|
||||
}
|
||||
|
||||
// MARK: - Event application
|
||||
|
||||
private func apply(_ event: SessionEvent) {
|
||||
switch event {
|
||||
case .adopted(let sessionId):
|
||||
adoptedSessionId = sessionId
|
||||
lastSessionStore.setLastSessionId(sessionId, host: hostId)
|
||||
case .gate(let gate):
|
||||
forwardPending(gate != nil)
|
||||
case .exited:
|
||||
forwardPending(false)
|
||||
lastSessionStore.setLastSessionId(nil, host: hostId)
|
||||
case .connection, .output, .telemetry, .digest:
|
||||
break // terminal / gate UI domain
|
||||
}
|
||||
processedEventCount += 1
|
||||
resumeEventWaiters()
|
||||
}
|
||||
|
||||
private func forwardPending(_ pending: Bool) {
|
||||
guard let adoptedSessionId else { return }
|
||||
onPendingChanged(adoptedSessionId, pending)
|
||||
}
|
||||
|
||||
// MARK: - Test barrier
|
||||
|
||||
/// Suspends until at least `eventCount` events have been applied.
|
||||
func waitUntilProcessed(eventCount target: Int) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard processedEventCount < target else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeEventWaiters() {
|
||||
let satisfied = eventWaiters.filter { $0.target <= processedEventCount }
|
||||
eventWaiters = eventWaiters.filter { $0.target > processedEventCount }
|
||||
for waiter in satisfied {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseAllWaiters() {
|
||||
let all = eventWaiters
|
||||
eventWaiters = []
|
||||
for waiter in all {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
103
ios/App/WebTerm/Wiring/TerminalContainerView.swift
Normal file
103
ios/App/WebTerm/Wiring/TerminalContainerView.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-15 · Terminal destination view: TerminalScreen (T-iOS-11) plus the
|
||||
/// gate/digest surfaces (T-iOS-14) wired to the controller's GateViewModel —
|
||||
/// the "接入 TerminalScreen 的 wiring 归 T-iOS-15" hand-off.
|
||||
///
|
||||
/// Layout decisions (documented):
|
||||
/// - `.id(controller.generation)`: a rebuild (background→foreground) swaps the
|
||||
/// ViewModels; without a new identity SwiftUI would keep the old UIView and
|
||||
/// `makeUIView` (where the output sink attaches) would never run again.
|
||||
/// - Digest + tool-gate banner live in a TOP overlay stack: the bottom edge
|
||||
/// belongs to the keyboard + key bar. TerminalScreen's own ReconnectBanner
|
||||
/// is also top-aligned, but it hides on `.connected` while gates/digests
|
||||
/// only arrive connected — practical overlap is nil.
|
||||
/// - Plan gate presents as a medium-detent sheet; swiping it away is allowed
|
||||
/// (the user may need to scroll the terminal to read the plan), and a
|
||||
/// "计划待批" re-entry chip appears until the gate resolves. Deciding
|
||||
/// removes the gate server-side → `planGate` goes nil → sheet dismisses.
|
||||
struct TerminalContainerView: View {
|
||||
let controller: TerminalSessionController
|
||||
/// Epoch of a plan gate the user swiped away — suppresses re-present for
|
||||
/// THAT gate only; a new epoch re-presents automatically.
|
||||
@State private var dismissedPlanGateEpoch: Int?
|
||||
|
||||
private enum Metrics {
|
||||
static let overlaySpacing: CGFloat = 8
|
||||
static let overlayHorizontalPadding: CGFloat = 12
|
||||
/// Clears TerminalScreen's own top-aligned ReconnectBanner zone.
|
||||
static let overlayTopPadding: CGFloat = 52
|
||||
}
|
||||
|
||||
private enum Copy {
|
||||
static let planGatePending = "⚠ 计划待批准"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TerminalScreen(viewModel: controller.terminalViewModel)
|
||||
.id(controller.generation)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.overlay(alignment: .top) { topOverlays }
|
||||
.sheet(isPresented: planGateBinding) { planGateSheet }
|
||||
}
|
||||
|
||||
// MARK: - Digest + tool gate (top stack)
|
||||
|
||||
@ViewBuilder private var topOverlays: some View {
|
||||
let gateViewModel = controller.gateViewModel
|
||||
VStack(spacing: Metrics.overlaySpacing) {
|
||||
if let digest = gateViewModel.digest {
|
||||
AwayDigestView(
|
||||
digest: digest,
|
||||
isExpanded: gateViewModel.isDigestExpanded,
|
||||
onExpand: { gateViewModel.expandDigest() },
|
||||
onDismiss: { gateViewModel.dismissDigest() }
|
||||
)
|
||||
}
|
||||
if let gate = gateViewModel.toolGate {
|
||||
GateBanner(gate: gate) { affordance, epoch in
|
||||
gateViewModel.decide(affordance, epoch: epoch)
|
||||
}
|
||||
}
|
||||
if hasDismissedPendingPlanGate {
|
||||
Button(Copy.planGatePending) { dismissedPlanGateEpoch = nil }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Metrics.overlayHorizontalPadding)
|
||||
.padding(.top, Metrics.overlayTopPadding)
|
||||
.animation(.default, value: gateViewModel.toolGate)
|
||||
.animation(.default, value: gateViewModel.digest)
|
||||
}
|
||||
|
||||
// MARK: - Plan gate sheet
|
||||
|
||||
private var hasDismissedPendingPlanGate: Bool {
|
||||
guard let gate = controller.gateViewModel.planGate else { return false }
|
||||
return gate.epoch == dismissedPlanGateEpoch
|
||||
}
|
||||
|
||||
private var planGateBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
guard let gate = controller.gateViewModel.planGate else { return false }
|
||||
return gate.epoch != dismissedPlanGateEpoch
|
||||
},
|
||||
set: { presented in
|
||||
guard !presented else { return }
|
||||
dismissedPlanGateEpoch = controller.gateViewModel.planGate?.epoch
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private var planGateSheet: some View {
|
||||
if let gate = controller.gateViewModel.planGate {
|
||||
PlanGateSheet(gate: gate) { affordance, epoch in
|
||||
controller.gateViewModel.decide(affordance, epoch: epoch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
209
ios/App/WebTerm/Wiring/TerminalSessionController.swift
Normal file
209
ios/App/WebTerm/Wiring/TerminalSessionController.swift
Normal file
@@ -0,0 +1,209 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import SessionCore
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · One open terminal = one controller: builds and owns the
|
||||
/// per-session stack (engine → fan-out → TerminalViewModel + GateViewModel +
|
||||
/// SessionActivityBridge) and drives the scenePhase lifecycle.
|
||||
///
|
||||
/// Lifecycle (plan §7 T-iOS-15):
|
||||
/// - `.background` → `suspend()`: `engine.close()` — a clean detach (the
|
||||
/// server-side PTY keeps running), never a half-dead socket that iOS will
|
||||
/// kill mid-suspend anyway.
|
||||
/// - `.active` after a suspend → `resumeIfNeeded()`: `close()` is terminal for
|
||||
/// an engine, so the stack is REBUILT and re-opened against the
|
||||
/// server-adopted sessionId. The fresh SwiftTerm view (new `generation` →
|
||||
/// new UIView) replays the full ring buffer and fires `sizeChanged` on first
|
||||
/// layout — that resize is the latest-writer-wins full-screen reclaim
|
||||
/// (换设备夺回全屏), immediately after the attach.
|
||||
/// - `.active` with the engine still alive (transient `.inactive`, e.g.
|
||||
/// control center / app switcher peek): `engine.notifyForegrounded(dims:)`
|
||||
/// with `TerminalViewModel.lastSentDims` — reconnect-if-needed + resize
|
||||
/// reclaim (latest-writer-wins). Skipped only when no valid dims were ever
|
||||
/// sent (SwiftTerm has not laid out yet → its first `sizeChanged` covers
|
||||
/// it). The W4 documented deviation is CLOSED (orchestrator added the
|
||||
/// `lastSentDims` accessor on behalf of the T-iOS-11 owner).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TerminalSessionController: Identifiable {
|
||||
let id = UUID()
|
||||
|
||||
/// Bumped on every rebuild; the container view uses it as the SwiftUI
|
||||
/// identity of `TerminalScreen`, forcing a fresh `makeUIView` so the new
|
||||
/// ViewModel's output sink actually attaches to a new SwiftTerm view.
|
||||
private(set) var generation = 0
|
||||
private(set) var terminalViewModel: TerminalViewModel
|
||||
private(set) var gateViewModel: GateViewModel
|
||||
|
||||
@ObservationIgnored private let host: HostRegistry.Host
|
||||
@ObservationIgnored private let environment: AppEnvironment
|
||||
@ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void
|
||||
@ObservationIgnored private var engine: SessionEngine
|
||||
@ObservationIgnored private var fanOut: EventFanOut<SessionEvent>
|
||||
@ObservationIgnored private var bridge: SessionActivityBridge
|
||||
/// What the next (re)open attaches to: the server-adopted id once known,
|
||||
/// else the list's requested id (nil = new session).
|
||||
@ObservationIgnored private var targetSessionId: UUID?
|
||||
@ObservationIgnored private var isSuspended = false
|
||||
@ObservationIgnored private var hasStarted = false
|
||||
@ObservationIgnored private var isTornDown = false
|
||||
|
||||
/// Fan-out branch order (single place, no magic indices).
|
||||
private enum Branch {
|
||||
static let terminal = 0
|
||||
static let gate = 1
|
||||
static let activity = 2
|
||||
static let count = 3
|
||||
}
|
||||
|
||||
init(
|
||||
host: HostRegistry.Host,
|
||||
sessionId: UUID?,
|
||||
environment: AppEnvironment,
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||
) {
|
||||
self.host = host
|
||||
self.environment = environment
|
||||
self.onPendingChanged = onPendingChanged
|
||||
self.targetSessionId = sessionId
|
||||
let stack = Self.makeStack(
|
||||
host: host, environment: environment, onPendingChanged: onPendingChanged
|
||||
)
|
||||
engine = stack.engine
|
||||
fanOut = stack.fanOut
|
||||
terminalViewModel = stack.terminalViewModel
|
||||
gateViewModel = stack.gateViewModel
|
||||
bridge = stack.bridge
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle entry points
|
||||
|
||||
/// Kick off: consumers first, then the engine connect (events buffer
|
||||
/// unbounded, so this order is belt-and-braces, not a race fix).
|
||||
func start() {
|
||||
guard !hasStarted, !isTornDown else { return }
|
||||
hasStarted = true
|
||||
startConsumers()
|
||||
let engine = engine
|
||||
let sessionId = targetSessionId
|
||||
Task { await engine.open(sessionId: sessionId, cwd: nil) }
|
||||
}
|
||||
|
||||
/// scenePhase → `.background`: clean detach (PTY keeps running).
|
||||
func suspend() {
|
||||
guard hasStarted, !isSuspended, !isTornDown else { return }
|
||||
isSuspended = true
|
||||
rememberAdoptedSession()
|
||||
stopConsumers()
|
||||
let engine = engine
|
||||
Task { await engine.close() }
|
||||
}
|
||||
|
||||
/// scenePhase → `.active`: rebuild-and-reopen if we suspended; with the
|
||||
/// engine still alive, notifyForegrounded (reconnect + size reclaim).
|
||||
func resumeIfNeeded() {
|
||||
guard !isTornDown else { return }
|
||||
guard isSuspended else {
|
||||
notifyForegroundedIfPossible()
|
||||
return
|
||||
}
|
||||
isSuspended = false
|
||||
rebuildStack()
|
||||
startConsumers()
|
||||
let engine = engine
|
||||
let sessionId = targetSessionId
|
||||
Task { await engine.open(sessionId: sessionId, cwd: nil) }
|
||||
}
|
||||
|
||||
/// Alive-engine `.active` hop: feed the engine the last VALID dims the
|
||||
/// terminal reported so it reconnects (if dropped during `.inactive`) and
|
||||
/// re-stamps the PTY size (latest-writer-wins reclaims full screen).
|
||||
private func notifyForegroundedIfPossible() {
|
||||
guard hasStarted, let dims = terminalViewModel.lastSentDims else { return }
|
||||
let engine = engine
|
||||
Task { await engine.notifyForegrounded(dims: dims.asTuple) }
|
||||
}
|
||||
|
||||
/// Back navigation / screen dismissed: detach for good. Idempotent.
|
||||
func teardown() {
|
||||
guard !isTornDown else { return }
|
||||
isTornDown = true
|
||||
rememberAdoptedSession()
|
||||
stopConsumers()
|
||||
let engine = engine
|
||||
Task { await engine.close() }
|
||||
}
|
||||
|
||||
// MARK: - Stack assembly
|
||||
|
||||
private struct Stack {
|
||||
let engine: SessionEngine
|
||||
let fanOut: EventFanOut<SessionEvent>
|
||||
let terminalViewModel: TerminalViewModel
|
||||
let gateViewModel: GateViewModel
|
||||
let bridge: SessionActivityBridge
|
||||
}
|
||||
|
||||
private static func makeStack(
|
||||
host: HostRegistry.Host,
|
||||
environment: AppEnvironment,
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||
) -> Stack {
|
||||
let engine = SessionEngine(
|
||||
transport: environment.termTransport,
|
||||
clock: ContinuousClock(),
|
||||
endpoint: host.endpoint,
|
||||
eventsSource: environment.makeEventsSource(endpoint: host.endpoint)
|
||||
)
|
||||
let fanOut = EventFanOut(source: engine.events, branchCount: Branch.count)
|
||||
return Stack(
|
||||
engine: engine,
|
||||
fanOut: fanOut,
|
||||
terminalViewModel: TerminalViewModel(
|
||||
engine: engine, events: fanOut.branches[Branch.terminal]
|
||||
),
|
||||
gateViewModel: GateViewModel(
|
||||
engine: engine, events: fanOut.branches[Branch.gate],
|
||||
haptics: GateHaptics(), clock: ContinuousClock()
|
||||
),
|
||||
bridge: SessionActivityBridge(
|
||||
events: fanOut.branches[Branch.activity], hostId: host.id,
|
||||
lastSessionStore: environment.lastSessionStore,
|
||||
onPendingChanged: onPendingChanged
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func rebuildStack() {
|
||||
fanOut.cancel()
|
||||
let stack = Self.makeStack(
|
||||
host: host, environment: environment, onPendingChanged: onPendingChanged
|
||||
)
|
||||
engine = stack.engine
|
||||
fanOut = stack.fanOut
|
||||
terminalViewModel = stack.terminalViewModel
|
||||
gateViewModel = stack.gateViewModel
|
||||
bridge = stack.bridge
|
||||
generation += 1 // new SwiftUI identity → fresh SwiftTerm view
|
||||
}
|
||||
|
||||
private func startConsumers() {
|
||||
terminalViewModel.start()
|
||||
gateViewModel.start()
|
||||
bridge.start()
|
||||
}
|
||||
|
||||
private func stopConsumers() {
|
||||
terminalViewModel.stop()
|
||||
gateViewModel.stop()
|
||||
bridge.stop()
|
||||
}
|
||||
|
||||
/// The reopen target: always prefer the server-adopted id (it may differ
|
||||
/// from what was requested — unknown UUIDs mint new sessions).
|
||||
private func rememberAdoptedSession() {
|
||||
targetSessionId = bridge.adoptedSessionId ?? targetSessionId
|
||||
}
|
||||
}
|
||||
26
ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift
Normal file
26
ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves
|
||||
/// the URLSession wrapper for the production side; no package owns it, so the
|
||||
/// assembly layer provides it). Deliberately logic-free: `APIClient` builds
|
||||
/// every request — including the Origin-iff-G rule (plan §3.4 铁律) — and this
|
||||
/// type only performs the exchange. Adding ANY header/URL logic here would
|
||||
/// bypass that single audited point (review CRITICAL).
|
||||
struct URLSessionHTTPTransport: HTTPTransport {
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
// http(s)-only endpoints (HostEndpoint validates) always produce
|
||||
// an HTTPURLResponse; anything else is a transport-level anomaly.
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
return (data, httpResponse)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user