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:
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user