feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
This commit is contained in:
@@ -2,6 +2,7 @@ import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Navigation + lifecycle owner: Pairing → SessionList → Terminal
|
||||
/// with the production dependency graph (plan §7 T-iOS-15 step 1).
|
||||
@@ -25,16 +26,25 @@ final class AppCoordinator {
|
||||
/// Add-host pairing VM (sheet from the list header).
|
||||
private(set) var addHostPairingViewModel: PairingViewModel?
|
||||
var isAddHostPresented = false
|
||||
/// T-iOS-26 · Projects sheet(入口在 RootView 的 toolbar —— 不碰
|
||||
/// `SessionListScreen`,该文件 W7 内归 T-iOS-23)。每次呈现新建 VM,
|
||||
/// prefs 每次进入都重新拉取。
|
||||
private(set) var projectsViewModel: ProjectsViewModel?
|
||||
var isProjectsPresented = false
|
||||
|
||||
let sessionList: SessionListViewModel
|
||||
@ObservationIgnored let environment: AppEnvironment
|
||||
/// T-iOS-22 · Deep-link handler; all routing/wiring logic lives in
|
||||
/// DeepLinkRouter.swift (incl. the `makeDeepLinkHandler` extension).
|
||||
@ObservationIgnored private(set) lazy var deepLink: DeepLinkHandler = makeDeepLinkHandler()
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
sessionList = SessionListViewModel(
|
||||
hostStore: environment.hostStore,
|
||||
http: environment.http,
|
||||
clock: ContinuousClock()
|
||||
clock: ContinuousClock(),
|
||||
unreadStore: environment.unreadStore
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,6 +64,7 @@ final class AppCoordinator {
|
||||
if route == .pairing {
|
||||
rootPairingViewModel = makePairingViewModel()
|
||||
}
|
||||
await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22)
|
||||
}
|
||||
|
||||
/// First-run pairing done → move to the list (the paired host is already
|
||||
@@ -83,30 +94,110 @@ final class AppCoordinator {
|
||||
Task { await sessionList.reloadHosts() }
|
||||
}
|
||||
|
||||
// MARK: - Projects (T-iOS-26)
|
||||
|
||||
/// Toolbar 入口(RootView):以当前活跃主机呈现 Projects sheet。
|
||||
func presentProjects() {
|
||||
guard let host = sessionList.activeHost else { return }
|
||||
projectsViewModel = ProjectsViewModel(host: host, http: environment.http)
|
||||
isProjectsPresented = true
|
||||
}
|
||||
|
||||
/// Sheet 消失(打开会话 OR 手动关闭):丢弃 VM。
|
||||
func projectsDismissed() {
|
||||
projectsViewModel = nil
|
||||
}
|
||||
|
||||
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
||||
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
||||
func openProject(_ request: ProjectOpenRequest) {
|
||||
guard terminalController == nil else { return } // one foreground session
|
||||
isProjectsPresented = false
|
||||
projectsViewModel = nil
|
||||
startTerminal(
|
||||
host: request.host, sessionId: nil,
|
||||
spawnCwd: request.cwd, bootstrapInput: request.bootstrapInput
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Terminal open/close
|
||||
|
||||
/// `SessionListScreen.onOpen` (one navigation signal per tap) and the
|
||||
/// "继续上次" banner both land here. `sessionId == nil` = new session.
|
||||
func open(_ request: SessionListViewModel.OpenRequest) {
|
||||
guard terminalController == nil else { return } // one foreground session
|
||||
startTerminal(host: request.host, sessionId: request.sessionId)
|
||||
}
|
||||
|
||||
/// 唯一的 controller 构造点(普通打开与 T-iOS-26 项目内 spawn 共用)。
|
||||
private func startTerminal(
|
||||
host: HostRegistry.Host,
|
||||
sessionId: UUID?,
|
||||
spawnCwd: String? = nil,
|
||||
bootstrapInput: String? = nil
|
||||
) {
|
||||
let controller = TerminalSessionController(
|
||||
host: request.host,
|
||||
sessionId: request.sessionId,
|
||||
host: host,
|
||||
sessionId: sessionId,
|
||||
environment: environment,
|
||||
onPendingChanged: { [weak self] sessionId, pending in
|
||||
self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending)
|
||||
}
|
||||
},
|
||||
onTitleChanged: { [weak self] sessionId, title in
|
||||
// T-iOS-23 · OSC title → list row (already sanitized in the
|
||||
// VM; the list VM sanitizes once more at its own boundary).
|
||||
self?.sessionList.setSessionTitle(sessionId: sessionId, title: title)
|
||||
},
|
||||
spawnCwd: spawnCwd,
|
||||
bootstrapInput: bootstrapInput
|
||||
)
|
||||
terminalController = controller
|
||||
controller.start()
|
||||
}
|
||||
|
||||
/// Back navigation popped the terminal: explicit detach.
|
||||
/// Back navigation popped the terminal: explicit detach. Also the first
|
||||
/// half of every session SWITCH (single live WS invariant, plan §1):
|
||||
/// list back-nav and `openDeepLinkedSession` both close here before the
|
||||
/// next `open` — one engine at a time, always close→open with replay.
|
||||
func closeTerminal() {
|
||||
// T-iOS-23 · leaving = seen: stamp the unread watermark for the
|
||||
// adopted session so output watched in the terminal never relights
|
||||
// the list dot.
|
||||
if let sessionId = terminalController?.terminalViewModel.sessionId {
|
||||
sessionList.markSeen(sessionId: sessionId)
|
||||
}
|
||||
terminalController?.teardown()
|
||||
terminalController = nil
|
||||
}
|
||||
|
||||
// MARK: - "在当前目录开新会话" (T-iOS-29)
|
||||
|
||||
/// 当前终端会话的 cwd。解析次序:/live-sessions 行数据(server-adopted
|
||||
/// id 匹配行,列表 VM 的最近快照)→ controller 的 `spawnCwd`(T-iOS-26
|
||||
/// 项目 fresh-spawn 尚未进列表轮询)→ nil(未知)。服务器数据不可信:
|
||||
/// 非绝对路径按未知处理(ProjectsViewModel 同款纪律;engine 侧还会再验)。
|
||||
var currentTerminalCwd: String? {
|
||||
guard let controller = terminalController else { return nil }
|
||||
let fromRows = controller.terminalViewModel.sessionId.flatMap { id in
|
||||
sessionList.rows.first(where: { $0.id == id })?.info.cwd
|
||||
}
|
||||
let candidate = fromRows ?? controller.spawnCwd
|
||||
return candidate.flatMap { Validation.isAbsoluteCwd($0) ? $0 : nil }
|
||||
}
|
||||
|
||||
/// TerminalScreen 工具栏与 exit 横幅共用动作:在当前会话的 cwd fresh
|
||||
/// spawn(`attach(null, cwd)`,镜像 web tabs.ts `newTab()` M6)。单活
|
||||
/// WS 不变式:先 `closeTerminal()`(旧会话记 last-seen 水位、engine
|
||||
/// detach),再开新 controller。cwd 未知 → 普通新会话;无打开的终端
|
||||
/// → no-op。不注入 bootstrap —— "开新 shell"不是"起 claude"。
|
||||
func openNewSessionInCurrentCwd() {
|
||||
guard let controller = terminalController else { return }
|
||||
let host = controller.host
|
||||
let cwd = currentTerminalCwd
|
||||
closeTerminal()
|
||||
startTerminal(host: host, sessionId: nil, spawnCwd: cwd)
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" (cold start step 5)
|
||||
|
||||
var continueLastSessionId: UUID? {
|
||||
|
||||
@@ -31,6 +31,10 @@ struct AppEnvironment: Sendable {
|
||||
/// over the real transports (two-step: RO GET, then WS attach + guarded
|
||||
/// kill; only runs after the user's explicit confirm, T-iOS-12).
|
||||
let probe: PairingViewModel.Probe
|
||||
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
|
||||
/// `var` + default so the memberwise init stays source-compatible for
|
||||
/// pre-P1 call sites while tests can inject an in-memory fake.
|
||||
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
||||
|
||||
static func production() -> AppEnvironment {
|
||||
let http = URLSessionHTTPTransport()
|
||||
|
||||
@@ -28,12 +28,35 @@ struct RootView: View {
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
coordinator.handleScenePhase(phase)
|
||||
}
|
||||
.onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22
|
||||
.alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) {
|
||||
Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() }
|
||||
} message: {
|
||||
Text(coordinator.deepLink.hintMessage ?? "")
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $coordinator.isAddHostPresented,
|
||||
onDismiss: { coordinator.addHostDismissed() }
|
||||
) {
|
||||
addHostSheet
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $coordinator.isProjectsPresented,
|
||||
onDismiss: { coordinator.projectsDismissed() }
|
||||
) {
|
||||
projectsSheet
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-link hint alert (unknown host / store failure, T-iOS-22).
|
||||
private var deepLinkHintBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.deepLink.hintMessage != nil },
|
||||
set: { presented in
|
||||
guard !presented else { return }
|
||||
coordinator.deepLink.clearHint()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Route switch
|
||||
@@ -66,6 +89,22 @@ struct RootView: View {
|
||||
onAddHost: { coordinator.presentAddHost() }
|
||||
)
|
||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||
// T-iOS-26 · Projects 入口挂在 RootView 层(SessionListScreen 是
|
||||
// T-iOS-23 的 W7 独占文件 —— 列表侧入口整合移交给它)。leading 位,
|
||||
// 避开列表自己的 topBarTrailing hostMenu。
|
||||
.toolbar { projectsToolbarItem }
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder private var projectsToolbarItem: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
coordinator.presentProjects()
|
||||
} label: {
|
||||
Label(RootCopy.projects, systemImage: "folder")
|
||||
}
|
||||
.disabled(coordinator.sessionList.activeHost == nil)
|
||||
.accessibilityIdentifier("sessions.projectsButton")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" highlight (cold start step 5)
|
||||
@@ -99,7 +138,17 @@ struct RootView: View {
|
||||
|
||||
@ViewBuilder private var terminalDestination: some View {
|
||||
if let controller = coordinator.terminalController {
|
||||
TerminalContainerView(controller: controller)
|
||||
TerminalContainerView(
|
||||
controller: controller,
|
||||
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() }
|
||||
)
|
||||
// T-iOS-29 · identity PER CONTROLLER: an in-place session switch
|
||||
// (new-in-cwd, deep link) swaps the controller while the
|
||||
// destination stays presented — without a new identity SwiftUI
|
||||
// keeps the old SwiftTerm UIView and the new ViewModel's output
|
||||
// sink never attaches (makeUIView never re-runs). Also resets the
|
||||
// container's per-session @State (plan-gate dismissal, timeline).
|
||||
.id(controller.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +163,20 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Projects sheet (T-iOS-26)
|
||||
|
||||
/// 自带 NavigationStack:列表 → 详情 → 差异都在 sheet 内 push;
|
||||
/// "在此仓库开新会话" 关掉 sheet 并在根导航推入终端。
|
||||
@ViewBuilder private var projectsSheet: some View {
|
||||
if let viewModel = coordinator.projectsViewModel {
|
||||
NavigationStack {
|
||||
ProjectsScreen(viewModel: viewModel) { request in
|
||||
coordinator.openProject(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum RootMetrics {
|
||||
@@ -123,4 +186,5 @@ private enum RootMetrics {
|
||||
|
||||
private enum RootCopy {
|
||||
static let continueLast = "继续上次会话"
|
||||
static let projects = "项目"
|
||||
}
|
||||
|
||||
@@ -19,15 +19,26 @@ import SwiftUI
|
||||
/// removes the gate server-side → `planGate` goes nil → sheet dismisses.
|
||||
struct TerminalContainerView: View {
|
||||
let controller: TerminalSessionController
|
||||
/// T-iOS-29 · pass-through to TerminalScreen's toolbar/exit-banner
|
||||
/// "在当前目录开新会话" action (RootView supplies the coordinator hop).
|
||||
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
||||
/// 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?
|
||||
/// T-iOS-24 (additive) · Non-nil while the full-timeline sheet is up; a
|
||||
/// FRESH VM per presentation (each open re-fetches /events).
|
||||
@State private var timelineViewModel: TimelineViewModel?
|
||||
/// T-iOS-25 (additive) · Quick-reply palette store — per-container over
|
||||
/// `.standard` defaults (non-secret UI prefs, plan §5.3 split).
|
||||
@State private var quickReplyStore = QuickReplyStore()
|
||||
|
||||
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
|
||||
/// Breathing room between the chip row and the keyboard/key-bar edge.
|
||||
static let quickReplyBottomPadding: CGFloat = 8
|
||||
}
|
||||
|
||||
private enum Copy {
|
||||
@@ -35,11 +46,33 @@ struct TerminalContainerView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TerminalScreen(viewModel: controller.terminalViewModel)
|
||||
.id(controller.generation)
|
||||
TerminalScreen(
|
||||
viewModel: controller.terminalViewModel,
|
||||
onNewSessionInCwd: onNewSessionInCwd
|
||||
)
|
||||
.id(controller.generation)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.overlay(alignment: .top) { topOverlays }
|
||||
.overlay(alignment: .bottom) { quickReplyOverlay }
|
||||
.sheet(isPresented: planGateBinding) { planGateSheet }
|
||||
.sheet(item: $timelineViewModel) { TimelineSheet(viewModel: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Quick-reply chips (T-iOS-25, additive)
|
||||
|
||||
/// Chips float at the bottom edge (above the keyboard's safe area) ONLY
|
||||
/// while a gate is waiting — visibility/read-only logic lives in
|
||||
/// `QuickReplyBar.isVisible`, driven by the SAME fan-out branches the two
|
||||
/// VMs already consume (no extra branch needed).
|
||||
@ViewBuilder private var quickReplyOverlay: some View {
|
||||
QuickReplyBar(
|
||||
terminalViewModel: controller.terminalViewModel,
|
||||
gateViewModel: controller.gateViewModel,
|
||||
store: quickReplyStore
|
||||
)
|
||||
.padding(.horizontal, Metrics.overlayHorizontalPadding)
|
||||
.padding(.bottom, Metrics.quickReplyBottomPadding)
|
||||
.animation(.default, value: controller.gateViewModel.currentGate)
|
||||
}
|
||||
|
||||
// MARK: - Digest + tool gate (top stack)
|
||||
@@ -51,7 +84,7 @@ struct TerminalContainerView: View {
|
||||
AwayDigestView(
|
||||
digest: digest,
|
||||
isExpanded: gateViewModel.isDigestExpanded,
|
||||
onExpand: { gateViewModel.expandDigest() },
|
||||
onExpand: { expandDigestAndPresentTimeline() },
|
||||
onDismiss: { gateViewModel.dismissDigest() }
|
||||
)
|
||||
}
|
||||
@@ -73,6 +106,21 @@ struct TerminalContainerView: View {
|
||||
.animation(.default, value: gateViewModel.digest)
|
||||
}
|
||||
|
||||
// MARK: - Timeline drill-down (T-iOS-24, additive)
|
||||
|
||||
/// The digest「展开」affordance does BOTH: inline expansion (which cancels
|
||||
/// the auto-fade, T-iOS-14 semantics — the digest must survive under the
|
||||
/// sheet) and the full-timeline sheet. No adopted sessionId yet →
|
||||
/// `forSession` returns nil → inline expand only (defensive; a digest
|
||||
/// only ever arrives after `attached`).
|
||||
private func expandDigestAndPresentTimeline() {
|
||||
controller.gateViewModel.expandDigest()
|
||||
timelineViewModel = TimelineViewModel.forSession(
|
||||
controller.terminalViewModel.sessionId,
|
||||
source: controller.timelineEventsSource
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Plan gate sheet
|
||||
|
||||
private var hasDismissedPendingPlanGate: Bool {
|
||||
|
||||
@@ -37,9 +37,26 @@ final class TerminalSessionController: Identifiable {
|
||||
private(set) var terminalViewModel: TerminalViewModel
|
||||
private(set) var gateViewModel: GateViewModel
|
||||
|
||||
@ObservationIgnored private let host: HostRegistry.Host
|
||||
/// T-iOS-29 · read-only exposure for the coordinator's new-in-cwd flow
|
||||
/// (the host to reopen against — a controller is host-bound for life).
|
||||
@ObservationIgnored let host: HostRegistry.Host
|
||||
@ObservationIgnored private let environment: AppEnvironment
|
||||
@ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void
|
||||
/// T-iOS-23 · OSC-title outlet (adopted sessionId + SANITIZED title) —
|
||||
/// re-attached to every rebuilt TerminalViewModel so a background→
|
||||
/// foreground rebuild never silently drops the list-title feed.
|
||||
@ObservationIgnored private let onTitleChanged: @MainActor (UUID, String) -> Void
|
||||
/// T-iOS-26 · fresh-spawn 变体:新会话的工作目录(`attach(null, cwd)`)。
|
||||
/// 仅在目标 sessionId 为 nil 时生效 —— 服务器对既有会话忽略 cwd。
|
||||
/// (T-iOS-29 起对 coordinator 只读可见:fresh spawn 未进列表轮询时,
|
||||
/// new-in-cwd 以它为 cwd 回退。)
|
||||
@ObservationIgnored let spawnCwd: String?
|
||||
/// T-iOS-26 · attach 后注入的首条输入(如 `claude\r`)。engine 的
|
||||
/// attach-first 队列语义保证它绝不先于 attach 出线;采纳既有会话 id 后
|
||||
/// 的重开(suspend→resume)不再注入。
|
||||
@ObservationIgnored private let bootstrapInput: String?
|
||||
/// 最近一次 open(+bootstrap) 提交任务 —— 测试屏障(ProjectOpenWiringTests)。
|
||||
@ObservationIgnored private(set) var openTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var engine: SessionEngine
|
||||
@ObservationIgnored private var fanOut: EventFanOut<SessionEvent>
|
||||
@ObservationIgnored private var bridge: SessionActivityBridge
|
||||
@@ -62,11 +79,17 @@ final class TerminalSessionController: Identifiable {
|
||||
host: HostRegistry.Host,
|
||||
sessionId: UUID?,
|
||||
environment: AppEnvironment,
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void,
|
||||
onTitleChanged: @escaping @MainActor (UUID, String) -> Void = { _, _ in },
|
||||
spawnCwd: String? = nil,
|
||||
bootstrapInput: String? = nil
|
||||
) {
|
||||
self.host = host
|
||||
self.environment = environment
|
||||
self.onPendingChanged = onPendingChanged
|
||||
self.onTitleChanged = onTitleChanged
|
||||
self.spawnCwd = spawnCwd
|
||||
self.bootstrapInput = bootstrapInput
|
||||
self.targetSessionId = sessionId
|
||||
let stack = Self.makeStack(
|
||||
host: host, environment: environment, onPendingChanged: onPendingChanged
|
||||
@@ -76,6 +99,7 @@ final class TerminalSessionController: Identifiable {
|
||||
terminalViewModel = stack.terminalViewModel
|
||||
gateViewModel = stack.gateViewModel
|
||||
bridge = stack.bridge
|
||||
terminalViewModel.onTitleChanged = onTitleChanged
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle entry points
|
||||
@@ -86,9 +110,7 @@ final class TerminalSessionController: Identifiable {
|
||||
guard !hasStarted, !isTornDown else { return }
|
||||
hasStarted = true
|
||||
startConsumers()
|
||||
let engine = engine
|
||||
let sessionId = targetSessionId
|
||||
Task { await engine.open(sessionId: sessionId, cwd: nil) }
|
||||
openEngineAtTarget()
|
||||
}
|
||||
|
||||
/// scenePhase → `.background`: clean detach (PTY keeps running).
|
||||
@@ -112,9 +134,23 @@ final class TerminalSessionController: Identifiable {
|
||||
isSuspended = false
|
||||
rebuildStack()
|
||||
startConsumers()
|
||||
openEngineAtTarget()
|
||||
}
|
||||
|
||||
/// (Re)open 当前目标:既有会话 → 裸 attach;fresh spawn(目标 id 为
|
||||
/// nil)→ 带 cwd attach + attach 后注入 bootstrap(T-iOS-26)。suspend
|
||||
/// 前已采纳的 id 走前一分支,bootstrap 不会重放。
|
||||
private func openEngineAtTarget() {
|
||||
let engine = engine
|
||||
let sessionId = targetSessionId
|
||||
Task { await engine.open(sessionId: sessionId, cwd: nil) }
|
||||
let cwd = sessionId == nil ? spawnCwd : nil
|
||||
let bootstrap = sessionId == nil ? bootstrapInput : nil
|
||||
openTask = Task {
|
||||
await engine.open(sessionId: sessionId, cwd: cwd)
|
||||
if let bootstrap {
|
||||
await engine.send(.input(data: bootstrap))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alive-engine `.active` hop: feed the engine the last VALID dims the
|
||||
@@ -136,6 +172,15 @@ final class TerminalSessionController: Identifiable {
|
||||
Task { await engine.close() }
|
||||
}
|
||||
|
||||
// MARK: - Timeline drill-down (T-iOS-24, additive)
|
||||
|
||||
/// Per-host timeline fetcher for the container's `TimelineSheet` — the
|
||||
/// same `APIClient.events` wrapper the engine's away-digest uses; the
|
||||
/// single derivation point stays `AppEnvironment.makeEventsSource`.
|
||||
var timelineEventsSource: @Sendable (UUID) async throws -> [TimelineEvent] {
|
||||
environment.makeEventsSource(endpoint: host.endpoint)
|
||||
}
|
||||
|
||||
// MARK: - Stack assembly
|
||||
|
||||
private struct Stack {
|
||||
@@ -186,6 +231,7 @@ final class TerminalSessionController: Identifiable {
|
||||
terminalViewModel = stack.terminalViewModel
|
||||
gateViewModel = stack.gateViewModel
|
||||
bridge = stack.bridge
|
||||
terminalViewModel.onTitleChanged = onTitleChanged // rebuilt VM re-wired
|
||||
generation += 1 // new SwiftUI identity → fresh SwiftTerm view
|
||||
}
|
||||
|
||||
|
||||
44
ios/App/WebTerm/Wiring/UnreadWatermarkStore.swift
Normal file
44
ios/App/WebTerm/Wiring/UnreadWatermarkStore.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// T-iOS-23 · Persistence seam for `SessionCore.UnreadLedger` watermarks.
|
||||
/// The ledger itself is persistence-agnostic (plan §7) — the App layer wires
|
||||
/// UserDefaults here. NON-SECRET UI state only (plan §5.3 split: Keychain =
|
||||
/// secrets, UserDefaults = prefs), same tier as `LastSessionStore`.
|
||||
protocol UnreadWatermarkStore: Sendable {
|
||||
func load() -> [UUID: Int]
|
||||
func save(_ watermarks: [UUID: Int])
|
||||
}
|
||||
|
||||
/// UserDefaults-backed implementation with an injectable suite for tests
|
||||
/// (mirrors `UserDefaultsLastSessionStore`). `@unchecked Sendable`:
|
||||
/// `UserDefaults` is documented thread-safe and this wrapper adds no mutable
|
||||
/// state of its own.
|
||||
struct UserDefaultsUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable {
|
||||
private static let key = "unreadWatermarks"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func load() -> [UUID: Int] {
|
||||
guard let raw = defaults.dictionary(forKey: Self.key) as? [String: Int] else {
|
||||
return [:]
|
||||
}
|
||||
// Stored data crosses a storage boundary — validate every key; garbage
|
||||
// is dropped, never trusted (two case-variant spellings of one UUID
|
||||
// collapse via max, so this can never crash on duplicates).
|
||||
let pairs = raw.compactMap { key, value in
|
||||
UUID(uuidString: key).map { ($0, value) }
|
||||
}
|
||||
return Dictionary(pairs, uniquingKeysWith: max)
|
||||
}
|
||||
|
||||
func save(_ watermarks: [UUID: Int]) {
|
||||
let plist = Dictionary(
|
||||
uniqueKeysWithValues: watermarks.map { ($0.key.uuidString, $0.value) }
|
||||
)
|
||||
defaults.set(plist, forKey: Self.key)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user