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,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 = "继续上次会话"
}