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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -0,0 +1,251 @@
import SessionCore
import SwiftUI
/// T-iOS-25 · Quick-reply chip row + (plan §7; behavior mirror of
/// `public/quick-reply.ts`, data layer in `QuickReplyStore.swift`).
///
/// Visibility decision (documented): the plan step says "waiting ".
/// On the iOS `SessionEvent` stream the raw `ClaudeStatus` is folded away by
/// the engine (`SessionEngine.applyGateFrame` keeps only pending/gate), so the
/// stream's ONLY waiting projection is the HELD GATE a `status:'waiting',
/// pending:true` frame surfaces as `.gate(GateState)` and the lift as
/// `.gate(nil)`. Chips therefore float while a gate is held AND the terminal
/// is live (not exited/failed). Waiting-without-pending (a Notification
/// permission_prompt with no held relay) never reaches the stream accepted
/// limitation; the observation point stays the SAME fan-out branches the
/// Gate/Terminal VMs already consume (no new SessionCore surface, no extra
/// fan-out branch needed).
///
/// Send path: a chip tap goes through `TerminalViewModel.sendInput` the one
/// ordered send pump so a rapid double-tap yields two frames in tap order,
/// never interleaved, and the read-only guard drops taps on a dead terminal.
struct QuickReplyBar: View {
enum Copy {
static let managePhrases = "管理常用语"
}
private enum Metrics {
static let chipSpacing: CGFloat = 6
static let rowHorizontalPadding: CGFloat = 8
static let rowVerticalPadding: CGFloat = 6
}
let terminalViewModel: TerminalViewModel
let gateViewModel: GateViewModel
let store: QuickReplyStore
@State private var isPanelPresented = false
/// Pure visibility rule (see type doc): held gate = the stream's waiting
/// signal; read-only (exited/failed) always hides.
static func isVisible(gate: GateState?, isReadOnly: Bool) -> Bool {
gate != nil && !isReadOnly
}
/// The production tap path (also exercised directly by tests): compose the
/// payload via `QuickReplyPalette` and hand it to the VM's ordered pump.
static func send(_ chip: QuickReplyChip, through viewModel: TerminalViewModel) {
viewModel.sendInput(QuickReplyPalette.payload(for: chip))
}
var body: some View {
if Self.isVisible(
gate: gateViewModel.currentGate,
isReadOnly: terminalViewModel.isReadOnly
) {
chipRow
.sheet(isPresented: $isPanelPresented) {
QuickReplyPanel(store: store)
}
}
}
private var chipRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Metrics.chipSpacing) {
ForEach(store.allChips) { chip in
chipButton(chip)
}
managePhrasesButton
}
.padding(.horizontal, Metrics.rowHorizontalPadding)
.padding(.vertical, Metrics.rowVerticalPadding)
}
.background(.thinMaterial, in: Capsule())
.transition(.move(edge: .bottom).combined(with: .opacity))
}
private func chipButton(_ chip: QuickReplyChip) -> some View {
Button {
Self.send(chip, through: terminalViewModel)
} label: {
// Text(verbatim:) user/label strings render as inert text, never
// LocalizedStringKey/Markdown (SEC-L3 mirror; T-iOS-23 convention).
Text(verbatim: chip.label)
.lineLimit(1)
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.small)
}
private var managePhrasesButton: some View {
Button {
isPanelPresented = true
} label: {
Image(systemName: "plus")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.small)
.accessibilityLabel(Copy.managePhrases)
}
}
/// : user-phrase CRUD + drag reorder (), presented from the
/// chip row's `+`. The add/edit form mirrors the web inline editor: text,
/// optional label (defaults to text) and an append-Enter toggle that starts
/// on. Tapping an existing row loads it into the form for editing ().
struct QuickReplyPanel: View {
enum Copy {
static let title = "常用语"
static let addSection = "添加新常用语"
static let customSection = "自定义常用语"
static let textPlaceholder = "要发送的文本"
static let labelPlaceholder = "标签(可选,默认同文本)"
static let appendEnterToggle = "发送后自动回车"
static let addButton = "添加"
static let saveButton = "保存修改"
static let cancelEditButton = "取消编辑"
static let doneButton = "完成"
static let emptyState = "还没有自定义常用语"
/// Payload preview suffix for append-Enter chips (web: `${text}`).
static let enterSuffixSymbol = ""
}
let store: QuickReplyStore
@Environment(\.dismiss) private var dismiss
@State private var draftText = ""
@State private var draftLabel = ""
/// Mirrors the web editor default: `enterCheck.checked = true`.
@State private var draftAppendEnter = true
/// Non-nil while the form edits an existing chip instead of adding.
@State private var editingChipId: String?
var body: some View {
NavigationStack {
List {
customSection
editorSection
}
.navigationTitle(Copy.title)
.toolbar {
ToolbarItem(placement: .topBarLeading) { EditButton() }
ToolbarItem(placement: .topBarTrailing) {
Button(Copy.doneButton) { dismiss() }
}
}
}
}
// MARK: - Custom phrases (delete / reorder / tap-to-edit)
@ViewBuilder private var customSection: some View {
Section(Copy.customSection) {
if store.userChips.isEmpty {
Text(Copy.emptyState)
.foregroundStyle(.secondary)
} else {
ForEach(store.userChips) { chip in
Button {
beginEditing(chip)
} label: {
chipRow(chip)
}
.tint(.primary)
}
.onDelete { offsets in
// Resolve ids FIRST removing while iterating offsets
// would shift indices under us.
let ids = offsets.map { store.userChips[$0].id }
for id in ids {
store.removeChip(id: id)
}
}
.onMove { fromOffsets, toOffset in
store.moveChips(fromOffsets: fromOffsets, toOffset: toOffset)
}
}
}
}
private func chipRow(_ chip: QuickReplyChip) -> some View {
VStack(alignment: .leading) {
// Text(verbatim:) stored strings are boundary data (SEC-L3 mirror).
Text(verbatim: chip.label)
.lineLimit(1)
Text(verbatim: chip.appendEnter
? chip.text + Copy.enterSuffixSymbol
: chip.text)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
// MARK: - Add / edit form
@ViewBuilder private var editorSection: some View {
Section(Copy.addSection) {
TextField(Copy.textPlaceholder, text: $draftText)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
TextField(Copy.labelPlaceholder, text: $draftLabel)
Toggle(Copy.appendEnterToggle, isOn: $draftAppendEnter)
Button(editingChipId == nil ? Copy.addButton : Copy.saveButton) {
commitDraft()
}
.disabled(trimmedDraftText.isEmpty)
if editingChipId != nil {
Button(Copy.cancelEditButton, role: .cancel) {
clearDraft()
}
}
}
}
private var trimmedDraftText: String {
draftText.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func beginEditing(_ chip: QuickReplyChip) {
editingChipId = chip.id
draftText = chip.text
draftLabel = chip.label
draftAppendEnter = chip.appendEnter
}
/// Web `onSaveClick` semantics: empty text is a no-op (button is disabled
/// anyway belt and braces), empty label defaults to the text.
private func commitDraft() {
let text = trimmedDraftText
guard !text.isEmpty else { return }
let trimmedLabel = draftLabel.trimmingCharacters(in: .whitespacesAndNewlines)
let label = trimmedLabel.isEmpty ? text : trimmedLabel
if let id = editingChipId {
store.updateChip(id: id, text: text, label: label,
appendEnter: draftAppendEnter)
} else {
store.addChip(text: text, label: label, appendEnter: draftAppendEnter)
}
clearDraft()
}
private func clearDraft() {
editingChipId = nil
draftText = ""
draftLabel = ""
draftAppendEnter = true
}
}

View File

@@ -0,0 +1,200 @@
import Foundation
import Observation
import SessionCore
/// T-iOS-25 · Quick-reply data layer: chip model, pure immutable CRUD and the
/// UserDefaults-backed palette store a behavior mirror of
/// `public/quick-reply.ts` (Chip / addChip / removeChip / reorderChip /
/// updateChip / loadPalette / savePalette).
///
/// Storage decision (plan §5.3 split): the palette is NON-SECRET UI prefs
/// UserDefaults with an injectable suite (same pattern as
/// `UserDefaultsLastSessionStore`), never Keychain.
/// One sendable snippet (mirror of the web `Chip` interface).
struct QuickReplyChip: Sendable, Equatable, Codable, Identifiable {
/// Stable identifier (built-ins use the `__` prefix, user chips `user_`).
let id: String
/// Raw bytes to send (without the Enter suffix).
let text: String
/// Display label on the chip button. Rendered via `Text(verbatim:)` only
/// the SwiftUI analogue of the web's textContent-never-innerHTML (SEC-L3).
let label: String
/// If true, Enter (`\r`) is appended to `text` when sending.
let appendEnter: Bool
}
/// Pure palette operations every function returns a NEW array and never
/// mutates its input (web quick-reply.ts CRUD, verbatim semantics).
enum QuickReplyPalette {
/// The six built-in chips for Claude Code interaction, order and values
/// mirroring web `BUILT_IN_CHIPS`. Esc bytes resolve through `KeyByteMap`
/// hand-writing an escape sequence in the App layer is a review finding.
static let builtInChips: [QuickReplyChip] = [
QuickReplyChip(id: "__yes", text: "yes", label: "yes", appendEnter: true),
QuickReplyChip(id: "__continue", text: "continue", label: "continue",
appendEnter: true),
QuickReplyChip(id: "__1", text: "1", label: "1", appendEnter: true),
QuickReplyChip(id: "__2", text: "2", label: "2", appendEnter: true),
QuickReplyChip(id: "__3", text: "3", label: "3", appendEnter: true),
QuickReplyChip(id: "__esc", text: KeyByteMap.bytes(for: .esc), label: "Esc",
appendEnter: false),
]
/// Prefix for user-created chip ids (web: `user_<ts>_<rand>`; iOS: UUID).
static let userChipIdPrefix = "user_"
/// The full byte string a chip tap sends: text plus when `appendEnter`
/// the Enter byte from `KeyByteMap` (`\r`, 0x0D, NOT `\n`; CLAUDE.md gotcha).
static func payload(for chip: QuickReplyChip) -> String {
chip.text + (chip.appendEnter ? KeyByteMap.bytes(for: .enter) : "")
}
/// New array with `chip` appended (web `addChip`).
static func adding(_ chips: [QuickReplyChip], _ chip: QuickReplyChip) -> [QuickReplyChip] {
chips + [chip]
}
/// New array with the chip matching `id` removed (web `removeChip`).
static func removing(_ chips: [QuickReplyChip], id: String) -> [QuickReplyChip] {
chips.filter { $0.id != id }
}
/// New array with the chip at `fromIndex` moved to `toIndex` (web
/// `reorderChip` splice semantics). Either index out of bounds the input
/// returned unchanged. Operates on a local copy value semantics
/// guarantee the caller's array is never touched.
static func reordering(
_ chips: [QuickReplyChip], fromIndex: Int, toIndex: Int
) -> [QuickReplyChip] {
guard chips.indices.contains(fromIndex), chips.indices.contains(toIndex) else {
return chips
}
var result = chips
let moved = result.remove(at: fromIndex)
result.insert(moved, at: toIndex)
return result
}
/// New array where the chip with `id` has non-nil fields patched (web
/// `updateChip`: shallow merge, id immutable). Unknown id unchanged.
static func updating(
_ chips: [QuickReplyChip], id: String,
text: String? = nil, label: String? = nil, appendEnter: Bool? = nil
) -> [QuickReplyChip] {
chips.map { chip in
guard chip.id == id else { return chip }
return QuickReplyChip(
id: chip.id,
text: text ?? chip.text,
label: label ?? chip.label,
appendEnter: appendEnter ?? chip.appendEnter
)
}
}
}
/// Per-item lossy decode wrapper (mirror of the web `filter(isValidChip)`):
/// one malformed entry is dropped, the well-formed rest survive stored data
/// crosses a storage boundary and is never trusted (LastSessionStore pattern).
private struct LossyQuickReplyChip: Decodable {
let chip: QuickReplyChip?
init(from decoder: any Decoder) throws {
chip = try? QuickReplyChip(from: decoder)
}
}
/// Observable palette store: holds the user chips, persists every change to
/// the injected `UserDefaults` and exposes the render list (built-ins first,
/// then user chips web render order).
@MainActor
@Observable
final class QuickReplyStore {
/// Single UserDefaults key for the encoded user palette
/// (web: localStorage `web-terminal:quick-reply-palette`).
static let paletteDefaultsKey = "quickReplyPalette"
private(set) var userChips: [QuickReplyChip]
@ObservationIgnored private let defaults: UserDefaults
/// Built-in chips first, then the user palette (web `render()` order).
var allChips: [QuickReplyChip] {
QuickReplyPalette.builtInChips + userChips
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
userChips = Self.loadPalette(from: defaults)
}
/// Add a new phrase (web editor `onSaveClick` semantics): text/label are
/// trimmed, empty text is a no-op (returns false), empty label defaults to
/// the text, the id is minted with the `user_` prefix.
@discardableResult
func addChip(text: String, label: String, appendEnter: Bool) -> Bool {
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedText.isEmpty else { return false }
let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines)
let chip = QuickReplyChip(
id: QuickReplyPalette.userChipIdPrefix + UUID().uuidString,
text: trimmedText,
label: trimmedLabel.isEmpty ? trimmedText : trimmedLabel,
appendEnter: appendEnter
)
setUserChips(QuickReplyPalette.adding(userChips, chip))
return true
}
func removeChip(id: String) {
setUserChips(QuickReplyPalette.removing(userChips, id: id))
}
func moveChip(fromIndex: Int, toIndex: Int) {
setUserChips(QuickReplyPalette.reordering(
userChips, fromIndex: fromIndex, toIndex: toIndex
))
}
/// SwiftUI `List.onMove` adapter (IndexSet + insert-before destination on
/// the ORIGINAL array `Array.move` implements that convention).
func moveChips(fromOffsets: IndexSet, toOffset: Int) {
var next = userChips
next.move(fromOffsets: fromOffsets, toOffset: toOffset)
setUserChips(next)
}
func updateChip(
id: String, text: String? = nil, label: String? = nil, appendEnter: Bool? = nil
) {
setUserChips(QuickReplyPalette.updating(
userChips, id: id, text: text, label: label, appendEnter: appendEnter
))
}
/// Load the persisted palette. Missing key, non-JSON data or a non-array
/// root `[]`; malformed items are dropped per-entry. Never throws
/// (web `loadPalette` contract).
static func loadPalette(from defaults: UserDefaults) -> [QuickReplyChip] {
guard let data = defaults.data(forKey: paletteDefaultsKey) else { return [] }
guard let lossy = try? JSONDecoder().decode([LossyQuickReplyChip].self, from: data)
else { return [] }
return lossy.compactMap(\.chip)
}
// MARK: - Private
private func setUserChips(_ chips: [QuickReplyChip]) {
userChips = chips
persist(chips)
}
/// Best-effort save (web `savePalette` contract: storage failure never
/// throws or crashes the in-memory palette stays authoritative for the
/// session). Encoding a `[QuickReplyChip]` cannot practically fail.
private func persist(_ chips: [QuickReplyChip]) {
guard let data = try? JSONEncoder().encode(chips) else { return }
defaults.set(data, forKey: Self.paletteDefaultsKey)
}
}

View File

@@ -19,6 +19,11 @@ struct ReconnectBanner: View {
}
let model: Model
/// T-iOS-29 · "" on the EXITED banner only (session over the
/// natural next step; manager.ts:145-153 the old session is done for
/// good). nil = affordance hidden. Not offered on `.failed`: that copy
/// asks the user to change a knob first, not to spawn again.
var onNewSession: (@MainActor () -> Void)? = nil
private enum Metrics {
static let contentSpacing: CGFloat = 8
@@ -27,6 +32,10 @@ struct ReconnectBanner: View {
static let backgroundOpacity = 0.9
}
private enum Copy {
static let newSession = "开新会话"
}
var body: some View {
HStack(spacing: Metrics.contentSpacing) {
if isSpinning {
@@ -38,6 +47,12 @@ struct ReconnectBanner: View {
Text(text)
.font(.footnote)
.multilineTextAlignment(.leading)
if let onNewSession, Self.isNewSessionActionAvailable(for: model) {
Button(Copy.newSession) { onNewSession() }
.font(.footnote.bold())
.buttonStyle(.plain)
.accessibilityIdentifier("terminal.exitNewSessionButton")
}
}
.padding(.horizontal, Metrics.horizontalPadding)
.padding(.vertical, Metrics.verticalPadding)
@@ -46,6 +61,13 @@ struct ReconnectBanner: View {
.accessibilityElement(children: .combine)
}
/// The new-session affordance appears on the `.exited` banner only
/// pure predicate so the T-iOS-29 tests pin the rule.
static func isNewSessionActionAvailable(for model: Model) -> Bool {
if case .exited = model { return true }
return false
}
private var isSpinning: Bool {
switch model {
case .connecting, .reconnecting: return true

View File

@@ -0,0 +1,365 @@
import APIClient
import Foundation
import os
import SwiftUI
import UIKit
import WireProtocol
/// T-iOS-28 · 线`GET /live-sessions/:id/preview`
/// SwiftTerm UIImage /LRU
/// 线 SwiftUI
/// `SessionThumbnailRenderer.swift` import SwiftTerm
///
/// Steps
/// - ** = (sessionId, lastOutputAt)**`lastOutputAt`T-iOS-37 =
/// = nilP1
/// 5s
///
/// - **** spawn SwiftTerm
/// `SessionThumbnailRenderGate`FIFOpermit +
/// 线 `maxConcurrentRenders`
/// - ****404// `.placeholder`
/// lastOutputAt
/// - ****preview `data` SwiftTerm ANSI
/// id//
struct SessionThumbnailRequest: Equatable, Sendable {
let endpoint: HostEndpoint
let sessionId: UUID
/// `LiveSessionInfo.lastOutputAt`ms since epoch
let lastOutputAt: Int?
var key: SessionThumbnailKey {
SessionThumbnailKey(sessionId: sessionId, lastOutputAt: lastOutputAt)
}
}
/// =
struct SessionThumbnailKey: Hashable, Sendable {
let sessionId: UUID
let lastOutputAt: Int?
}
/// 线`Equatable` `.rendered` ===
enum SessionThumbnailImage: Sendable {
case rendered(UIImage)
case placeholder
var isPlaceholder: Bool {
if case .placeholder = self { return true }
return false
}
var uiImage: UIImage? {
if case .rendered(let image) = self { return image }
return nil
}
}
extension SessionThumbnailImage: Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.placeholder, .placeholder): return true
case (.rendered(let a), .rendered(let b)): return a === b
default: return false
}
}
}
/// feature WireProtocol `Tunables`
enum SessionThumbnailTunable {
/// + 线 spawn
static let maxConcurrentRenders = 2
/// LRU 2x
static let maxCacheEntries = 32
/// tail 24 KiBsrc/config.ts:46
/// DEFAULT_PREVIEW_BYTESenv `PREVIEW_BYTES` ~10×
///
static let maxPreviewDataBytes = 256 * 1024
/// /
/// 300
static let maxRenderCols = 300
static let maxRenderRows = 120
/// web `Math.max(2, )`public/preview-grid.ts:112-117
static let minRenderGrid = 2
}
// MARK: - LRU
/// LRU`order` 使
struct SessionThumbnailCache {
let maxEntries: Int
private let entries: [SessionThumbnailKey: SessionThumbnailImage]
private let order: [SessionThumbnailKey]
init(maxEntries: Int) {
self.init(maxEntries: maxEntries, entries: [:], order: [])
}
private init(
maxEntries: Int,
entries: [SessionThumbnailKey: SessionThumbnailImage],
order: [SessionThumbnailKey]
) {
self.maxEntries = max(1, maxEntries)
self.entries = entries
self.order = order
}
func value(for key: SessionThumbnailKey) -> SessionThumbnailImage? {
entries[key]
}
/// most-recently-used
func bumping(_ key: SessionThumbnailKey) -> SessionThumbnailCache {
guard entries[key] != nil else { return self }
return SessionThumbnailCache(
maxEntries: maxEntries, entries: entries,
order: order.filter { $0 != key } + [key]
)
}
/// least-recently-used
func inserting(
_ image: SessionThumbnailImage, for key: SessionThumbnailKey
) -> SessionThumbnailCache {
var newEntries = entries.merging([key: image]) { _, new in new }
var newOrder = order.filter { $0 != key } + [key]
while newOrder.count > maxEntries, let oldest = newOrder.first {
newOrder = Array(newOrder.dropFirst())
newEntries = newEntries.filter { $0.key != oldest }
}
return SessionThumbnailCache(
maxEntries: maxEntries, entries: newEntries, order: newOrder
)
}
}
// MARK: - FIFOpermit
/// 线`acquire` FIFO `release`
/// permit activeCount `waitUntilWaiting`
/// FakeClock.waitForSleepers continuation
@MainActor
final class SessionThumbnailRenderGate {
let limit: Int
private(set) var activeCount = 0
private(set) var peakActiveCount = 0
private var waiters: [CheckedContinuation<Void, Never>] = []
private var barriers: [(target: Int, cont: CheckedContinuation<Void, Never>)] = []
var waitingCount: Int { waiters.count }
init(limit: Int) {
self.limit = max(1, limit)
}
func acquire() async {
if activeCount < limit {
activeCount += 1
peakActiveCount = max(peakActiveCount, activeCount)
return
}
await withCheckedContinuation { cont in
waiters = waiters + [cont]
notifyBarriers()
}
// release() = permit activeCount
peakActiveCount = max(peakActiveCount, activeCount)
}
func release() {
guard waiters.isEmpty else {
let next = waiters[0]
waiters = Array(waiters.dropFirst())
next.resume() // permit activeCount
return
}
activeCount = max(0, activeCount - 1)
}
/// `count` acquire
func waitUntilWaiting(count: Int) async {
if waiters.count >= count { return }
await withCheckedContinuation { cont in
barriers = barriers + [(count, cont)]
}
}
private func notifyBarriers() {
let met = barriers.filter { $0.target <= waiters.count }
barriers = barriers.filter { $0.target > waiters.count }
for barrier in met { barrier.cont.resume() }
}
}
// MARK: - 线
@MainActor
final class SessionThumbnailPipeline {
/// = `APIClient.preview(id:)`RO GET Origin
typealias PreviewLoader = @MainActor (SessionThumbnailRequest) async throws -> SessionPreview
/// = `SessionThumbnailRenderer.render` SwiftTerm
/// `clampedGeometry`
typealias PreviewRenderer = @MainActor (_ data: String, _ cols: Int, _ rows: Int) -> UIImage?
private let loader: PreviewLoader
private let renderer: PreviewRenderer
private let gate: SessionThumbnailRenderGate
private var cache: SessionThumbnailCache
/// await
private var inFlight: [SessionThumbnailKey: Task<SessionThumbnailImage, Never>] = [:]
private let logger = Logger(
subsystem: SessionThumbnailLog.subsystem, category: SessionThumbnailLog.category
)
init(
loader: @escaping PreviewLoader,
renderer: @escaping PreviewRenderer,
gate: SessionThumbnailRenderGate? = nil,
maxCacheEntries: Int = SessionThumbnailTunable.maxCacheEntries
) {
self.loader = loader
self.renderer = renderer
self.gate = gate
?? SessionThumbnailRenderGate(limit: SessionThumbnailTunable.maxConcurrentRenders)
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
}
/// ephemeral URLSessionpreview
/// only T-iOS-19 RO GET
static func live() -> SessionThumbnailPipeline {
SessionThumbnailPipeline(
loader: { request in
try await APIClient(endpoint: request.endpoint, http: liveTransport)
.preview(id: request.sessionId)
},
renderer: { data, cols, rows in
SessionThumbnailRenderer.render(data: data, cols: cols, rows: rows)
}
)
}
private static let liveTransport = URLSessionHTTPTransport()
/// `.placeholder`
/// UI
func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage {
let key = request.key
if let hit = cache.value(for: key) {
cache = cache.bumping(key)
return hit
}
if let running = inFlight[key] {
return await running.value
}
let task = Task { await produce(request) }
inFlight = inFlight.merging([key: task]) { _, new in new }
let result = await task.value
inFlight = inFlight.filter { $0.key != key }
cache = cache.inserting(result, for: key)
return result
}
private func produce(_ request: SessionThumbnailRequest) async -> SessionThumbnailImage {
await gate.acquire()
defer { gate.release() }
let preview: SessionPreview
do {
preview = try await loader(request)
} catch {
logger.debug("preview fetch failed: \(String(describing: error), privacy: .public)")
return .placeholder
}
guard Self.isAcceptable(preview, for: request),
let grid = Self.clampedGeometry(cols: preview.cols, rows: preview.rows) else {
logger.debug("preview rejected at boundary (id/bytes/geometry)")
return .placeholder
}
guard let image = renderer(preview.data, grid.cols, grid.rows) else {
logger.debug("offscreen snapshot returned nil")
return .placeholder
}
return .rendered(image)
}
// MARK: - data SwiftTerm
/// id
static func isAcceptable(
_ preview: SessionPreview, for request: SessionThumbnailRequest
) -> Bool {
preview.id == request.sessionId
&& preview.data.utf8.count <= SessionThumbnailTunable.maxPreviewDataBytes
}
/// + resize `WireConstants.resizeRange`
/// 1...1000 nilmin web
/// `max(2,·)`max /
static func clampedGeometry(cols: Int, rows: Int) -> (cols: Int, rows: Int)? {
guard Validation.isValidResize(cols: cols, rows: rows) else { return nil }
let clampedCols = min(
max(cols, SessionThumbnailTunable.minRenderGrid),
SessionThumbnailTunable.maxRenderCols
)
let clampedRows = min(
max(rows, SessionThumbnailTunable.minRenderGrid),
SessionThumbnailTunable.maxRenderRows
)
return (clampedCols, clampedRows)
}
}
// MARK: - SwiftUI
/// `.task(id: key)` lastOutputAt
/// / /
/// UI
struct SessionThumbnailView: View {
let request: SessionThumbnailRequest
let pipeline: SessionThumbnailPipeline
@State private var image: SessionThumbnailImage?
private enum Metrics {
static let width: CGFloat = 88
static let height: CGFloat = 56
static let cornerRadius: CGFloat = 6
/// web public/preview-grid.ts PREVIEW_THEME
static let placeholderBackground = Color(
red: 14 / 255, green: 15 / 255, blue: 19 / 255
)
}
var body: some View {
content
.frame(width: Metrics.width, height: Metrics.height)
.clipShape(RoundedRectangle(cornerRadius: Metrics.cornerRadius))
.accessibilityLabel(SessionThumbnailCopy.thumbnailLabel)
.task(id: request.key) {
image = await pipeline.thumbnail(for: request)
}
}
@ViewBuilder private var content: some View {
if let snapshot = image?.uiImage {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
ZStack {
Metrics.placeholderBackground
Image(systemName: "terminal")
.foregroundStyle(.secondary)
}
}
}
}
///
enum SessionThumbnailCopy {
static let thumbnailLabel = "会话画面缩略图"
}
private enum SessionThumbnailLog {
static let subsystem = "com.yaojia.webterm"
static let category = "session-thumbnail"
}

View File

@@ -0,0 +1,85 @@
import SwiftTerm
import UIKit
/// T-iOS-28 · SwiftTerm preview tail ANSI
/// ****
/// `TerminalView` `UIGraphicsImageRenderer` view
///
/// web public/preview-grid.ts makePreviewCard/renderPreview
/// - = cols×rows
/// - `scrollback: 0` `changeScrollback(nil)`buffer
/// `contentOffset` 0 draw
/// - + web cursor==background DECTCEM
/// iOS caret
/// - `snapshotTargetWidth`
///
/// `TerminalView` runloop CADisplayLink
/// `updateUiClosed()` runloop
@MainActor
enum SessionThumbnailRenderer {
/// pt 88pt 2 `snapshotScale`=2
/// KB
static let snapshotTargetWidth: CGFloat = 176
/// 2x 3x × 2.25
static let snapshotScale: CGFloat = 2
/// web public/preview-grid.ts fontSize: 12
static let fontSize: CGFloat = 12
/// ptframe optimal + slack `Int(width/cellW)`
/// cell
static let layoutSlack: CGFloat = 0.5
/// DECTCEM iOS caret
///
static let hideCursorSequence = "\u{1b}[?25l"
/// web PREVIEW_THEME#0e0f13 / #e7e8ec
static let backgroundColor = UIColor(
red: 14 / 255, green: 15 / 255, blue: 19 / 255, alpha: 1
)
static let foregroundColor = UIColor(
red: 231 / 255, green: 232 / 255, blue: 236 / 255, alpha: 1
)
private static let initialProbeFrame = CGRect(x: 0, y: 0, width: 64, height: 64)
/// `cols`/`rows` `clampedGeometry`
/// nil
static func render(data: String, cols: Int, rows: Int) -> UIImage? {
let font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
let terminal = TerminalView(frame: initialProbeFrame, font: font)
defer { terminal.updateUiClosed() } // CADisplayLink
terminal.changeScrollback(nil) // web scrollback: 0
terminal.resize(cols: cols, rows: rows)
terminal.nativeBackgroundColor = backgroundColor
terminal.nativeForegroundColor = foregroundColor
let optimal = terminal.getOptimalFrameSize()
guard optimal.width > 0, optimal.height > 0 else { return nil }
terminal.frame = CGRect(
x: 0, y: 0,
width: optimal.width + layoutSlack, height: optimal.height + layoutSlack
)
terminal.layoutIfNeeded()
terminal.feed(text: data) // ANSI
terminal.feed(text: hideCursorSequence)
return snapshot(of: terminal, contentSize: optimal.size)
}
/// `UIGraphicsImageRenderer` + `layer.render(in:)` window
/// CoreGraphics draw SwiftTerm MetalMetal
/// `layer.render` `setUseMetal`
private static func snapshot(of view: UIView, contentSize: CGSize) -> UIImage? {
let scale = min(1, snapshotTargetWidth / contentSize.width)
let size = CGSize(
width: contentSize.width * scale, height: contentSize.height * scale
)
guard size.width >= 1, size.height >= 1 else { return nil }
let format = UIGraphicsImageRendererFormat()
format.scale = snapshotScale
format.opaque = true
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { context in
context.cgContext.scaleBy(x: scale, y: scale)
view.layer.render(in: context.cgContext)
}
}
}

View File

@@ -0,0 +1,251 @@
import Foundation
import HostRegistry
import Observation
import OSLog
import WireProtocol
/// T-iOS-22 · Deep-link routing.
///
/// Two external entry points share ONE validation surface:
/// - `webterminal://open?host=<uuid>&join=<uuid>` (scheme registered in
/// project.yml `CFBundleURLTypes`; web QR `?join=`
/// T-iOS-35 ), and
/// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses
/// `route(from:)` same UUID rules, no second parser).
///
/// (plan T-iOS-22): a deep link is EXTERNAL input. Every field is
/// whitelist-validated (scheme / action / query keys / UUID v4 via the frozen
/// `Validation.isValidSessionId` never re-regexed); ANY invalid part
/// `.ignore` + counter (never crash, never partially apply). The host id
/// resolves ONLY through a `HostStore` lookup (`DeepLinkHandler`) no URL or
/// request is ever built from link contents.
enum DeepLinkRouter {
/// Parse outcome. `.openSession` carries RAW validated ids host
/// EXISTENCE is resolved later against the store (`DeepLinkHandler`).
enum Route: Equatable, Sendable {
/// `webterminal://open` with both ids syntactically valid.
case openSession(hostId: UUID, sessionId: UUID)
/// Push payload with a valid `sessionId` (no host id in the payload
/// T-iOS-21's notification handler owns the resolution strategy).
case gateSession(sessionId: UUID)
/// Anything else. Callers count + log; they never partially apply.
case ignore
}
/// Whitelisted URL shape (`webterminal://open`). Scheme and URL host are
/// case-insensitive per RFC 3986 compared lowercased.
private enum LinkShape {
static let scheme = "webterminal"
static let action = "open"
static let emptyPaths: Set<String> = ["", "/"]
}
/// Whitelisted query keys (exact match; unknown keys are ignored).
private enum QueryKey {
static let host = "host"
static let join = "join"
}
/// Root key of the APNs payload (server contract, T-iOS-20 final shape).
private enum PushKey {
static let sessionId = "sessionId"
}
// MARK: - URL entry (onOpenURL)
static func route(url: URL) -> Route {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.scheme?.lowercased() == LinkShape.scheme,
components.host?.lowercased() == LinkShape.action,
LinkShape.emptyPaths.contains(components.path),
let hostId = uniqueValidatedId(in: components, key: QueryKey.host),
let sessionId = uniqueValidatedId(in: components, key: QueryKey.join)
else { return .ignore }
return .openSession(hostId: hostId, sessionId: sessionId)
}
// MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21)
static func route(from payload: [AnyHashable: Any]) -> Route {
guard let raw = payload[PushKey.sessionId] as? String,
let sessionId = validatedId(raw)
else { return .ignore }
return .gateSession(sessionId: sessionId)
}
// MARK: - Field validation
/// Exactly ONE occurrence of `key`, and its value is a v4 UUID.
/// Duplicates are ambiguous input nil (never partially apply).
private static func uniqueValidatedId(in components: URLComponents, key: String) -> UUID? {
let matches = (components.queryItems ?? []).filter { $0.name == key }
guard matches.count == 1, let raw = matches.first?.value else { return nil }
return validatedId(raw)
}
/// Frozen-contract v4 check (`Validation.isValidSessionId`, plan §3.1)
/// FIRST `UUID(uuidString:)` alone would admit non-v4 ids the server's
/// SESSION_ID_RE rejects.
private static func validatedId(_ raw: String) -> UUID? {
guard Validation.isValidSessionId(raw) else { return nil }
return UUID(uuidString: raw)
}
}
/// User-facing deep-link copy (plan §4: ).
enum DeepLinkCopy {
static let hintTitle = "无法打开链接"
static let unknownHostHint = "链接指向的主机尚未配对,请先扫码完成配对。"
static let hostLoadFailed = "读取已配对主机失败,无法打开链接,请重进 App 后再试。"
static let hintConfirm = ""
}
/// Applies parsed routes to the app: host-store lookup (the ONLY host-id
/// resolution point), the cold-launch stash, the unknown-host pairing hint,
/// and the invalid-link counter. Pure routing stays in `DeepLinkRouter`; this
/// class owns the stateful edges so `AppCoordinator`'s diff stays minimal.
@MainActor
@Observable
final class DeepLinkHandler {
/// Coordinator-provided effects (closures the handler never reaches
/// into navigation state itself).
struct Actions {
/// Open `sessionId` on a RESOLVED, store-known host.
let openSession: @MainActor (HostRegistry.Host, UUID) -> Void
/// Unknown host id surface the pairing flow.
let showPairing: @MainActor () -> Void
}
/// Alert copy for RootView (unknown host / store failure). nil = no alert.
private(set) var hintMessage: String?
/// Invalid deep links dropped so far (plan: `.ignore` + log counter).
private(set) var ignoredCount = 0
@ObservationIgnored private let loadHosts: @Sendable () async throws -> [HostRegistry.Host]
@ObservationIgnored private let actions: Actions
/// Cold-launch gate: `handle(url:)` before `markReady()` stashes instead
/// of applying (the host list / root route may not exist yet).
@ObservationIgnored private var isReady = false
/// Single-slot stash a newer link before readiness replaces the older
/// one (two half-applied navigations would be worse than dropping one).
@ObservationIgnored private var pendingRoute: DeepLinkRouter.Route?
@ObservationIgnored private let logger = Logger(
subsystem: DeepLinkLog.subsystem, category: DeepLinkLog.category
)
init(
loadHosts: @escaping @Sendable () async throws -> [HostRegistry.Host],
actions: Actions
) {
self.loadHosts = loadHosts
self.actions = actions
}
// MARK: - Entry points
/// `.onOpenURL` lands here (via `AppCoordinator.handleDeepLink`). Invalid
/// links are counted and dropped they are NEVER stashed.
func handle(url: URL) async {
let route = DeepLinkRouter.route(url: url)
guard route != .ignore else {
recordIgnored()
return
}
guard isReady else {
pendingRoute = route
return
}
await apply(route)
}
/// Cold-start bootstrap finished (root route decided) flush the stash.
/// Idempotent: the slot is cleared before applying, so a second call
/// never replays.
func markReady() async {
isReady = true
guard let route = pendingRoute else { return }
pendingRoute = nil
await apply(route)
}
func clearHint() {
hintMessage = nil
}
// MARK: - Apply (host id resolves ONLY through the store)
private func apply(_ route: DeepLinkRouter.Route) async {
// `.gateSession` never reaches here in P1: it only exists for the
// push path, whose handling (host resolution incl.) is T-iOS-21.
guard case let .openSession(hostId, sessionId) = route else { return }
do {
let hosts = try await loadHosts()
guard let host = hosts.first(where: { $0.id == hostId }) else {
hintMessage = DeepLinkCopy.unknownHostHint
actions.showPairing()
return
}
actions.openSession(host, sessionId)
} catch {
// Explicit failure copy a broken store must never look like a
// silently dead link (plan §4 error-handling rule).
hintMessage = DeepLinkCopy.hostLoadFailed
logger.error("deep link host-store read failed: \(error)")
}
}
private func recordIgnored() {
ignoredCount += 1
// URL contents are untrusted external input log the counter only,
// never echo the link (log-injection / privacy hygiene).
logger.notice("ignored invalid deep link (total: \(self.ignoredCount))")
}
}
private enum DeepLinkLog {
static let subsystem = "com.yaojia.webterm"
static let category = "deep-link"
}
// MARK: - AppCoordinator wiring (kept here so the coordinator diff stays tiny)
extension AppCoordinator {
/// Factory for the coordinator's lazy `deepLink` property.
func makeDeepLinkHandler() -> DeepLinkHandler {
DeepLinkHandler(
loadHosts: { [environment] in try await environment.hostStore.loadAll() },
actions: DeepLinkHandler.Actions(
openSession: { [weak self] host, sessionId in
self?.openDeepLinkedSession(host: host, sessionId: sessionId)
},
showPairing: { [weak self] in
self?.showPairingForDeepLink()
}
)
)
}
/// RootView's `.onOpenURL` entry (sync SwiftUI context async apply).
/// Fires for BOTH warm links and cold launches on cold start the
/// handler stashes until `bootstrap()` calls `markReady()`.
func handleDeepLink(url: URL) {
Task { await deepLink.handle(url: url) }
}
/// Deep links may target a session while another terminal is open:
/// detach the current one first (`open` is a no-op otherwise its
/// one-foreground-session guard would swallow the link).
private func openDeepLinkedSession(host: HostRegistry.Host, sessionId: UUID) {
if terminalController != nil {
closeTerminal()
}
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
}
/// Unknown host id pairing. On the first-run pairing route the pairing
/// screen is already frontmost only the list route needs the sheet.
private func showPairingForDeepLink() {
guard route == .sessions else { return }
presentAddHost()
}
}

View File

@@ -0,0 +1,234 @@
import Foundation
import WireProtocol
// T-iOS-27 · App-layer fetcher for `GET /projects/diff?path=&staged=` the
// read-only structured git diff (server route src/server.ts:601-618, parser
// src/http/diff.ts). iOS mirrors the web split: parsing lives ONLY server-side;
// this file fetches + tolerantly decodes, the screen renders verbatim.
//
// APIClient APIClient T-iOS-38 ownerPLAN §6 Owns
// fetcher `HTTPTransport`
// APIClient ** APIClient T-iOS-38 owner
// follow-up**//
//
// Origin plan §3.4/§5.1iff-G`/projects/diff` RO
// requireAllowedOriginsrc/server.ts:601 "no Origin guard; same
// threat model as /projects"** Origin**
// G
//
// plan §4 web
// normalizeDiffResultpublic/diff.ts:38-102
// file/hunk/line kind .contextstatus
// .modified crash
// MARK: - src/types.ts:445-479App T-iOS-38
/// One diff line's semantic kind. Unknown wire values degrade to `.context`
/// (the web renderer's `?? 'df-context'` fallback, public/diff.ts:192).
enum DiffLineKind: String, Sendable, Equatable {
case added, removed, context, hunk, meta
}
struct DiffLine: Sendable, Equatable {
let kind: DiffLineKind
/// Verbatim server bytes (marker already stripped server-side). UNTRUSTED
/// display input render via `Text(verbatim:)` only, never Markdown/links.
let text: String
}
struct DiffHunk: Sendable, Equatable {
let header: String
let lines: [DiffLine]
}
/// File-level change status. Unknown wire values degrade to `.modified`
/// (display-only field; the web keeps the raw string for a CSS class).
enum DiffFileStatus: String, Sendable, Equatable {
case modified, added, deleted, renamed, binary, untracked
}
struct DiffFile: Sendable, Equatable {
let oldPath: String
let newPath: String
let status: DiffFileStatus
let added: Int
let removed: Int
let binary: Bool
let hunks: [DiffHunk]
}
struct DiffResult: Sendable, Equatable {
let files: [DiffFile]
let staged: Bool
let truncated: Bool
}
// MARK: - Decodable extension
extension DiffLine: Decodable {
private enum CodingKeys: String, CodingKey { case kind, text }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// kind/text normalizeLine
let rawKind = try container.decode(String.self, forKey: .kind)
text = try container.decode(String.self, forKey: .text)
kind = DiffLineKind(rawValue: rawKind) ?? .context
}
}
extension DiffHunk: Decodable {
private enum CodingKeys: String, CodingKey { case header, lines }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
header = try container.decode(String.self, forKey: .header)
let boxes = try container.decode([DiffLossyBox<DiffLine>].self, forKey: .lines)
lines = boxes.compactMap(\.value)
}
}
extension DiffFile: Decodable {
private enum CodingKeys: String, CodingKey {
case oldPath, newPath, status, added, removed, binary, hunks
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
oldPath = try container.decode(String.self, forKey: .oldPath)
newPath = try container.decode(String.self, forKey: .newPath)
added = try container.decode(Int.self, forKey: .added)
removed = try container.decode(Int.self, forKey: .removed)
binary = try container.decode(Bool.self, forKey: .binary)
let rawStatus = try container.decode(String.self, forKey: .status)
status = DiffFileStatus(rawValue: rawStatus) ?? .modified
let boxes = try container.decode([DiffLossyBox<DiffHunk>].self, forKey: .hunks)
hunks = boxes.compactMap(\.value)
}
}
extension DiffResult: Decodable {
private enum CodingKeys: String, CodingKey { case files, staged, truncated }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
staged = try container.decode(Bool.self, forKey: .staged)
truncated = try container.decode(Bool.self, forKey: .truncated)
let boxes = try container.decode([DiffLossyBox<DiffFile>].self, forKey: .files)
files = boxes.compactMap(\.value)
}
}
/// Per-element tolerance shim: a malformed element becomes nil instead of
/// failing the whole array. Local twin of APIClient's internal `LossyBox`
/// (not importable across the package boundary) folds together with the
/// fetcher in the T-iOS-38 follow-up.
private struct DiffLossyBox<Wrapped: Decodable>: Decodable {
let value: Wrapped?
init(from decoder: any Decoder) {
value = try? Wrapped(from: decoder)
}
}
// MARK: - route src/server.ts:602-618
enum DiffFetchError: Error, Equatable {
/// path / / URL
case invalidRequest
/// 400`path query parameter is required`
case pathInvalid
/// 404SEC-H7 + + .git
case projectNotFound
/// 200 DiffResult
case invalidResponse
/// 500 `failed to read diff`
case unexpectedStatus(Int)
}
// MARK: - DiffFetcher
/// Fetch a repo's structured diff over the injected transport. Immutable
/// value; one instance per (endpoint) the screen's VM closes over it.
struct DiffFetcher: Sendable {
let endpoint: HostEndpoint
private let http: any HTTPTransport
/// Route constants`stagedFlag`
/// `req.query['staged'] === '1'`src/server.ts:613 `1`/`0`
/// web fetchDiff `true`/`false` `'1'`
/// staged web Owns
private enum Route {
static let path = "/projects/diff"
static let pathKey = "path"
static let stagedKey = "staged"
static let stagedOn = "1"
static let stagedOff = "0"
static let methodGet = "GET"
}
private enum Status {
static let ok = 200
static let badRequest = 400
static let notFound = 404
}
/// Strict RFC 3986 unreserved set APIClient `Endpoints.
/// unreservedCharacters` `+` Express qs
/// `&`/`=` internal importT-iOS-38
private static let unreservedCharacters = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
init(endpoint: HostEndpoint, http: any HTTPTransport) {
self.endpoint = endpoint
self.http = http
}
/// `GET /projects/diff?path=&staged=` `DiffResult`
/// path 400 I/O
func fetch(path: String, staged: Bool) async throws -> DiffResult {
guard !path.isEmpty, let request = buildRequest(path: path, staged: staged) else {
throw DiffFetchError.invalidRequest
}
let (data, response) = try await http.send(request)
switch response.statusCode {
case Status.ok:
guard let result = try? JSONDecoder().decode(DiffResult.self, from: data) else {
throw DiffFetchError.invalidResponse
}
return result
case Status.badRequest:
throw DiffFetchError.pathInvalid
case Status.notFound:
throw DiffFetchError.projectNotFound
default:
throw DiffFetchError.unexpectedStatus(response.statusCode)
}
}
/// Build the RO GET APIClient `APIRoute.urlRequest(for:)`
/// baseURL scheme/host/portpath/query fragment
/// ** stamp Origin**iff-G
private func buildRequest(path: String, staged: Bool) -> URLRequest? {
guard let encodedPath = path.addingPercentEncoding(
withAllowedCharacters: Self.unreservedCharacters
) else { return nil }
guard var components = URLComponents(
url: endpoint.baseURL, resolvingAgainstBaseURL: true
) else { return nil }
components.path = Route.path
components.query = nil
components.fragment = nil
components.user = nil
components.password = nil
let stagedValue = staged ? Route.stagedOn : Route.stagedOff
components.percentEncodedQuery =
"\(Route.pathKey)=\(encodedPath)&\(Route.stagedKey)=\(stagedValue)"
guard let url = components.url else { return nil }
var request = URLRequest(url: url)
request.httpMethod = Route.methodGet
return request
}
}

View File

@@ -0,0 +1,310 @@
import APIClient
import Foundation
import HostRegistry
import OSLog
import UIKit
import UserNotifications
import WireProtocol
/// T-iOS-21 · Allow/Deny** App**
/// `UNUserNotificationCenterDelegate.didReceive` notification
/// extension targetService Extension action tap
///
///
/// - push payload ****sessionId `DeepLinkRouter.route(from:)`
/// v4 capability token v4
/// `randomUUID()` src/server.ts:445****
/// `.invalidPayload`
/// - token `hookDecision`
/// /
/// - POST begin/endBackgroundTask didReceive async
/// = completionHandler POST settle
/// - 403token /
// MARK: - Seams
/// Seam over `UIApplication.beginBackgroundTask`/`endBackgroundTask`.
@MainActor
protocol BackgroundTaskRunning: AnyObject {
/// Returns an opaque token for `end(_:)` UIBackgroundTaskIdentifier
func begin(name: String) -> Int
func end(_ token: Int)
}
@MainActor
final class UIApplicationBackgroundTaskRunner: BackgroundTaskRunning {
private var active: [Int: UIBackgroundTaskIdentifier] = [:]
func begin(name: String) -> Int {
var identifier = UIBackgroundTaskIdentifier.invalid
identifier = UIApplication.shared.beginBackgroundTask(withName: name) {
// 线UIKit
MainActor.assumeIsolated { self.expire(identifier) }
}
active[identifier.rawValue] = identifier
return identifier.rawValue
}
func end(_ token: Int) {
guard let identifier = active.removeValue(forKey: token) else { return }
UIApplication.shared.endBackgroundTask(identifier)
}
private func expire(_ identifier: UIBackgroundTaskIdentifier) {
guard identifier != .invalid, active.removeValue(forKey: identifier.rawValue) != nil else {
return
}
UIApplication.shared.endBackgroundTask(identifier)
}
}
/// Seam for the local-notification fallback
@MainActor
protocol LocalNoticePosting: AnyObject {
func post(title: String, body: String) async
}
extension UNNotificationCenterAdapter: LocalNoticePosting {
func post(title: String, body: String) async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
let request = UNNotificationRequest(
identifier: UUID().uuidString, content: content, trigger: nil
)
do {
try await add(request)
} catch {
//
Logger(subsystem: PushLog.subsystem, category: PushLog.actionHandler)
.error("fallback local notice failed: \(error)")
}
}
}
/// payload token
enum PushDecisionCopy {
static let expiredTitle = "审批已失效"
static let expiredBody = "该次批准/拒绝已过期或已被处理,请打开 App 在会话中查看。"
static let failedTitle = "审批发送失败"
static let failedBody = "无法联系主机,请打开 App 在会话中处理。"
}
/// `parse` SendabledidReceive nonisolated
/// MainActorUN isolation
enum ParsedNotificationAction: Sendable, Equatable {
/// Allow/Deny + `{sessionId, token}`
case decision(HookDecision, sessionId: UUID, token: String)
/// done + sessionId
case openSession(sessionId: UUID)
/// payload ""
case invalidPayload
/// dismiss / id no-op
case dismissed
}
// MARK: - Handler
@MainActor
final class NotificationActionHandler: NSObject {
/// Coordinator
struct Actions {
let openSession: @MainActor (HostRegistry.Host, UUID) async -> Void
}
private enum TaskName {
static let decision = "webterm.hook-decision"
}
private let hostStore: any HostStore
private let http: any HTTPTransport
private let backgroundTasks: any BackgroundTaskRunning
private let notices: any LocalNoticePosting
private let actions: Actions
private let logger = Logger(subsystem: PushLog.subsystem, category: PushLog.actionHandler)
private(set) var invalidPayloadCount = 0
init(
hostStore: any HostStore,
http: any HTTPTransport,
backgroundTasks: any BackgroundTaskRunning,
notices: any LocalNoticePosting,
actions: Actions
) {
self.hostStore = hostStore
self.http = http
self.backgroundTasks = backgroundTasks
self.notices = notices
self.actions = actions
}
// MARK: - Parsepayload
nonisolated static func parse(
actionIdentifier: String, userInfo: [AnyHashable: Any]
) -> ParsedNotificationAction {
switch actionIdentifier {
case GateNotificationCategory.allowActionId:
return decisionAction(.allow, userInfo: userInfo)
case GateNotificationCategory.denyActionId:
return decisionAction(.deny, userInfo: userInfo)
case UNNotificationDefaultActionIdentifier:
guard case let .gateSession(sessionId) = DeepLinkRouter.route(from: userInfo) else {
return .invalidPayload
}
return .openSession(sessionId: sessionId)
default:
return .dismissed
}
}
private nonisolated static func decisionAction(
_ decision: HookDecision, userInfo: [AnyHashable: Any]
) -> ParsedNotificationAction {
guard case let .gateSession(sessionId) = DeepLinkRouter.route(from: userInfo),
let token = userInfo[PayloadKey.token] as? String,
Validation.isValidSessionId(token) // token = randomUUID() v4
else { return .invalidPayload }
return .decision(decision, sessionId: sessionId, token: token)
}
private enum PayloadKey {
/// capability tokenT-iOS-20 payload 稿 gate
static let token = "token"
}
// MARK: - Handle
func handle(_ action: ParsedNotificationAction) async {
switch action {
case .dismissed:
return
case .invalidPayload:
invalidPayloadCount += 1
// payload/
logger.notice("dropped invalid push payload (total: \(self.invalidPayloadCount))")
case let .openSession(sessionId):
await routeToSession(sessionId)
case let .decision(decision, sessionId, token):
await postDecision(decision, sessionId: sessionId, token: token)
}
}
// MARK: - DecisionAllow/Deny POST /hook/decision
private func postDecision(_ decision: HookDecision, sessionId: UUID, token: String) async {
let taskToken = backgroundTasks.begin(name: TaskName.decision)
defer { backgroundTasks.end(taskToken) }
do {
guard let host = try await resolveHost(sessionId: sessionId) else {
logger.error("hook decision: no paired host lists session — cannot deliver")
await notices.post(
title: PushDecisionCopy.failedTitle, body: PushDecisionCopy.failedBody
)
return
}
try await APIClient(endpoint: host.endpoint, http: http)
.hookDecision(sessionId: sessionId, decision: decision, token: token)
} catch APIClientError.decisionRejected {
// 403token //SEC-C1/M1 App
await notices.post(
title: PushDecisionCopy.expiredTitle, body: PushDecisionCopy.expiredBody
)
} catch {
logger.error("hook decision POST failed: \(error)")
await notices.post(
title: PushDecisionCopy.failedTitle, body: PushDecisionCopy.failedBody
)
}
}
// MARK: - Default tap DeepLinkRouter sessionId
private func routeToSession(_ sessionId: UUID) async {
do {
guard let host = try await resolveHost(sessionId: sessionId) else {
// App
logger.notice("push tap: session not found on any paired host")
return
}
await actions.openSession(host, sessionId)
} catch {
logger.error("push tap: host store read failed: \(error)")
}
}
// MARK: - Host resolutionpayload host handler
/// 线 POST
/// RO `/live-sessions` Origin
private func resolveHost(sessionId: UUID) async throws -> HostRegistry.Host? {
let hosts = try await hostStore.loadAll()
if hosts.count <= 1 { return hosts.first }
for host in hosts {
let sessions = (try? await APIClient(endpoint: host.endpoint, http: http)
.liveSessions()) ?? []
if sessions.contains(where: { $0.id == sessionId }) { return host }
}
return nil
}
}
// MARK: - UNUserNotificationCenterDelegateUNNotificationResponse
extension NotificationActionHandler: UNUserNotificationCenterDelegate {
/// async = completionHandler return
/// "completionHandler only after the POST settles"
/// UN nonisolated MainActor Sendable
/// `ParsedNotificationAction`
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let parsed = Self.parse(
actionIdentifier: response.actionIdentifier,
userInfo: response.notification.request.content.userInfo
)
await handle(parsed)
}
}
// MARK: - AppCoordinator wiring DeepLinkRouter.swift makeDeepLinkHandler
extension AppCoordinator {
/// PushAppDelegate.didFinishLaunching
/// hostStore/http AppEnvironment ad-hoc URLSession/keychain
func makePushWiring() -> (registrar: PushRegistrar, handler: NotificationActionHandler) {
let center = UNNotificationCenterAdapter()
let registrar = PushRegistrar(
hostStore: environment.hostStore,
http: environment.http,
center: center,
remote: UIApplicationRemoteRegistrar()
)
let handler = NotificationActionHandler(
hostStore: environment.hostStore,
http: environment.http,
backgroundTasks: UIApplicationBackgroundTaskRunner(),
notices: center,
actions: NotificationActionHandler.Actions(
openSession: { [weak self] host, sessionId in
await self?.openPushedSession(host: host, sessionId: sessionId)
}
)
)
return (registrar, handler)
}
/// `bootstrap()``route == .loading`
/// deep-link
/// closeopenopenDeepLinkedSession T-iOS-22 private
///
func openPushedSession(host: HostRegistry.Host, sessionId: UUID) async {
await bootstrap()
if terminalController != nil {
closeTerminal()
}
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
}
}

