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:
Yaojia Wang
2026-07-05 01:04:42 +02:00
parent 5098643355
commit cc4d3129cc
22 changed files with 1752 additions and 106 deletions

View 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 backgroundforeground
/// 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()
}
}
}