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
104 lines
4.2 KiB
Swift
104 lines
4.2 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|