View File

@@ -0,0 +1,331 @@
import APIClient
import Foundation
import HostRegistry
import OSLog
import UIKit
import UserNotifications
import WireProtocol
/// T-iOS-21 · APNs registerForRemoteNotifications device
/// token `POST /push/apns-token`builder T-iOS-38
///
/// "document the choice"** [.alert, .sound]**
/// provisional provisional
/// Allow/Deny alert
/// gate `sound: 'default'`src/push/apns.ts:342
///
/// crashdevice token
/// `registerForRemoteNotifications` delegate launch/scene
/// `activate()``registeredHostIds` /
/// upsert 5/min/IP
// MARK: - SeamsUNUserNotificationCenter / UIApplication
/// Seam over the `UNUserNotificationCenter` surface this feature touches.
@MainActor
protocol NotificationCenterClient: AnyObject {
func authorizationStatus() async -> UNAuthorizationStatus
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
func setNotificationCategories(_ categories: Set<UNNotificationCategory>)
func add(_ request: UNNotificationRequest) async throws
}
@MainActor
final class UNNotificationCenterAdapter: NotificationCenterClient {
private let center = UNUserNotificationCenter.current()
func authorizationStatus() async -> UNAuthorizationStatus {
// The async `notificationSettings()` returns non-Sendable
// UNNotificationSettings across an isolation hop (Swift 6 error);
// extract only the Sendable status inside the completion instead.
// @Sendable literal: the closure must NOT inherit this class's
// MainActor isolation UNUserNotificationCenter invokes it on a
// background queue, and an isolated closure traps the Swift 6 runtime
// executor check (dispatch_assert_queue_fail; W7 verify boot-crash).
await withCheckedContinuation { continuation in
center.getNotificationSettings { @Sendable settings in
continuation.resume(returning: settings.authorizationStatus)
}
}
}
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
// Completion-handler bridge for the same Swift 6 reason as above (the
// SDK's async variant sends the non-Sendable center across isolation).
try await withCheckedThrowingContinuation { continuation in
center.requestAuthorization(options: options) { @Sendable granted, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: granted)
}
}
}
}
func setNotificationCategories(_ categories: Set<UNNotificationCategory>) {
center.setNotificationCategories(categories)
}
func add(_ request: UNNotificationRequest) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
center.add(request) { @Sendable error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}
/// Seam over `UIApplication.registerForRemoteNotifications`.
@MainActor
protocol RemoteNotificationRegistering: AnyObject {
func registerForRemoteNotifications()
}
@MainActor
final class UIApplicationRemoteRegistrar: RemoteNotificationRegistering {
func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
}
// MARK: - WEBTERM_GATE category
/// Allow/Deny category **plan T-iOS-21**
/// - Allow `.authenticationRequired` =
/// Face ID/
/// - Deny fail-safe
/// - **** `.foreground` POST UI
enum GateNotificationCategory {
/// `GATE_CATEGORY` src/push/apns.ts:52T-iOS-20 稿
static let identifier = "WEBTERM_GATE"
static let allowActionId = "WEBTERM_ALLOW"
static let denyActionId = "WEBTERM_DENY"
static let allowTitle = "允许"
static let denyTitle = "拒绝"
static func category() -> UNNotificationCategory {
let allow = UNNotificationAction(
identifier: allowActionId, title: allowTitle,
options: [.authenticationRequired]
)
let deny = UNNotificationAction(
identifier: denyActionId, title: denyTitle,
options: [.destructive] // .foreground/.authenticationRequired
)
return UNNotificationCategory(
identifier: identifier, actions: [allow, deny],
intentIdentifiers: [], options: []
)
}
}
// MARK: - PushRegistrar
@MainActor
final class PushRegistrar {
/// provisional doc
static let authorizationOptions: UNAuthorizationOptions = [.alert, .sound]
private let hostStore: any HostStore
private let http: any HTTPTransport
private let center: any NotificationCenterClient
private let remote: any RemoteNotificationRegistering
private let logger = Logger(subsystem: PushLog.subsystem, category: PushLog.registrar)
/// device token hexiOS
///
private(set) var currentTokenHex: String?
/// `currentTokenHex`
private var registeredHostIds: Set<UUID> = []
init(
hostStore: any HostStore,
http: any HTTPTransport,
center: any NotificationCenterClient,
remote: any RemoteNotificationRegistering
) {
self.hostStore = hostStore
self.http = http
self.center = center
self.remote = remote
}
/// launch / scene active category
/// **** = ;
/// /
func activate() async {
center.setNotificationCategories([GateNotificationCategory.category()])
let hosts: [HostRegistry.Host]
do {
hosts = try await hostStore.loadAll()
} catch {
logger.error("push activate: host store read failed: \(error)")
return
}
guard !hosts.isEmpty else {
logger.debug("push activate: no paired hosts — skip authorization")
return
}
guard await ensureAuthorization() else { return }
remote.registerForRemoteNotifications()
}
/// `didRegisterForRemoteNotificationsWithDeviceToken`
func handleDeviceToken(_ deviceToken: Data) async {
let hex = Self.hexToken(deviceToken)
if hex != currentTokenHex {
currentTokenHex = hex
registeredHostIds = []
}
await registerPendingHosts()
}
/// `didFailToRegisterForRemoteNotificationsWithError`
/// / aps-environment entitlement crash
func handleRegistrationFailure(_ error: any Error) {
logger.error("remote notification registration failed: \(error)")
}
/// device token**additive hook** App
/// UI `HostStore.remove(id:)`
/// token
/// APNs 410
func handleHostRemoved(_ host: HostRegistry.Host) async {
registeredHostIds.remove(host.id)
guard let token = currentTokenHex else { return }
do {
try await APIClient(endpoint: host.endpoint, http: http)
.unregisterApnsToken(token)
} catch {
logger.error("APNs token unregister failed for host \(host.id): \(error)")
}
}
// MARK: - Internals
/// notDetermined denied authorized/
/// provisional/ephemeral
private func ensureAuthorization() async -> Bool {
switch await center.authorizationStatus() {
case .notDetermined:
do {
guard try await center.requestAuthorization(
options: Self.authorizationOptions
) else {
logger.notice("push authorization denied by user")
return false
}
return true
} catch {
logger.error("push authorization request failed: \(error)")
return false
}
case .denied:
logger.notice("push authorization previously denied — skip registration")
return false
default:
return true
}
}
private func registerPendingHosts() async {
guard let token = currentTokenHex else { return }
let hosts: [HostRegistry.Host]
do {
hosts = try await hostStore.loadAll()
} catch {
logger.error("push token registration: host store read failed: \(error)")
return
}
for host in hosts where !registeredHostIds.contains(host.id) {
do {
try await APIClient(endpoint: host.endpoint, http: http)
.registerApnsToken(token)
registeredHostIds.insert(host.id)
} catch {
// crash token
///
logger.error("APNs token registration failed for host \(host.id): \(error)")
}
}
}
/// APNs device token hex wire 64160 hexT-iOS-38
static func hexToken(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - PushAppDelegateapp
/// `@UIApplicationDelegateAdaptor` remote-notification
/// UIApplicationDelegate SwiftUI App
///
/// 线wiring SwiftUI
/// `WebTermApp.init` coordinator `bootstrap`
/// `didFinishLaunching` registrar/handler handler
/// UNUserNotificationCenter delegateApple delegate
/// +
/// scenePhase active `WebTermApp`
/// Allow/Deny 30
@MainActor
final class PushAppDelegate: NSObject, UIApplicationDelegate {
/// WebTermApp.init didFinishLaunching
static var bootstrap: AppCoordinator?
/// 宿XCTest XCUITest
/// 线
static var isRunningUnderTests: Bool {
NSClassFromString("XCTestCase") != nil
|| ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] != nil
}
private(set) var registrar: PushRegistrar?
private(set) var actionHandler: NotificationActionHandler?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
guard !Self.isRunningUnderTests, let coordinator = Self.bootstrap else {
Self.bootstrap = nil
return true
}
Self.bootstrap = nil
let wiring = coordinator.makePushWiring()
registrar = wiring.registrar
actionHandler = wiring.handler
UNUserNotificationCenter.current().delegate = wiring.handler
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Task { await registrar?.handleDeviceToken(deviceToken) }
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: any Error
) {
registrar?.handleRegistrationFailure(error)
}
/// scenePhase active
func activatePush() {
Task { await registrar?.activate() }
}
}
enum PushLog {
static let subsystem = "com.yaojia.webterm"
static let registrar = "push-registrar"
static let actionHandler = "push-action"
}

