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
127 lines
3.9 KiB
Swift
127 lines
3.9 KiB
Swift
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 = "继续上次会话"
|
|
}
|