View File

@@ -0,0 +1,269 @@
import SwiftUI
import WireProtocol
/// T-iOS-27 · Read-only diff viewer web public/diff.ts render-only
/// src/http/diff.ts
///
/// T-iOS-26 ProjectDetail `(endpoint, path)` push/present
/// TerminalScreen per-session cwd
/// TerminalScreenT-iOS-11 Owns toolbar hook线
/// T-iOS-26/29
///
/// SEC-H4 diff ****
/// `Text(verbatim:)` LocalizedStringKey/Markdown/
/// List diff 2MB
/// Text
struct DiffScreen: View {
@State private var viewModel: DiffViewModel
private enum Metrics {
static let pickerHorizontalPadding: CGFloat = 16
static let pickerVerticalPadding: CGFloat = 8
static let lineVerticalInset: CGFloat = 1
static let lineHorizontalInset: CGFloat = 12
static let fileHeaderTopInset: CGFloat = 14
static let statTagSpacing: CGFloat = 8
static let lineBackgroundOpacity = 0.12
}
/// T-iOS-26 `(endpoint, path)` +
init(endpoint: HostEndpoint, path: String, http: any HTTPTransport) {
_viewModel = State(initialValue: .forProject(
endpoint: endpoint, path: path, http: http
))
}
/// / VM
init(viewModel: DiffViewModel) {
_viewModel = State(initialValue: viewModel)
}
var body: some View {
VStack(spacing: 0) {
scopePicker
.padding(.horizontal, Metrics.pickerHorizontalPadding)
.padding(.vertical, Metrics.pickerVerticalPadding)
content
}
.navigationTitle(DiffCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() } // fresh screen one initial fetch
}
// MARK: - staged/unstaged re-fetch VM
private var scopePicker: some View {
Picker(DiffCopy.scopePickerLabel, selection: Binding(
get: { viewModel.staged },
set: { newValue in Task { await viewModel.setStaged(newValue) } }
)) {
Text(DiffCopy.working).tag(false)
Text(DiffCopy.staged).tag(true)
}
.pickerStyle(.segmented)
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .empty(let truncated):
emptyState(truncated: truncated)
case .failed(let failure):
failedState(failure)
case .loaded(let presentation):
diffList(presentation)
}
}
/// files banner
///
private func emptyState(truncated: Bool) -> some View {
VStack(spacing: 0) {
if truncated { truncatedBanner }
ContentUnavailableView(
DiffCopy.emptyTitle,
systemImage: "checkmark.circle",
description: Text(DiffCopy.emptyDetail)
)
}
}
/// path 400/404 Steps
private func failedState(_ failure: DiffViewModel.Failure) -> some View {
let copy = Self.failureCopy(failure)
return ContentUnavailableView {
Label(copy.title, systemImage: "exclamationmark.triangle")
} description: {
Text(copy.detail)
} actions: {
Button(DiffCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
}
}
static func failureCopy(_ failure: DiffViewModel.Failure) -> (title: String, detail: String) {
switch failure {
case .pathInvalid:
return (DiffCopy.failedPathInvalid, DiffCopy.failedPathInvalidDetail)
case .notFound:
return (DiffCopy.failedNotFound, DiffCopy.failedNotFoundDetail)
case .unavailable:
return (DiffCopy.failedUnavailable, DiffCopy.failedUnavailableDetail)
}
}
// MARK: - VM
private func diffList(_ presentation: DiffPresentation) -> some View {
List {
if presentation.truncated {
truncatedBanner
.listRowSeparator(.hidden)
}
ForEach(presentation.rows) { row in
rowView(row)
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0) // diff
}
private var truncatedBanner: some View {
Label(DiffCopy.truncatedBanner, systemImage: "scissors")
.font(.footnote)
.foregroundStyle(.orange)
}
@ViewBuilder private func rowView(_ row: DiffRow) -> some View {
switch row.kind {
case .fileHeader(let header):
fileHeaderRow(header)
case .binaryNotice:
Text(DiffCopy.binaryFile)
.font(.caption.italic())
.foregroundStyle(.secondary)
.listRowSeparator(.hidden)
case .hunkHeader(let header):
diffTextRow(
header, color: .blue,
background: Color.blue.opacity(Metrics.lineBackgroundOpacity)
)
case .line(let kind, let text):
diffTextRow(
text, color: Self.lineColor(kind), background: Self.lineBackground(kind)
)
}
}
private func fileHeaderRow(_ header: DiffFileHeader) -> some View {
HStack(spacing: Metrics.statTagSpacing) {
// verbatim +
Text(verbatim: header.pathLabel)
.font(.footnote.weight(.semibold).monospaced())
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
Text(verbatim: "+\(header.added)")
.font(.caption.monospacedDigit())
.foregroundStyle(.green)
Text(verbatim: "-\(header.removed)")
.font(.caption.monospacedDigit())
.foregroundStyle(.red)
Text(DiffStatusStyle.label(for: header.status))
.font(.caption2)
.foregroundStyle(DiffStatusStyle.color(for: header.status))
}
.padding(.top, Metrics.fileHeaderTopInset)
.accessibilityElement(children: .combine)
}
/// One monospaced diff line. UNTRUSTED server bytes: `Text(verbatim:)`,
/// single-line tail truncation (read-only skim view; no wrapping blob).
private func diffTextRow(_ text: String, color: Color, background: Color) -> some View {
Text(verbatim: text)
.font(.caption.monospaced())
.foregroundStyle(color)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(background)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(
top: Metrics.lineVerticalInset,
leading: Metrics.lineHorizontalInset,
bottom: Metrics.lineVerticalInset,
trailing: Metrics.lineHorizontalInset
))
}
// MARK: - kind kind .context
static func lineColor(_ kind: DiffLineKind) -> Color {
switch kind {
case .added: return .green
case .removed: return .red
case .context: return .primary
case .hunk: return .blue
case .meta: return .secondary
}
}
static func lineBackground(_ kind: DiffLineKind) -> Color {
switch kind {
case .added: return .green.opacity(Metrics.lineBackgroundOpacity)
case .removed: return .red.opacity(Metrics.lineBackgroundOpacity)
case .hunk: return .blue.opacity(Metrics.lineBackgroundOpacity)
case .context, .meta: return .clear
}
}
}
// MARK: - status /
enum DiffStatusStyle {
static func label(for status: DiffFileStatus) -> String {
switch status {
case .modified: return "修改"
case .added: return "新增"
case .deleted: return "删除"
case .renamed: return "重命名"
case .binary: return "二进制"
case .untracked: return "未跟踪"
}
}
static func color(for status: DiffFileStatus) -> Color {
switch status {
case .added, .untracked: return .green
case .deleted: return .red
case .renamed: return .blue
case .modified, .binary: return .secondary
}
}
}
// MARK: - plan
enum DiffCopy {
static let title = "代码差异"
static let scopePickerLabel = "差异范围"
static let working = "工作区"
static let staged = "已暂存"
static let truncatedBanner = "差异过大,已截断显示(主机侧 DIFF_MAX_BYTES / DIFF_MAX_FILES 限制)。"
static let emptyTitle = "无改动"
static let emptyDetail = "当前范围下没有可显示的改动。"
static let binaryFile = "二进制文件"
static let failedPathInvalid = "路径无效"
static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400请从项目列表重新进入。"
static let failedNotFound = "项目未找到"
static let failedNotFoundDetail = "该路径不是主机上的 git 仓库或已被移除404"
static let failedUnavailable = "差异加载失败"
static let failedUnavailableDetail = "无法从主机获取 diff请检查连接后重试。"
static let retry = "重试"
}

View File

@@ -0,0 +1,286 @@
import APIClient
import SwiftUI
import WireProtocol
/// T-iOS-26 · sessions/worktrees/CLAUDE.md + diff
/// T-iOS-27 `DiffScreen(endpoint:path:http:)`+ ""
///
/// ///CLAUDE.md ****
/// `Text(verbatim:)` LocalizedStringKey/Markdown/
struct ProjectDetailScreen: View {
@State private var viewModel: ProjectDetailViewModel
private let endpoint: HostEndpoint
private let http: any HTTPTransport
private let onOpenClaude: (String) -> Void
@State private var isDiffPresented = false
private enum Metrics {
static let headerSpacing: CGFloat = 4
static let chipSpacing: CGFloat = 6
static let claudeMdLineLimit = 40
}
init(
viewModel: ProjectDetailViewModel,
endpoint: HostEndpoint,
http: any HTTPTransport,
onOpenClaude: @escaping (String) -> Void
) {
_viewModel = State(initialValue: viewModel)
self.endpoint = endpoint
self.http = http
self.onOpenClaude = onOpenClaude
}
var body: some View {
content
.navigationTitle(ProjectDetailCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() }
.navigationDestination(isPresented: $isDiffPresented) {
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
}
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let failure):
failedState(failure)
case .loaded(let detail):
detailList(detail)
}
}
/// 400/404/500 `{error}` Steps
private func failedState(_ failure: ProjectDetailViewModel.Failure) -> some View {
let copy = Self.failureCopy(failure)
return ContentUnavailableView {
Label(copy.title, systemImage: "exclamationmark.triangle")
} description: {
Text(copy.detail)
} actions: {
Button(ProjectDetailCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
}
}
static func failureCopy(
_ failure: ProjectDetailViewModel.Failure
) -> (title: String, detail: String) {
switch failure {
case .pathInvalid:
return (ProjectDetailCopy.failedPathInvalid,
ProjectDetailCopy.failedPathInvalidDetail)
case .notFound:
return (ProjectDetailCopy.failedNotFound,
ProjectDetailCopy.failedNotFoundDetail)
case .unavailable:
return (ProjectDetailCopy.failedUnavailable,
ProjectDetailCopy.failedUnavailableDetail)
}
}
// MARK: - Loaded
private func detailList(_ detail: ProjectDetail) -> some View {
List {
headerSection(detail)
actionsSection(detail)
sessionsSection(detail.sessions)
worktreesSection(detail.worktrees)
claudeMdSection(detail)
}
.listStyle(.insetGrouped)
}
private func headerSection(_ detail: ProjectDetail) -> some View {
Section {
VStack(alignment: .leading, spacing: Metrics.headerSpacing) {
Text(verbatim: detail.name)
.font(.headline)
.lineLimit(1)
Text(verbatim: detail.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: Metrics.chipSpacing) {
if let branch = detail.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(.caption)
.foregroundStyle(.secondary)
}
if detail.dirty == true {
Text(ProjectsCopy.dirtyBadge)
.font(.caption2)
.foregroundStyle(.orange)
}
}
}
}
}
private func actionsSection(_ detail: ProjectDetail) -> some View {
Section {
Button {
onOpenClaude(detail.path)
} label: {
Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
if detail.isGit {
Button {
isDiffPresented = true
} label: {
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
}
}
}
}
@ViewBuilder private func sessionsSection(_ sessions: [ProjectSessionRef]) -> some View {
Section(ProjectDetailCopy.sessionsHeader) {
if sessions.isEmpty {
Text(ProjectDetailCopy.noSessions)
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(sessions, id: \.id) { session in
sessionRow(session)
}
}
}
}
private func sessionRow(_ session: ProjectSessionRef) -> some View {
HStack(spacing: Metrics.chipSpacing) {
Text(verbatim: Self.statusGlyph(session.status))
// title cwd verbatim退 id
Text(verbatim: session.title ?? String(
session.id.uuidString.lowercased().prefix(8)
))
.lineLimit(1)
Spacer(minLength: 0)
if session.exited {
Text(ProjectDetailCopy.sessionExited)
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(ProjectDetailCopy.clientCount(session.clientCount))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
/// web claudeIconpublic/tabs.ts:74-80
static func statusGlyph(_ status: ClaudeStatus) -> String {
switch status {
case .working: return ""
case .waiting: return ""
case .idle: return ""
case .stuck: return ""
case .unknown: return ""
}
}
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
if !worktrees.isEmpty {
Section(ProjectDetailCopy.worktreesHeader) {
ForEach(worktrees, id: \.path) { worktree in
worktreeRow(worktree)
}
}
}
}
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
VStack(alignment: .leading, spacing: Metrics.headerSpacing) {
HStack(spacing: Metrics.chipSpacing) {
Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead)
.font(.callout)
.lineLimit(1)
if worktree.isMain {
badge(ProjectDetailCopy.worktreeMain)
}
if worktree.isCurrent {
badge(ProjectDetailCopy.worktreeCurrent)
}
if worktree.locked == true {
badge(ProjectDetailCopy.worktreeLocked)
}
}
Text(verbatim: worktree.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
private func badge(_ text: String) -> some View {
Text(text)
.font(.caption2)
.foregroundStyle(.blue)
}
@ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View {
if detail.hasClaudeMd {
Section(ProjectDetailCopy.claudeMdHeader) {
if let content = detail.claudeMd {
// verbatim +
Text(verbatim: content)
.font(.caption.monospaced())
.lineLimit(Metrics.claudeMdLineLimit)
} else {
Text(ProjectDetailCopy.claudeMdPresent)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
}
// MARK: - plan §4
enum ProjectDetailCopy {
static let title = "项目详情"
static let openClaude = "在此仓库开新会话"
static let viewDiff = "查看代码差异"
static let sessionsHeader = "运行中的会话"
static let noSessions = "暂无运行中的会话。"
static let sessionExited = "已退出"
static let worktreesHeader = "Worktrees"
static let worktreeMain = ""
static let worktreeCurrent = "当前"
static let worktreeLocked = "已锁定"
static let detachedHead = "(分离 HEAD"
static let claudeMdHeader = "CLAUDE.md"
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"
static let retry = "重试"
static let failedPathInvalid = "路径无效"
static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400请从项目列表重新进入。"
static let failedNotFound = "项目未找到"
static let failedNotFoundDetail = "该路径不在主机的项目根目录下或已被移除404"
static let failedUnavailable = "详情加载失败"
static let failedUnavailableDetail = "无法从主机获取项目详情,请检查连接后重试。"
static func clientCount(_ count: Int) -> String {
"\(count) 个客户端"
}
}

View File

@@ -0,0 +1,184 @@
import APIClient
import SwiftUI
/// T-iOS-26 · Projects web v0.6 projects namespace
/// + + dirty + Active now +
/// /// `ProjectsViewModel`
///
/// //**** `Text(verbatim:)`
/// LocalizedStringKey/Markdown
struct ProjectsScreen: View {
@Bindable var viewModel: ProjectsViewModel
/// "" AppCoordinator.openProject
var onOpen: (ProjectOpenRequest) -> Void = { _ in }
private enum Metrics {
static let rowSpacing: CGFloat = 2
static let chipSpacing: CGFloat = 6
}
var body: some View {
list
.navigationTitle(ProjectsCopy.title)
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchText, prompt: ProjectsCopy.searchPrompt)
.refreshable { await viewModel.refresh() }
.task { await viewModel.load() }
.onChange(of: viewModel.openRequest) { _, request in
guard let request else { return }
onOpen(request)
}
.navigationDestination(for: ProjectRoute.self) { route in
ProjectDetailScreen(
viewModel: viewModel.makeDetailViewModel(path: route.path),
endpoint: viewModel.host.endpoint,
http: viewModel.http,
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
)
}
}
// MARK: - List
private var list: some View {
List {
errorRows
if let message = viewModel.emptyStateMessage {
Text(message)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.listRowSeparator(.hidden)
}
ForEach(viewModel.groups) { group in
groupSection(group)
}
}
.listStyle(.insetGrouped)
.overlay {
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
ProgressView()
}
}
}
/// prefs
@ViewBuilder private var errorRows: some View {
ForEach(
[
viewModel.fetchErrorMessage,
viewModel.prefsErrorMessage,
viewModel.prefsSyncErrorMessage,
viewModel.openErrorMessage,
].compactMap(\.self),
id: \.self
) { message in
Label(message, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.orange)
.listRowSeparator(.hidden)
}
}
// MARK: - Group sectionflat chromenamespace/other
@ViewBuilder private func groupSection(_ group: ProjectGroup) -> some View {
let isCollapsed = viewModel.isCollapsed(group)
Section {
if !isCollapsed {
ForEach(group.projects, id: \.path) { project in
projectRow(project, group: group)
}
}
} header: {
if group.kind != .flat {
groupHeader(group, isCollapsed: isCollapsed)
}
}
}
private func groupHeader(_ group: ProjectGroup, isCollapsed: Bool) -> some View {
HStack(spacing: Metrics.chipSpacing) {
if group.isCollapsible {
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
.font(.caption2)
}
// namespace label verbatim
Text(verbatim: group.label)
.lineLimit(1)
Text(verbatim: "\(group.projects.count)")
.foregroundStyle(.secondary)
// web
if group.kind != .active && group.activeCount > 0 {
Text(ProjectsCopy.activeCountBadge(group.activeCount))
.font(.caption2)
.foregroundStyle(.green)
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
.onTapGesture {
guard group.isCollapsible else { return }
Task { await viewModel.toggleCollapsed(key: group.key) }
}
.accessibilityAddTraits(group.isCollapsible ? .isButton : [])
}
// MARK: - Project row
private func projectRow(_ project: ProjectInfo, group: ProjectGroup) -> some View {
NavigationLink(value: ProjectRoute(path: project.path)) {
HStack(spacing: Metrics.chipSpacing) {
favouriteButton(project)
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
Text(verbatim: ProjectGrouping.displayLabel(
name: project.name, groupKey: group.key
))
.font(.body.weight(.medium))
.lineLimit(1)
projectChips(project)
}
Spacer(minLength: 0)
if ProjectGrouping.hasRunningSession(project) {
Image(systemName: "circle.fill")
.font(.caption2)
.foregroundStyle(.green)
.accessibilityLabel(ProjectsCopy.activeCountBadge(1))
}
}
}
}
private func favouriteButton(_ project: ProjectInfo) -> some View {
Button {
Task { await viewModel.toggleFavourite(path: project.path) }
} label: {
Image(systemName: viewModel.isFavourite(project.path) ? "star.fill" : "star")
.foregroundStyle(.yellow)
}
.buttonStyle(.borderless) // List
}
@ViewBuilder private func projectChips(_ project: ProjectInfo) -> some View {
HStack(spacing: Metrics.chipSpacing) {
if let branch = project.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(.caption)
.foregroundStyle(.secondary)
}
if project.dirty == true {
Text(ProjectsCopy.dirtyBadge)
.font(.caption2)
.foregroundStyle(.orange)
}
}
}
}
/// push value-based navigation
struct ProjectRoute: Hashable {
let path: String
}

View File

@@ -16,6 +16,11 @@ struct SessionListScreen: View {
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
/// Host-switch header hook: "" entry (pairing sheet, T-iOS-15).
var onAddHost: () -> Void = {}
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
/// render-concurrency gate across ALL rows (scrolling must never spawn
/// unbounded offscreen terminals). `@State` keeps it stable across body
/// rebuilds; `live()` is cheap (the URLSession is a static shared).
@State private var thumbnails = SessionThumbnailPipeline.live()
var body: some View {
content
@@ -60,7 +65,7 @@ struct SessionListScreen: View {
Button {
viewModel.openSession(id: row.id)
} label: {
SessionRowView(row: row)
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
@@ -74,6 +79,23 @@ struct SessionListScreen: View {
.refreshable { await viewModel.refresh() }
}
/// T-iOS-28 · build one row's thumbnail slot. No paired host (defensive
/// rows imply a host) no slot; the request key carries `lastOutputAt`
/// so unchanged sessions render exactly once (pipeline cache).
private func thumbnailSlot(
for row: SessionListViewModel.SessionRow
) -> SessionThumbnailView? {
guard let endpoint = viewModel.activeHost?.endpoint else { return nil }
return SessionThumbnailView(
request: SessionThumbnailRequest(
endpoint: endpoint,
sessionId: row.id,
lastOutputAt: row.info.lastOutputAt
),
pipeline: thumbnails
)
}
private func errorRow(_ message: String) -> some View {
Label(message, systemImage: "exclamationmark.triangle")
.font(.footnote)
@@ -142,15 +164,26 @@ struct SessionListScreen: View {
private struct SessionRowView: View {
let row: SessionListViewModel.SessionRow
/// T-iOS-28 (additive) · trailing live-preview thumbnail; nil = no slot
/// (no host / preview-less contexts) and the row lays out as before.
var thumbnail: SessionThumbnailView?
var body: some View {
HStack(alignment: .top, spacing: Metrics.rowSpacing) {
indicator
.frame(width: Metrics.indicatorWidth)
VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) {
Text(title)
.font(.body)
.lineLimit(1)
HStack(spacing: Metrics.titleSpacing) {
// T-iOS-23: OSC titles are attacker-controlled already
// sanitized in the VM, rendered verbatim (no Markdown /
// LocalizedStringKey interpretation), one line only.
Text(verbatim: title)
.font(.body)
.lineLimit(1)
if row.isUnread {
unreadDot
}
}
Text(meta)
.font(.caption)
.foregroundStyle(.secondary)
@@ -158,6 +191,10 @@ private struct SessionRowView: View {
TelemetryChips(model: telemetry)
}
}
if let thumbnail {
Spacer(minLength: Metrics.titleSpacing)
thumbnail
}
}
.opacity(row.info.exited ? Metrics.exitedOpacity : 1)
.accessibilityElement(children: .combine)
@@ -179,8 +216,19 @@ private struct SessionRowView: View {
}
}
/// `cwd` is server-supplied display text (untrusted plain Text only).
/// Unread dot (T-iOS-23): output newer than the local last-seen watermark.
private var unreadDot: some View {
Circle()
.fill(.blue)
.frame(width: Metrics.unreadDotSize, height: Metrics.unreadDotSize)
.accessibilityLabel(ScreenCopy.unreadLabel)
}
/// Sanitized OSC title first (T-iOS-23, mirrors web autoTitle precedence,
/// public/tabs.ts:570), else the cwd-derived name. Both are server-supplied
/// display text (untrusted verbatim Text only).
private var title: String {
if let osc = row.title, !osc.isEmpty { return osc }
guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory }
return URL(fileURLWithPath: cwd).lastPathComponent
}
@@ -213,6 +261,8 @@ private struct SessionRowView: View {
static let dotSize: CGFloat = 10
static let dotTopPadding: CGFloat = 5
static let exitedOpacity = 0.55
static let titleSpacing: CGFloat = 6
static let unreadDotSize: CGFloat = 8
}
}
@@ -231,6 +281,7 @@ private enum ScreenCopy {
static let unknownDirectory = "未知目录"
static let exitedTag = "已退出"
static let pendingBadgeLabel = "等待审批"
static let unreadLabel = "有新输出"
static func clientCount(_ count: Int) -> String {
"\(count) 台设备在看"

View File

@@ -20,26 +20,51 @@ import UIKit
/// T-iOS-15 this screen only renders and routes.
struct TerminalScreen: View {
let viewModel: TerminalViewModel
/// T-iOS-29 · ""toolbar + exit
/// "" closeopen fresh spawncwd
/// `AppCoordinator.openNewSessionInCurrentCwd`nil =
/// / coordinator
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
private enum Metrics {
static let bannerHorizontalPadding: CGFloat = 12
static let bannerTopPadding: CGFloat = 8
}
private enum Copy {
static let newSessionInCwd = "在当前目录开新会话"
}
var body: some View {
TerminalHostView(viewModel: viewModel)
.ignoresSafeArea(.container, edges: .bottom)
.overlay(alignment: .top) {
if let model = viewModel.bannerModel {
ReconnectBanner(model: model)
ReconnectBanner(model: model, onNewSession: onNewSessionInCwd)
.padding(.horizontal, Metrics.bannerHorizontalPadding)
.padding(.top, Metrics.bannerTopPadding)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.animation(.default, value: viewModel.bannerModel)
.toolbar { newSessionToolbarItem }
.onAppear { viewModel.start() }
}
/// Mirrors web `tabs.ts newTab()` (M6): the + affordance opens a fresh
/// session in the active session's cwd, if known.
@ToolbarContentBuilder private var newSessionToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
if let onNewSessionInCwd {
Button {
onNewSessionInCwd()
} label: {
Label(Copy.newSessionInCwd, systemImage: "plus.rectangle.on.folder")
}
.accessibilityIdentifier("terminal.newInCwdButton")
}
}
}
}
// MARK: - SwiftTerm bridge
@@ -112,9 +137,15 @@ private struct TerminalHostView: UIViewRepresentable {
UIApplication.shared.open(url)
}
// Title/cwd surface in the session list via the server (T-iOS-13/23),
// not from the local emulator deliberate no-ops.
func setTerminalTitle(source: TerminalView, title: String) {}
/// OSC 0/2 title (T-iOS-23): hostile input the VM sanitizes
/// (TitleSanitizer) before any UI/registry use, then the wiring
/// surfaces it on the session-list row.
func setTerminalTitle(source: TerminalView, title: String) {
viewModel.setTerminalTitle(title)
}
// cwd surfaces in the session list via the server (T-iOS-13), not
// from the local emulator deliberate no-op.
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
func scrolled(source: TerminalView, position: Double) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}

View File

@@ -0,0 +1,166 @@
import SwiftUI
import WireProtocol
/// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the
/// away-digestaffordance (TerminalContainerView wiring).
///
/// Mirrors the web timeline panel (public/timeline.ts): rows are
/// "HH:MM · icon · label", newest-first, capped at
/// `TimelineViewModel.maxEvents`. `label` is SERVER text (untrusted display
/// input) rendered via `Text(verbatim:)` only, `lineLimit(1)` (same row
/// discipline as AwayDigestView; web sets it via textContent, SEC-H6).
struct TimelineSheet: View {
let viewModel: TimelineViewModel
private enum Metrics {
static let rowSpacing: CGFloat = 10
static let iconColumnWidth: CGFloat = 24
}
var body: some View {
NavigationStack {
content
.navigationTitle(TimelineCopy.title)
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.medium, .large])
.task { await viewModel.load() } // fresh VM per presentation one fetch
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .empty:
emptyState
case .failed:
failedState
case .loaded(let events):
eventList(events)
}
}
/// Server replied `[]` covers both "no activity yet" and timeline
/// capture disabled host-side (src/server.ts:589-591). An empty timeline
/// is a normal state, NEVER an error (task spec).
private var emptyState: some View {
ContentUnavailableView(
TimelineCopy.emptyTitle,
systemImage: "clock.badge.questionmark",
description: Text(TimelineCopy.emptyDetail)
)
}
/// Explicit, retryable error state the fetch can fail transiently
/// (LAN hop, host asleep); retry re-runs the same load path.
private var failedState: some View {
ContentUnavailableView {
Label(TimelineCopy.loadFailed, systemImage: "wifi.exclamationmark")
} description: {
Text(TimelineCopy.loadFailedDetail)
} actions: {
Button(TimelineCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
}
}
// MARK: - Rows (newest-first, already ordered by the VM)
private func eventList(_ events: [TimelineEvent]) -> some View {
List {
// Offset identity (not `at`): the server can ingest several
// events in the same millisecond, and rows are static.
ForEach(Array(events.enumerated()), id: \.offset) { _, event in
row(event)
}
}
.listStyle(.plain)
}
private func row(_ event: TimelineEvent) -> some View {
HStack(spacing: Metrics.rowSpacing) {
Text(TimelineRowFormat.timeLabel(atMs: event.at))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Text(verbatim: TimelineClassStyle.glyph(for: event.class))
.font(.callout)
.foregroundStyle(TimelineClassStyle.color(for: event.class))
.frame(width: Metrics.iconColumnWidth)
// Server-derived phrase untrusted: verbatim (never
// LocalizedStringKey/Markdown) + hard single-line truncation.
Text(verbatim: event.label)
.font(.subheadline)
.lineLimit(1)
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
}
}
// MARK: - class icon / color mapping
/// Glyphs mirror web `timelineIcon` verbatim (public/timeline.ts:88-96).
/// Colors are semantic the web CSS defines NO tl-icon-* colors, so iOS
/// aligns with SessionListScreen's status color convention (waiting=orange,
/// stuck=red) and extends it. Total functions: the server is untrusted, so an
/// unknown class degrades to a neutral glyph/color instead of trapping (even
/// though `APIClient.events` already drops unknown classes defense in depth).
enum TimelineClassStyle {
static let fallbackGlyph = ""
static func glyph(for cls: String) -> String {
switch cls {
case "tool": return "🔧"
case "waiting": return ""
case "done": return ""
case "stuck": return ""
case "user": return "💬"
default: return fallbackGlyph
}
}
static func color(for cls: String) -> Color {
switch cls {
case "tool": return .blue
case "waiting": return .orange
case "done": return .green
case "stuck": return .red
case "user": return .purple
default: return .secondary
}
}
}
// MARK: - Row time formatting
/// Mirrors web `formatHHMM` (public/timeline.ts:101-106): 24-hour wall-clock
/// "HH:mm". Fixed POSIX locale so a 12-hour user locale can't leak AM/PM into
/// the fixed format; timezone injectable for deterministic tests.
enum TimelineRowFormat {
private static let millisecondsPerSecond = 1_000.0
static func timeLabel(atMs: Int, timeZone: TimeZone = .current) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = timeZone
formatter.dateFormat = "HH:mm"
let date = Date(timeIntervalSince1970: Double(atMs) / millisecondsPerSecond)
return formatter.string(from: date)
}
}
// MARK: - plan
enum TimelineCopy {
static let title = "活动时间线"
static let emptyTitle = "暂无活动"
static let emptyDetail = "会话还没有可展示的事件主机关闭时间线TIMELINE_ENABLED=0时也会显示为空。"
static let loadFailed = "时间线加载失败"
static let loadFailedDetail = "无法从主机获取活动时间线,请检查连接后重试。"
static let retry = "重试"
}

View File

@@ -0,0 +1,164 @@
import Foundation
import Observation
import WireProtocol
/// T-iOS-27 · State for the read-only diff viewer (`DiffScreen`).
///
/// The fetch closure is injected `forProject` `DiffFetcher`
/// App T-iOS-26 ProjectDetail (endpoint, path)
/// fake
/// `DiffFetcher` VM
/// - files `.loaded`/hunk /****
/// diff DIFF_MAX_BYTES 2MB Text
/// - `[]` `.empty`truncated
/// - 400/ `.failed(.pathInvalid)`404 `.failed(.notFound)`
/// `.failed(.unavailable)` `load()`
/// - staged/unstaged `setStaged` fetch no-op
@MainActor
@Observable
final class DiffViewModel {
/// DiffScreen DiffCopy
enum Failure: Equatable {
case pathInvalid
case notFound
case unavailable
}
/// Rendering phase an explicit enum so the screen can never show an
/// error and diff rows at the same time (same discipline as Timeline).
enum Phase: Equatable {
case loading
/// filestruncated
case empty(truncated: Bool)
case loaded(DiffPresentation)
case failed(Failure)
}
private(set) var phase: Phase = .loading
/// false = unstagedtrue = --staged
private(set) var staged = false
@ObservationIgnored
private let fetch: @Sendable (_ staged: Bool) async throws -> DiffResult
init(fetch: @escaping @Sendable (_ staged: Bool) async throws -> DiffResult) {
self.fetch = fetch
}
/// `DiffScreen(endpoint:path:http:)` T-iOS-26
/// ProjectDetail DiffFetcher
static func forProject(
endpoint: HostEndpoint, path: String, http: any HTTPTransport
) -> DiffViewModel {
let fetcher = DiffFetcher(endpoint: endpoint, http: http)
return DiffViewModel(fetch: { staged in
try await fetcher.fetch(path: path, staged: staged)
})
}
/// Fetch and present. Also thepath: callable again from `.failed`.
func load() async {
phase = .loading
do {
let result = try await fetch(staged)
phase = Self.presentation(for: result)
} catch let error as DiffFetchError {
phase = .failed(Self.failure(for: error))
} catch {
phase = .failed(.unavailable) //
}
}
/// staged/unstaged fetch Steps
func setStaged(_ newValue: Bool) async {
guard newValue != staged else { return }
staged = newValue
await load()
}
// MARK: -
static func presentation(for result: DiffResult) -> Phase {
guard !result.files.isEmpty else {
return .empty(truncated: result.truncated)
}
return .loaded(DiffPresentation(
rows: makeRows(files: result.files), truncated: result.truncated
))
}
/// (binary | hunk )
/// binary hunks web renderDiffFile early return
/// public/diff.ts:163-166id
static func makeRows(files: [DiffFile]) -> [DiffRow] {
var rows: [DiffRow] = []
for file in files {
rows.append(DiffRow(id: rows.count, kind: .fileHeader(header(for: file))))
if file.binary {
rows.append(DiffRow(id: rows.count, kind: .binaryNotice))
continue
}
for hunk in file.hunks {
rows.append(DiffRow(id: rows.count, kind: .hunkHeader(hunk.header)))
for line in hunk.lines {
rows.append(DiffRow(
id: rows.count, kind: .line(kind: line.kind, text: line.text)
))
}
}
}
return rows
}
private static func failure(for error: DiffFetchError) -> Failure {
switch error {
case .invalidRequest, .pathInvalid:
return .pathInvalid
case .projectNotFound:
return .notFound
case .invalidResponse, .unexpectedStatus:
return .unavailable
}
}
/// Rename old new webpublic/diff.ts:146-150
/// newPath `Text(verbatim:)`
private static func header(for file: DiffFile) -> DiffFileHeader {
let pathLabel = file.status == .renamed && file.oldPath != file.newPath
? "\(file.oldPath)\(file.newPath)"
: file.newPath
return DiffFileHeader(
pathLabel: pathLabel, added: file.added,
removed: file.removed, status: file.status
)
}
}
// MARK: -
/// Display-ready flattened diff
struct DiffPresentation: Equatable {
let rows: [DiffRow]
let truncated: Bool
}
/// One lazy-list row. `id` =
struct DiffRow: Equatable, Identifiable {
enum Kind: Equatable {
case fileHeader(DiffFileHeader)
case binaryNotice
case hunkHeader(String)
case line(kind: DiffLineKind, text: String)
}
let id: Int
let kind: Kind
}
/// File-header row payload rename
struct DiffFileHeader: Equatable {
let pathLabel: String
let added: Int
let removed: Int
let status: DiffFileStatus
}

View File

@@ -0,0 +1,76 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// T-iOS-26 · `GET /projects/detail?path=` phase
/// DiffViewModel/TimelineViewModel
///
/// fetch `forHost` `APIClient.projectDetail(path:)`
/// builder 400/404/500 `{error}` T-iOS-38
/// fake VM
/// - `.loaded(ProjectDetail)`sessions/worktrees/hasClaudeMd
/// - 400 `.failed(.pathInvalid)`404 `.failed(.notFound)`
/// 500// `.failed(.unavailable)` `load()`
@MainActor
@Observable
final class ProjectDetailViewModel {
/// ProjectDetailScreen
enum Failure: Equatable {
case pathInvalid
case notFound
case unavailable
}
enum Phase: Equatable {
case loading
case loaded(ProjectDetail)
case failed(Failure)
}
private(set) var phase: Phase = .loading
/// /diff
/// builder URL
let path: String
@ObservationIgnored
private let fetch: @Sendable () async throws -> ProjectDetail
init(path: String, fetch: @escaping @Sendable () async throws -> ProjectDetail) {
self.path = path
self.fetch = fetch
}
/// ProjectsViewModel.makeDetailViewModel
static func forHost(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> ProjectDetailViewModel {
let client = APIClient(endpoint: endpoint, http: http)
return ProjectDetailViewModel(path: path, fetch: {
try await client.projectDetail(path: path)
})
}
/// Fetch `.failed`
func load() async {
phase = .loading
do {
phase = .loaded(try await fetch())
} catch let error as APIClientError {
phase = .failed(Self.failure(for: error))
} catch {
phase = .failed(.unavailable) //
}
}
private static func failure(for error: APIClientError) -> Failure {
switch error {
case .projectPathInvalid, .invalidRequest:
return .pathInvalid
case .projectNotFound:
return .notFound
default:
return .unavailable
}
}
}

View File

@@ -0,0 +1,192 @@
import APIClient
import Foundation
/// T-iOS-26 · Projects web v0.6
/// public/projects.tsfilterProjects / sortProjects / groupProjects /
/// displayLabel使
///
/// ** key web **namespace
/// `" active"` / `" other"` key
/// `/prefs.collapsed` key =
/// label UI namespace = key
///
/// Active assert reality`ProjectSessionRef.exited`
/// src/types.ts:262-269running = `!exited` web
/// `hasRunningSession` running ****
enum ProjectGrouping {
/// key `First.Second` namespace
/// public/projects.ts:83-84
static let activeGroupKey = " active"
static let otherGroupKey = " other"
/// namespace MIN_GROUP_SIZE
static let minGroupSize = 2
// MARK: - filter filterProjectsname/path
static func filter(_ projects: [ProjectInfo], query: String) -> [ProjectInfo] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return projects }
let lower = trimmed.lowercased()
return projects.filter {
$0.name.lowercased().contains(lower) || $0.path.lowercased().contains(lower)
}
}
// MARK: - sort sortProjects lastActiveMs
/// JS `Array.sort` web Swift
/// `sorted`
static func sort(_ projects: [ProjectInfo], favourites: Set<String>) -> [ProjectInfo] {
projects.enumerated().sorted { a, b in
let aFav = favourites.contains(a.element.path)
let bFav = favourites.contains(b.element.path)
if aFav != bFav { return aFav }
let aActive = a.element.lastActiveMs ?? 0
let bActive = b.element.lastActiveMs ?? 0
if aActive != bActive { return aActive > bActive }
return a.offset < b.offset
}.map(\.element)
}
// MARK: - group groupProjects
static func group(_ projects: [ProjectInfo], favourites: Set<String>) -> [ProjectGroup] {
let (namespaceGroups, other) = bucketByNamespace(projects, favourites: favourites)
// flat chrome 退
guard !namespaceGroups.isEmpty else {
return [makeGroup(
key: otherGroupKey, label: ProjectsCopy.allGroupLabel,
kind: .flat, items: projects, favourites: favourites
)]
}
var groups: [ProjectGroup] = []
let active = projects.filter(hasRunningSession)
if !active.isEmpty {
groups.append(makeGroup(
key: activeGroupKey, label: ProjectsCopy.activeGroupLabel,
kind: .active, items: active, favourites: favourites
))
}
groups.append(contentsOf: orderedByRecency(namespaceGroups))
if !other.isEmpty {
groups.append(makeGroup(
key: otherGroupKey, label: ProjectsCopy.otherGroupLabel,
kind: .other, items: other, favourites: favourites
))
}
return groups
}
/// namespace `<key>.`
/// `Billo.Platform.` displayLabel
static func displayLabel(name: String, groupKey: String) -> String {
if groupKey == activeGroupKey || groupKey == otherGroupKey { return name }
let prefix = "\(groupKey)."
guard name.lowercased().hasPrefix(prefix.lowercased()) else { return name }
return String(name.dropFirst(prefix.count))
}
static func hasRunningSession(_ project: ProjectInfo) -> Bool {
project.sessions.contains { !$0.exited }
}
// MARK: - Internals
/// namespace = `'a.b.c'` `'a.b'` nil
/// JS `split('.')`
private static func namespaceKey(_ name: String) -> String? {
let segments = name.split(separator: ".", omittingEmptySubsequences: false)
guard segments.count >= 2 else { return nil }
return segments.prefix(2).joined(separator: ".")
}
/// key JS Map
/// ( namespace, Other )
private static func bucketByNamespace(
_ projects: [ProjectInfo], favourites: Set<String>
) -> (groups: [ProjectGroup], other: [ProjectInfo]) {
var bucketOrder: [String] = []
var buckets: [String: (display: String, items: [ProjectInfo])] = [:]
var other: [ProjectInfo] = []
for project in projects {
guard let namespace = namespaceKey(project.name) else {
other.append(project)
continue
}
let lowerKey = namespace.lowercased()
if var bucket = buckets[lowerKey] {
bucket.items.append(project)
buckets[lowerKey] = bucket
} else {
buckets[lowerKey] = (namespace, [project])
bucketOrder.append(lowerKey)
}
}
var groups: [ProjectGroup] = []
for lowerKey in bucketOrder {
guard let bucket = buckets[lowerKey] else { continue }
if bucket.items.count < minGroupSize {
other.append(contentsOf: bucket.items)
} else {
groups.append(makeGroup(
key: bucket.display, label: bucket.display,
kind: .namespace, items: bucket.items, favourites: favourites
))
}
}
return (groups, other)
}
/// namespace label
/// groupProjects sort
private static func orderedByRecency(_ groups: [ProjectGroup]) -> [ProjectGroup] {
groups.sorted { a, b in
let aMax = maxLastActive(a.projects)
let bMax = maxLastActive(b.projects)
if aMax != bMax { return aMax > bMax }
return a.label.localizedCompare(b.label) == .orderedAscending
}
}
private static func maxLastActive(_ items: [ProjectInfo]) -> Int {
items.reduce(0) { max($0, $1.lastActiveMs ?? 0) }
}
private static func makeGroup(
key: String, label: String, kind: ProjectGroupKind,
items: [ProjectInfo], favourites: Set<String>
) -> ProjectGroup {
ProjectGroup(
key: key, label: label, kind: kind,
projects: sort(items, favourites: favourites),
activeCount: items.filter(hasRunningSession).count
)
}
}
/// web `ProjectGroupKind`
enum ProjectGroupKind: Equatable, Sendable {
case active
case namespace
case other
case flat
}
/// web `ProjectGroup`
struct ProjectGroup: Equatable, Identifiable, Sendable {
/// key web /prefs
let key: String
let label: String
let kind: ProjectGroupKind
///
let projects: [ProjectInfo]
///
let activeCount: Int
var id: String { key }
/// Active now flat chrome
var isCollapsible: Bool { kind == .namespace || kind == .other }
}

View File

@@ -0,0 +1,236 @@
import APIClient
import Foundation
import HostRegistry
import Observation
import WireProtocol
/// T-iOS-26 · Projects web v0.6 projects
/// `GET /projects` + `GET/PUT /prefs` / +
/// ""
///
/// Documented decisions:
/// - **prefs clobber **`PUT /prefs` blob
/// src/server.ts:286-288 `UiPrefs` unknown-key-preserving
/// API web/prefs GET
/// toggle ** PUT** =
/// - **PUT echo ** sanitize
/// + toggle
/// - **prefs load ** web mountProjects.init
/// clobber
/// - APIClient
/// OpenRequest `Validation.isAbsoluteCwd`deep-link
@MainActor
@Observable
final class ProjectsViewModel {
// MARK: - Observable state
private(set) var projects: [ProjectInfo] = []
private(set) var hasLoadedOnce = false
///
private(set) var fetchErrorMessage: String?
/// prefs / clobber
private(set) var prefsErrorMessage: String?
/// PUT /prefs
private(set) var prefsSyncErrorMessage: String?
/// ""
private(set) var openErrorMessage: String?
///
private(set) var favourites: [String] = []
/// key true web/
private(set) var collapsedGroups: [String: Bool] = [:]
/// T-iOS-26 id onChange
private(set) var openRequest: ProjectOpenRequest?
/// .searchable
var searchText = ""
// MARK: - Dependencies & internal state
let host: HostRegistry.Host
@ObservationIgnored let http: any HTTPTransport
/// prefs blobnil =
/// PUT
@ObservationIgnored private var prefsBase: UiPrefs?
private var client: APIClient {
APIClient(endpoint: host.endpoint, http: http)
}
init(host: HostRegistry.Host, http: any HTTPTransport) {
self.host = host
self.http = http
}
// MARK: - Derived view state
/// web
var groups: [ProjectGroup] {
ProjectGrouping.group(
ProjectGrouping.filter(projects, query: searchText),
favourites: Set(favourites)
)
}
var isSearching: Bool {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// caret web renderGrid
func isCollapsed(_ group: ProjectGroup) -> Bool {
group.isCollapsible && !isSearching && collapsedGroups[group.key] == true
}
func isFavourite(_ path: String) -> Bool {
favourites.contains(path)
}
/// vs web renderGrid msg
var emptyStateMessage: String? {
guard hasLoadedOnce, fetchErrorMessage == nil else { return nil }
if projects.isEmpty { return ProjectsCopy.emptyNoProjects }
if ProjectGrouping.filter(projects, query: searchText).isEmpty {
return ProjectsCopy.emptyNoMatch
}
return nil
}
// MARK: - Load / refresh
/// prefs+ prefs web init
func load() async {
await loadPrefsIfNeeded()
await refresh()
}
/// +
func refresh() async {
do {
projects = try await client.projects()
fetchErrorMessage = nil
hasLoadedOnce = true
} catch {
fetchErrorMessage = ProjectsCopy.fetchFailed(Self.errorDetail(error))
}
}
private func loadPrefsIfNeeded() async {
guard prefsBase == nil else { return }
do {
let prefs = try await client.prefs()
adopt(prefs)
prefsErrorMessage = nil
} catch {
prefsErrorMessage = ProjectsCopy.prefsLoadFailed
}
}
// MARK: - Favourites / collapse prefs
func toggleFavourite(path: String) async {
favourites = favourites.contains(path)
? favourites.filter { $0 != path }
: favourites + [path]
await persistPrefs()
}
func toggleCollapsed(key: String) async {
if collapsedGroups[key] == true {
collapsedGroups = collapsedGroups.filter { $0.key != key }
} else {
collapsedGroups = collapsedGroups.merging([key: true]) { _, new in new }
}
await persistPrefs()
}
/// blob PUT
/// echoprefs
private func persistPrefs() async {
guard let base = prefsBase else { return }
let next = base.withFavourites(favourites).withCollapsed(collapsedGroups)
prefsBase = next // toggle
do {
let echoed = try await client.putPrefs(next)
adopt(echoed)
prefsSyncErrorMessage = nil
} catch {
prefsSyncErrorMessage = ProjectsCopy.prefsSyncFailed(Self.errorDetail(error))
}
}
private func adopt(_ prefs: UiPrefs) {
prefsBase = prefs
favourites = prefs.favourites
collapsedGroups = prefs.collapsed
}
// MARK: - T-iOS-26
/// `attach(null, cwd)` + attach `claude\r` engine
/// attach-first
func requestOpenClaude(cwd: String) {
guard Validation.isAbsoluteCwd(cwd) else {
openErrorMessage = ProjectsCopy.openClaudeInvalidPath
return
}
openErrorMessage = nil
openRequest = ProjectOpenRequest(
id: UUID(), host: host, cwd: cwd,
bootstrapInput: ProjectLaunch.claudeBootstrapInput
)
}
// MARK: - Detail assembly/
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {
.forHost(endpoint: host.endpoint, http: http, path: path)
}
// MARK: - Helpers
private static func errorDetail(_ error: any Error) -> String {
(error as? APIClientError)?.message ?? error.localizedDescription
}
}
/// "" `SessionListViewModel.OpenRequest`
/// cwd+bootstrap T-iOS-13
/// AppCoordinator.openProject
struct ProjectOpenRequest: Equatable, Sendable, Identifiable {
let id: UUID
let host: HostRegistry.Host
///
let cwd: String
/// attach nil = shell
let bootstrapInput: String?
}
/// public/tabs.ts:679 `openProject` cmd
enum ProjectLaunch {
/// Enter `\r`0x0D `\n` CLAUDE.md Gotchas
static let claudeBootstrapInput = "claude\r"
}
/// plan §4
enum ProjectsCopy {
static let title = "项目"
static let searchPrompt = "筛选项目…"
static let activeGroupLabel = "活跃中"
static let otherGroupLabel = "其他"
static let allGroupLabel = "全部项目"
static let dirtyBadge = "未提交"
static let emptyNoProjects = "未发现项目。请检查主机的 PROJECT_ROOTS 配置。"
static let emptyNoMatch = "没有匹配的项目。"
static let prefsLoadFailed = "云端收藏/折叠状态加载失败,本次修改仅在本机生效。"
static let openClaudeInvalidPath = "项目路径无效,无法开新会话。"
static func fetchFailed(_ detail: String) -> String {
"刷新项目列表失败:\(detail)"
}
static func prefsSyncFailed(_ detail: String) -> String {
"收藏/折叠状态同步失败:\(detail)"
}
static func activeCountBadge(_ count: Int) -> String {
"\(count) 活跃"
}
}

View File

@@ -2,6 +2,7 @@ import APIClient
import Foundation
import HostRegistry
import Observation
import SessionCore
import WireProtocol
/// T-iOS-13 · Session list state (merged chooser + dashboard, plan §7):
@@ -56,6 +57,12 @@ final class SessionListViewModel {
let info: LiveSessionInfo
let isPendingApproval: Bool
let telemetry: TelemetryChips.Model?
/// Sanitized OSC title (T-iOS-23) nil = no title, row falls back to
/// the cwd-derived name. NEVER raw delegate input (TitleSanitizer).
let title: String?
/// `lastOutputAt` strictly newer than the local last-seen watermark
/// (UnreadLedger; mirrors the web tab dot, public/tabs.ts hasActivity).
let isUnread: Bool
var id: UUID { info.id }
var indicator: StatusIndicator {
@@ -105,10 +112,19 @@ final class SessionListViewModel {
@ObservationIgnored private let clock: any Clock<Duration>
/// Injected time source for telemetry staleness (ms since epoch).
@ObservationIgnored private let nowMs: @Sendable () -> Int
@ObservationIgnored private let unreadStore: any UnreadWatermarkStore
@ObservationIgnored private var pollTask: Task<Void, Never>?
@ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
@ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
@ObservationIgnored private var pendingSessionIds: Set<UUID> = []
/// T-iOS-23 · last-seen watermarks (loaded once, persisted per `markSeen`).
/// NOT pruned to the active host's fetch other hosts' watermarks must
/// survive a host switch; `UnreadLedger.maxEntries` bounds growth instead.
@ObservationIgnored private var unreadLedger: UnreadLedger
/// T-iOS-23 · sanitized OSC titles by sessionId. In-memory only replay
/// re-emits the OSC sequence on next attach, persistence would only keep
/// stale titles. Other hosts' entries never match a visible row.
@ObservationIgnored private var sessionTitles: [UUID: String] = [:]
@ObservationIgnored private var hasAttemptedHostLoad = false
@ObservationIgnored private var hasLoadedOnce = false
@@ -120,12 +136,15 @@ final class SessionListViewModel {
hostStore: any HostStore,
http: any HTTPTransport,
clock: any Clock<Duration>,
unreadStore: any UnreadWatermarkStore,
nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs
) {
self.hostStore = hostStore
self.http = http
self.clock = clock
self.unreadStore = unreadStore
self.nowMs = nowMs
unreadLedger = UnreadLedger(watermarks: unreadStore.load())
}
// MARK: - Visibility lifecycle (poll task owner)
@@ -230,6 +249,32 @@ final class SessionListViewModel {
rebuildRows()
}
// MARK: - Unread watermarks (T-iOS-23; UnreadLedger + persisted store)
/// The user just looked at (or is about to look at) this session: stamp
/// the last-seen watermark NOW and persist it. Called on row tap
/// (`openSession`) and by the coordinator when a terminal closes output
/// that streamed while the user was watching must not relight the dot.
func markSeen(sessionId: UUID) {
unreadLedger = unreadLedger.record(seen: sessionId, at: nowMs())
unreadStore.save(unreadLedger.watermarks)
rebuildRows()
}
// MARK: - OSC title registry (T-iOS-23; fed by TerminalViewModel wiring)
/// Surface a terminal-reported OSC title on the session's list row.
/// `title` is UNTRUSTED (host/attacker-controlled OSC payload) sanitized
/// again at THIS boundary regardless of upstream (sanitize is idempotent).
/// Empty after sanitisation clears the entry (web `title.trim() || null`).
func setSessionTitle(sessionId: UUID, title: String) {
let sanitized = TitleSanitizer.sanitize(title)
sessionTitles = sanitized.isEmpty
? sessionTitles.filter { $0.key != sessionId }
: sessionTitles.merging([sessionId: sanitized]) { _, new in new }
rebuildRows()
}
// MARK: - Swipe-to-kill (optimistic + rollback)
func kill(sessionId: UUID) async {
@@ -258,8 +303,11 @@ final class SessionListViewModel {
}
/// Open a listed session. Unknown ids are refused at the boundary.
/// Tapping = seeing: the unread watermark is stamped immediately (the
/// terminal replays everything anyway; the dot must not outlive the tap).
func openSession(id: UUID) {
guard let activeHost, rows.contains(where: { $0.id == id }) else { return }
markSeen(sessionId: id)
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: id)
}
@@ -272,7 +320,11 @@ final class SessionListViewModel {
SessionRow(
info: info,
isPendingApproval: pendingSessionIds.contains(info.id),
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now)
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now),
title: sessionTitles[info.id],
isUnread: unreadLedger.isUnread(
sessionId: info.id, lastOutputAt: info.lastOutputAt
)
)
}
}

View File

@@ -67,6 +67,10 @@ final class TerminalViewModel {
/// Server-adopted session id (ALWAYS the server-issued one persisting it
/// per host is the T-iOS-15 wiring's job via `LastSessionStore`).
private(set) var sessionId: UUID?
/// Sanitized OSC title (T-iOS-23). Raw `setTerminalTitle` delegate input
/// is HOST/ATTACKER-CONTROLLED and passes `TitleSanitizer` at THIS
/// boundary; nil = no title (empty after sanitisation).
private(set) var terminalTitle: String?
/// Read-only = no input reaches the PTY (exit / terminal failure). Resize
/// is NOT gated here the engine owns terminal-state dropping.
@@ -121,6 +125,17 @@ final class TerminalViewModel {
/// Test tap, called after each event is applied (state already coherent).
@ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?
// MARK: - OSC title surface (T-iOS-23; wired by TerminalSessionController)
/// List-side registry hook: fires with the ADOPTED sessionId and the
/// SANITIZED title ("" = title cleared the registry drops the entry).
/// Titles arriving before adoption are held and forwarded once on
/// `.adopted` (defensive `attached` always precedes output on the wire).
@ObservationIgnored var onTitleChanged: (@MainActor (UUID, String) -> Void)?
/// Latest sanitized title not yet delivered to `onTitleChanged` because
/// no sessionId was known at the time. nil = nothing held.
@ObservationIgnored private var heldTitleForward: String?
private struct CountWaiter {
let target: Int
let continuation: CheckedContinuation<Void, Never>
@@ -194,6 +209,19 @@ final class TerminalViewModel {
enqueueSend(.input(data: data))
}
/// SwiftTerm reported an OSC 0/2 title (`setTerminalTitle` delegate).
/// Sanitize FIRST the raw string is untrusted terminal output then
/// surface locally and forward to the list registry (T-iOS-23).
func setTerminalTitle(_ raw: String) {
let sanitized = TitleSanitizer.sanitize(raw)
terminalTitle = sanitized.isEmpty ? nil : sanitized
guard let sessionId else {
heldTitleForward = sanitized
return
}
onTitleChanged?(sessionId, sanitized)
}
/// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded
/// the engine validates bounds and owns terminal-state dropping. Valid
/// dims are remembered in `lastSentDims` so the T-iOS-15 wiring can feed
@@ -214,6 +242,10 @@ final class TerminalViewModel {
applyConnection(state)
case .adopted(let id):
sessionId = id // ALWAYS adopt the server-issued id
if let held = heldTitleForward {
heldTitleForward = nil
onTitleChanged?(id, held)
}
case .output(let data):
deliverOutput(data)
case .exited(let code, let reason):

View File

@@ -0,0 +1,81 @@
import Foundation
import Observation
import WireProtocol
/// T-iOS-24 · State for the full-timeline drill-down sheet (`TimelineSheet`).
///
/// One VM per presentation (the container view builds a fresh one on each
/// digesttap), so every open re-fetches `GET /live-sessions/:id/events`.
/// The fetch closure is injected production wraps `APIClient.events` via
/// `TerminalSessionController.timelineEventsSource`; tests inject fakes.
///
/// Server data is UNTRUSTED at this boundary (plan §4): the tolerant decode
/// (malformed / unknown-class entries dropped, non-array `[]`) already
/// happened inside `APIClient.events` `TimelineEvent.decodeList`. This VM
/// only distinguishes the three USER-visible outcomes:
/// - `[]` `.empty` the server replies `[]` both for "no activity yet" and
/// for timeline capture disabled (src/server.ts:589-591); NEVER an error;
/// - thrown fetch `.failed` explicit, retryable (`load()` again);
/// - events `.loaded`, ordered exactly like the web panel.
@MainActor
@Observable
final class TimelineViewModel: Identifiable {
/// Rendering phase an explicit enum so the sheet can never show an
/// error and rows at the same time.
enum Phase: Equatable {
case loading
/// Display-ready rows: capped + newest-first (web parity, see
/// `presentation(for:)`).
case loaded([TimelineEvent])
/// Server returned `[]` (no activity OR timeline disabled host-side).
case empty
/// Fetch failed retryable via `load()`.
case failed
}
/// Web parity: `DEFAULT_MAX_EVENTS` (public/timeline.ts:20).
static let maxEvents = 50
/// `.sheet(item:)` identity a fresh VM per presentation.
nonisolated let id = UUID()
private(set) var phase: Phase = .loading
@ObservationIgnored private let fetch: @Sendable () async throws -> [TimelineEvent]
init(fetch: @escaping @Sendable () async throws -> [TimelineEvent]) {
self.fetch = fetch
}
/// Assembly seam for the digestentry point: no adopted sessionId
/// yet no sheet (defensive a digest only arrives after `attached`, so
/// the id is normally known). Otherwise the id is passed to `source`
/// verbatim on every load.
static func forSession(
_ sessionId: UUID?,
source: @escaping @Sendable (UUID) async throws -> [TimelineEvent]
) -> TimelineViewModel? {
guard let sessionId else { return nil }
return TimelineViewModel(fetch: { try await source(sessionId) })
}
/// Fetch and present. Also thepath: callable again from `.failed`.
func load() async {
phase = .loading
do {
let events = try await fetch()
phase = Self.presentation(for: events)
} catch {
phase = .failed // user-facing copy lives in TimelineSheet
}
}
/// Pure presentation reducer, mirroring the web panel's render() pipeline
/// line for line (public/timeline.ts:174-189): the server returns
/// oldest-first `slice(0, maxEvents)` first, THEN reverse, so the sheet
/// shows the same capped slice newest-first as the web timeline panel.
static func presentation(for events: [TimelineEvent]) -> Phase {
guard !events.isEmpty else { return .empty }
return .loaded(Array(events.prefix(maxEvents).reversed()))
}
}

View File

@@ -3,13 +3,33 @@ import SwiftUI
/// T-iOS-15 · App entry: assemble the production dependency graph once and
/// hand it to the coordinator (Pairing SessionList Terminal). All wiring
/// lives under `Wiring/`; this file stays a thin `@main`.
///
/// T-iOS-21线
/// - `@UIApplicationDelegateAdaptor` `PushAppDelegate`remote-notification
/// UIApplicationDelegateSwiftUI `init`
/// `didFinishLaunching` coordinator
/// delegate
/// - scenePhase `.active` `activate()` +
/// token Allow/Deny
@main
struct WebTermApp: App {
@State private var coordinator = AppCoordinator(environment: .production())
@UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate
@Environment(\.scenePhase) private var scenePhase
@State private var coordinator: AppCoordinator
init() {
let coordinator = AppCoordinator(environment: .production())
_coordinator = State(initialValue: coordinator)
PushAppDelegate.bootstrap = coordinator // didFinishLaunching
}
var body: some Scene {
WindowGroup {
RootView(coordinator: coordinator)
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
pushDelegate.activatePush()
}
}
}
}

View File

@@ -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 closeopen 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 controllercwd
/// 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? {

View File

@@ -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()

View File

@@ -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 = "项目"
}

View File

@@ -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 digestaffordance 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 {

View File

@@ -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
/// suspendresume
@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 attachfresh spawn id
/// nil cwd attach + attach bootstrapT-iOS-26suspend
/// 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
}

View 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)
}
}