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:
251
ios/App/WebTerm/Components/QuickReply.swift
Normal file
251
ios/App/WebTerm/Components/QuickReply.swift
Normal 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
|
||||
}
|
||||
}
|
||||
200
ios/App/WebTerm/Components/QuickReplyStore.swift
Normal file
200
ios/App/WebTerm/Components/QuickReplyStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
365
ios/App/WebTerm/Components/SessionThumbnail.swift
Normal file
365
ios/App/WebTerm/Components/SessionThumbnail.swift
Normal 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)不变 =
|
||||
/// 会话无新输出 = 缩略图不可能变 → 绝不重渲染。nil(P1 前旧服务器)意味着
|
||||
/// 没有失效信号:渲染一次后永久命中——宁可陈旧也不随每次 5s 轮询无界重
|
||||
/// 渲染(文档化降级)。
|
||||
/// - **并发上限**:离屏渲染要 spawn 一个完整 SwiftTerm 终端,列表滚动绝不能
|
||||
/// 无界并发——`SessionThumbnailRenderGate`(FIFO、permit 转移)把「取数 +
|
||||
/// 渲染」整段管线钳在 `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 KiB(src/config.ts:46
|
||||
/// DEFAULT_PREVIEW_BYTES,env `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: - 并发上限闸(FIFO、permit 转移)
|
||||
|
||||
/// 渲染管线的并发闸:`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 URLSession(preview 字节可能含屏上密钥,
|
||||
/// 内存缓存 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)→ nil(拒渲染);合法值钳到缩略图网格界(min 镜像 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"
|
||||
}
|
||||
85
ios/App/WebTerm/Components/SessionThumbnailRenderer.swift
Normal file
85
ios/App/WebTerm/Components/SessionThumbnailRenderer.swift
Normal 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
|
||||
/// 布局余量(pt):frame 取 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 默认不启 Metal——Metal 层无法被
|
||||
/// `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)
|
||||
}
|
||||
}
|
||||
}
|
||||
251
ios/App/WebTerm/DeepLinkRouter.swift
Normal file
251
ios/App/WebTerm/DeepLinkRouter.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
234
ios/App/WebTerm/DiffFetcher.swift
Normal file
234
ios/App/WebTerm/DiffFetcher.swift
Normal 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 单一 owner(PLAN §6 Owns
|
||||
// 铁律),本任务不得改包。此 fetcher 走同一枚注入的 `HTTPTransport`,语义与
|
||||
// APIClient 路由构建完全同构 —— **回收进 APIClient 是 T-iOS-38 owner 的
|
||||
// follow-up**(届时本文件的路由/编码/解码可整体搬迁,测试随行)。
|
||||
//
|
||||
// Origin 铁律(plan §3.4/§5.1,iff-G):`/projects/diff` 是 RO 端点 —— 路由层
|
||||
// 没有 requireAllowedOrigin(src/server.ts:601 注释 "no Origin guard; same
|
||||
// threat model as /projects"),因此请求**绝不携带 Origin**。若服务器未来把它
|
||||
// 改为 G,测试先红而不是靠巧合通过。
|
||||
//
|
||||
// 服务器是不可信输入源(plan §4):解码逐层宽容,镜像 web 的
|
||||
// normalizeDiffResult(public/diff.ts:38-102)—— 顶层三键缺一 → 整体无效;
|
||||
// 畸形 file/hunk/line 逐条丢弃;未知枚举值降级(kind → .context、status →
|
||||
// .modified),绝不 crash。
|
||||
|
||||
// MARK: - 模型(镜像 src/types.ts:445-479;App 内部,待 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
|
||||
/// 服务器 404:SEC-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 不可 import,T-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/port,path/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
|
||||
}
|
||||
}
|
||||
310
ios/App/WebTerm/Push/NotificationActionHandler.swift
Normal file
310
ios/App/WebTerm/Push/NotificationActionHandler.swift
Normal 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 target,Service 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 之后;
|
||||
/// - 失败必须可见:403(token 过期/已用)与传输失败都补一条本地通知,绝不静默吞。
|
||||
|
||||
// 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` 的白名单产物(Sendable——didReceive 在 nonisolated 上下文解析后
|
||||
/// 才跨进 MainActor,UN 对象绝不跨 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: - Parse(纯函数,payload 级可测核心)
|
||||
|
||||
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 token(T-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: - Decision(Allow/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 {
|
||||
// 403:token 过期/已用/不匹配(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 resolution(payload 不含 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: - UNUserNotificationCenterDelegate(薄胶水;UNNotificationResponse 无法单测构造)
|
||||
|
||||
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 的
|
||||
/// close→open(openDeepLinkedSession 是 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))
|
||||
}
|
||||
}
|
||||
331
ios/App/WebTerm/Push/PushRegistrar.swift
Normal file
331
ios/App/WebTerm/Push/PushRegistrar.swift
Normal 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)。
|
||||
///
|
||||
/// 重试语义:注册失败只记日志绝不 crash;device token 每次
|
||||
/// `registerForRemoteNotifications` 都会经 delegate 重新送达(launch/scene
|
||||
/// 激活各触发一次 `activate()`),`registeredHostIds` 记账保证只补注册失败/
|
||||
/// 新增的主机(服务器端 upsert 本身幂等,5/min/IP 限频下不浪费配额)。
|
||||
|
||||
// MARK: - Seams(UNUserNotificationCenter / 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:52,T-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 的小写 hex;仅驻内存(iOS 每次注册都重新送达,
|
||||
/// 无需落盘——落盘的只有服务器侧的注册表)。
|
||||
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 形状 64–160 hex,T-iOS-38)。
|
||||
static func hexToken(_ data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PushAppDelegate(app 生命周期胶水,单测不覆盖——全部逻辑在上方可测类型里)
|
||||
|
||||
/// `@UIApplicationDelegateAdaptor` 入口:remote-notification 回调只能经
|
||||
/// UIApplicationDelegate 送达,SwiftUI App 本体拿不到。
|
||||
///
|
||||
/// 接线路径(wiring 决策,任务允许的最小增量):SwiftUI 生命周期先跑
|
||||
/// `WebTermApp.init`(在那里创建 coordinator 并静态交接到 `bootstrap`),后跑
|
||||
/// `didFinishLaunching`(在此消费、组装 registrar/handler 并把 handler 设为
|
||||
/// UNUserNotificationCenter delegate——Apple 要求该 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"
|
||||
}
|
||||
269
ios/App/WebTerm/Screens/DiffScreen.swift
Normal file
269
ios/App/WebTerm/Screens/DiffScreen.swift
Normal 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 入口
|
||||
/// 未接 —— TerminalScreen(T-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 = "重试"
|
||||
}
|
||||
286
ios/App/WebTerm/Screens/ProjectDetailScreen.swift
Normal file
286
ios/App/WebTerm/Screens/ProjectDetailScreen.swift
Normal 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 claudeIcon(public/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) 个客户端"
|
||||
}
|
||||
}
|
||||
184
ios/App/WebTerm/Screens/ProjectsScreen.swift
Normal file
184
ios/App/WebTerm/Screens/ProjectsScreen.swift
Normal 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 section(flat 无 chrome;namespace/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
|
||||
}
|
||||
@@ -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) 台设备在看"
|
||||
|
||||
@@ -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 横幅上的
|
||||
/// "开新会话"(同一动作 —— close→open fresh spawn,cwd 解析在
|
||||
/// `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) {}
|
||||
|
||||
166
ios/App/WebTerm/Screens/TimelineSheet.swift
Normal file
166
ios/App/WebTerm/Screens/TimelineSheet.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the
|
||||
/// away-digest「展开」affordance (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 = "重试"
|
||||
}
|
||||
164
ios/App/WebTerm/ViewModels/DiffViewModel.swift
Normal file
164
ios/App/WebTerm/ViewModels/DiffViewModel.swift
Normal 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
|
||||
/// 服务器回了空 files(该范围无改动)。truncated 位仍需提示。
|
||||
case empty(truncated: Bool)
|
||||
case loaded(DiffPresentation)
|
||||
case failed(Failure)
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .loading
|
||||
/// 当前范围:false = 工作区(unstaged),true = 已暂存(--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 the「重试」path: 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-166)。id 为稳定递增序号(行内容可重复,不能当身份)。
|
||||
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”(镜像 web,public/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
|
||||
}
|
||||
76
ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift
Normal file
76
ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
192
ios/App/WebTerm/ViewModels/ProjectGrouping.swift
Normal file
192
ios/App/WebTerm/ViewModels/ProjectGrouping.swift
Normal file
@@ -0,0 +1,192 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
|
||||
/// T-iOS-26 · Projects 列表的纯分组逻辑 —— 逐条镜像 web v0.6 的
|
||||
/// public/projects.ts(filterProjects / 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-269),running = 任一会话 `!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(镜像 filterProjects:name/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 }
|
||||
}
|
||||
236
ios/App/WebTerm/ViewModels/ProjectsViewModel.swift
Normal file
236
ios/App/WebTerm/ViewModels/ProjectsViewModel.swift
Normal 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 blob(含未知键)。nil = 从未成功加载 →
|
||||
/// 绝不 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
|
||||
/// 后采纳服务器 echo。无底盘(prefs 从未加载成功)→ 本地生效、不回写。
|
||||
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) 活跃"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
81
ios/App/WebTerm/ViewModels/TimelineViewModel.swift
Normal file
81
ios/App/WebTerm/ViewModels/TimelineViewModel.swift
Normal 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
|
||||
/// digest「展开」tap), 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 digest「展开」entry 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 the「重试」path: 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()))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// 回调只能走 UIApplicationDelegate);SwiftUI 生命周期保证本 `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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Navigation + lifecycle owner: Pairing → SessionList → Terminal
|
||||
/// with the production dependency graph (plan §7 T-iOS-15 step 1).
|
||||
@@ -25,16 +26,25 @@ final class AppCoordinator {
|
||||
/// Add-host pairing VM (sheet from the list header).
|
||||
private(set) var addHostPairingViewModel: PairingViewModel?
|
||||
var isAddHostPresented = false
|
||||
/// T-iOS-26 · Projects sheet(入口在 RootView 的 toolbar —— 不碰
|
||||
/// `SessionListScreen`,该文件 W7 内归 T-iOS-23)。每次呈现新建 VM,
|
||||
/// prefs 每次进入都重新拉取。
|
||||
private(set) var projectsViewModel: ProjectsViewModel?
|
||||
var isProjectsPresented = false
|
||||
|
||||
let sessionList: SessionListViewModel
|
||||
@ObservationIgnored let environment: AppEnvironment
|
||||
/// T-iOS-22 · Deep-link handler; all routing/wiring logic lives in
|
||||
/// DeepLinkRouter.swift (incl. the `makeDeepLinkHandler` extension).
|
||||
@ObservationIgnored private(set) lazy var deepLink: DeepLinkHandler = makeDeepLinkHandler()
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
sessionList = SessionListViewModel(
|
||||
hostStore: environment.hostStore,
|
||||
http: environment.http,
|
||||
clock: ContinuousClock()
|
||||
clock: ContinuousClock(),
|
||||
unreadStore: environment.unreadStore
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,6 +64,7 @@ final class AppCoordinator {
|
||||
if route == .pairing {
|
||||
rootPairingViewModel = makePairingViewModel()
|
||||
}
|
||||
await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22)
|
||||
}
|
||||
|
||||
/// First-run pairing done → move to the list (the paired host is already
|
||||
@@ -83,30 +94,110 @@ final class AppCoordinator {
|
||||
Task { await sessionList.reloadHosts() }
|
||||
}
|
||||
|
||||
// MARK: - Projects (T-iOS-26)
|
||||
|
||||
/// Toolbar 入口(RootView):以当前活跃主机呈现 Projects sheet。
|
||||
func presentProjects() {
|
||||
guard let host = sessionList.activeHost else { return }
|
||||
projectsViewModel = ProjectsViewModel(host: host, http: environment.http)
|
||||
isProjectsPresented = true
|
||||
}
|
||||
|
||||
/// Sheet 消失(打开会话 OR 手动关闭):丢弃 VM。
|
||||
func projectsDismissed() {
|
||||
projectsViewModel = nil
|
||||
}
|
||||
|
||||
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
||||
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
||||
func openProject(_ request: ProjectOpenRequest) {
|
||||
guard terminalController == nil else { return } // one foreground session
|
||||
isProjectsPresented = false
|
||||
projectsViewModel = nil
|
||||
startTerminal(
|
||||
host: request.host, sessionId: nil,
|
||||
spawnCwd: request.cwd, bootstrapInput: request.bootstrapInput
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Terminal open/close
|
||||
|
||||
/// `SessionListScreen.onOpen` (one navigation signal per tap) and the
|
||||
/// "继续上次" banner both land here. `sessionId == nil` = new session.
|
||||
func open(_ request: SessionListViewModel.OpenRequest) {
|
||||
guard terminalController == nil else { return } // one foreground session
|
||||
startTerminal(host: request.host, sessionId: request.sessionId)
|
||||
}
|
||||
|
||||
/// 唯一的 controller 构造点(普通打开与 T-iOS-26 项目内 spawn 共用)。
|
||||
private func startTerminal(
|
||||
host: HostRegistry.Host,
|
||||
sessionId: UUID?,
|
||||
spawnCwd: String? = nil,
|
||||
bootstrapInput: String? = nil
|
||||
) {
|
||||
let controller = TerminalSessionController(
|
||||
host: request.host,
|
||||
sessionId: request.sessionId,
|
||||
host: host,
|
||||
sessionId: sessionId,
|
||||
environment: environment,
|
||||
onPendingChanged: { [weak self] sessionId, pending in
|
||||
self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending)
|
||||
}
|
||||
},
|
||||
onTitleChanged: { [weak self] sessionId, title in
|
||||
// T-iOS-23 · OSC title → list row (already sanitized in the
|
||||
// VM; the list VM sanitizes once more at its own boundary).
|
||||
self?.sessionList.setSessionTitle(sessionId: sessionId, title: title)
|
||||
},
|
||||
spawnCwd: spawnCwd,
|
||||
bootstrapInput: bootstrapInput
|
||||
)
|
||||
terminalController = controller
|
||||
controller.start()
|
||||
}
|
||||
|
||||
/// Back navigation popped the terminal: explicit detach.
|
||||
/// Back navigation popped the terminal: explicit detach. Also the first
|
||||
/// half of every session SWITCH (single live WS invariant, plan §1):
|
||||
/// list back-nav and `openDeepLinkedSession` both close here before the
|
||||
/// next `open` — one engine at a time, always close→open with replay.
|
||||
func closeTerminal() {
|
||||
// T-iOS-23 · leaving = seen: stamp the unread watermark for the
|
||||
// adopted session so output watched in the terminal never relights
|
||||
// the list dot.
|
||||
if let sessionId = terminalController?.terminalViewModel.sessionId {
|
||||
sessionList.markSeen(sessionId: sessionId)
|
||||
}
|
||||
terminalController?.teardown()
|
||||
terminalController = nil
|
||||
}
|
||||
|
||||
// MARK: - "在当前目录开新会话" (T-iOS-29)
|
||||
|
||||
/// 当前终端会话的 cwd。解析次序:/live-sessions 行数据(server-adopted
|
||||
/// id 匹配行,列表 VM 的最近快照)→ controller 的 `spawnCwd`(T-iOS-26
|
||||
/// 项目 fresh-spawn 尚未进列表轮询)→ nil(未知)。服务器数据不可信:
|
||||
/// 非绝对路径按未知处理(ProjectsViewModel 同款纪律;engine 侧还会再验)。
|
||||
var currentTerminalCwd: String? {
|
||||
guard let controller = terminalController else { return nil }
|
||||
let fromRows = controller.terminalViewModel.sessionId.flatMap { id in
|
||||
sessionList.rows.first(where: { $0.id == id })?.info.cwd
|
||||
}
|
||||
let candidate = fromRows ?? controller.spawnCwd
|
||||
return candidate.flatMap { Validation.isAbsoluteCwd($0) ? $0 : nil }
|
||||
}
|
||||
|
||||
/// TerminalScreen 工具栏与 exit 横幅共用动作:在当前会话的 cwd fresh
|
||||
/// spawn(`attach(null, cwd)`,镜像 web tabs.ts `newTab()` M6)。单活
|
||||
/// WS 不变式:先 `closeTerminal()`(旧会话记 last-seen 水位、engine
|
||||
/// detach),再开新 controller。cwd 未知 → 普通新会话;无打开的终端
|
||||
/// → no-op。不注入 bootstrap —— "开新 shell"不是"起 claude"。
|
||||
func openNewSessionInCurrentCwd() {
|
||||
guard let controller = terminalController else { return }
|
||||
let host = controller.host
|
||||
let cwd = currentTerminalCwd
|
||||
closeTerminal()
|
||||
startTerminal(host: host, sessionId: nil, spawnCwd: cwd)
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" (cold start step 5)
|
||||
|
||||
var continueLastSessionId: UUID? {
|
||||
|
||||
@@ -31,6 +31,10 @@ struct AppEnvironment: Sendable {
|
||||
/// over the real transports (two-step: RO GET, then WS attach + guarded
|
||||
/// kill; only runs after the user's explicit confirm, T-iOS-12).
|
||||
let probe: PairingViewModel.Probe
|
||||
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
|
||||
/// `var` + default so the memberwise init stays source-compatible for
|
||||
/// pre-P1 call sites while tests can inject an in-memory fake.
|
||||
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
||||
|
||||
static func production() -> AppEnvironment {
|
||||
let http = URLSessionHTTPTransport()
|
||||
|
||||
@@ -28,12 +28,35 @@ struct RootView: View {
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
coordinator.handleScenePhase(phase)
|
||||
}
|
||||
.onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22
|
||||
.alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) {
|
||||
Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() }
|
||||
} message: {
|
||||
Text(coordinator.deepLink.hintMessage ?? "")
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $coordinator.isAddHostPresented,
|
||||
onDismiss: { coordinator.addHostDismissed() }
|
||||
) {
|
||||
addHostSheet
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $coordinator.isProjectsPresented,
|
||||
onDismiss: { coordinator.projectsDismissed() }
|
||||
) {
|
||||
projectsSheet
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-link hint alert (unknown host / store failure, T-iOS-22).
|
||||
private var deepLinkHintBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.deepLink.hintMessage != nil },
|
||||
set: { presented in
|
||||
guard !presented else { return }
|
||||
coordinator.deepLink.clearHint()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Route switch
|
||||
@@ -66,6 +89,22 @@ struct RootView: View {
|
||||
onAddHost: { coordinator.presentAddHost() }
|
||||
)
|
||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||
// T-iOS-26 · Projects 入口挂在 RootView 层(SessionListScreen 是
|
||||
// T-iOS-23 的 W7 独占文件 —— 列表侧入口整合移交给它)。leading 位,
|
||||
// 避开列表自己的 topBarTrailing hostMenu。
|
||||
.toolbar { projectsToolbarItem }
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder private var projectsToolbarItem: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
coordinator.presentProjects()
|
||||
} label: {
|
||||
Label(RootCopy.projects, systemImage: "folder")
|
||||
}
|
||||
.disabled(coordinator.sessionList.activeHost == nil)
|
||||
.accessibilityIdentifier("sessions.projectsButton")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" highlight (cold start step 5)
|
||||
@@ -99,7 +138,17 @@ struct RootView: View {
|
||||
|
||||
@ViewBuilder private var terminalDestination: some View {
|
||||
if let controller = coordinator.terminalController {
|
||||
TerminalContainerView(controller: controller)
|
||||
TerminalContainerView(
|
||||
controller: controller,
|
||||
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() }
|
||||
)
|
||||
// T-iOS-29 · identity PER CONTROLLER: an in-place session switch
|
||||
// (new-in-cwd, deep link) swaps the controller while the
|
||||
// destination stays presented — without a new identity SwiftUI
|
||||
// keeps the old SwiftTerm UIView and the new ViewModel's output
|
||||
// sink never attaches (makeUIView never re-runs). Also resets the
|
||||
// container's per-session @State (plan-gate dismissal, timeline).
|
||||
.id(controller.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +163,20 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Projects sheet (T-iOS-26)
|
||||
|
||||
/// 自带 NavigationStack:列表 → 详情 → 差异都在 sheet 内 push;
|
||||
/// "在此仓库开新会话" 关掉 sheet 并在根导航推入终端。
|
||||
@ViewBuilder private var projectsSheet: some View {
|
||||
if let viewModel = coordinator.projectsViewModel {
|
||||
NavigationStack {
|
||||
ProjectsScreen(viewModel: viewModel) { request in
|
||||
coordinator.openProject(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum RootMetrics {
|
||||
@@ -123,4 +186,5 @@ private enum RootMetrics {
|
||||
|
||||
private enum RootCopy {
|
||||
static let continueLast = "继续上次会话"
|
||||
static let projects = "项目"
|
||||
}
|
||||
|
||||
@@ -19,15 +19,26 @@ import SwiftUI
|
||||
/// removes the gate server-side → `planGate` goes nil → sheet dismisses.
|
||||
struct TerminalContainerView: View {
|
||||
let controller: TerminalSessionController
|
||||
/// T-iOS-29 · pass-through to TerminalScreen's toolbar/exit-banner
|
||||
/// "在当前目录开新会话" action (RootView supplies the coordinator hop).
|
||||
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
||||
/// Epoch of a plan gate the user swiped away — suppresses re-present for
|
||||
/// THAT gate only; a new epoch re-presents automatically.
|
||||
@State private var dismissedPlanGateEpoch: Int?
|
||||
/// T-iOS-24 (additive) · Non-nil while the full-timeline sheet is up; a
|
||||
/// FRESH VM per presentation (each open re-fetches /events).
|
||||
@State private var timelineViewModel: TimelineViewModel?
|
||||
/// T-iOS-25 (additive) · Quick-reply palette store — per-container over
|
||||
/// `.standard` defaults (non-secret UI prefs, plan §5.3 split).
|
||||
@State private var quickReplyStore = QuickReplyStore()
|
||||
|
||||
private enum Metrics {
|
||||
static let overlaySpacing: CGFloat = 8
|
||||
static let overlayHorizontalPadding: CGFloat = 12
|
||||
/// Clears TerminalScreen's own top-aligned ReconnectBanner zone.
|
||||
static let overlayTopPadding: CGFloat = 52
|
||||
/// Breathing room between the chip row and the keyboard/key-bar edge.
|
||||
static let quickReplyBottomPadding: CGFloat = 8
|
||||
}
|
||||
|
||||
private enum Copy {
|
||||
@@ -35,11 +46,33 @@ struct TerminalContainerView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TerminalScreen(viewModel: controller.terminalViewModel)
|
||||
.id(controller.generation)
|
||||
TerminalScreen(
|
||||
viewModel: controller.terminalViewModel,
|
||||
onNewSessionInCwd: onNewSessionInCwd
|
||||
)
|
||||
.id(controller.generation)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.overlay(alignment: .top) { topOverlays }
|
||||
.overlay(alignment: .bottom) { quickReplyOverlay }
|
||||
.sheet(isPresented: planGateBinding) { planGateSheet }
|
||||
.sheet(item: $timelineViewModel) { TimelineSheet(viewModel: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Quick-reply chips (T-iOS-25, additive)
|
||||
|
||||
/// Chips float at the bottom edge (above the keyboard's safe area) ONLY
|
||||
/// while a gate is waiting — visibility/read-only logic lives in
|
||||
/// `QuickReplyBar.isVisible`, driven by the SAME fan-out branches the two
|
||||
/// VMs already consume (no extra branch needed).
|
||||
@ViewBuilder private var quickReplyOverlay: some View {
|
||||
QuickReplyBar(
|
||||
terminalViewModel: controller.terminalViewModel,
|
||||
gateViewModel: controller.gateViewModel,
|
||||
store: quickReplyStore
|
||||
)
|
||||
.padding(.horizontal, Metrics.overlayHorizontalPadding)
|
||||
.padding(.bottom, Metrics.quickReplyBottomPadding)
|
||||
.animation(.default, value: controller.gateViewModel.currentGate)
|
||||
}
|
||||
|
||||
// MARK: - Digest + tool gate (top stack)
|
||||
@@ -51,7 +84,7 @@ struct TerminalContainerView: View {
|
||||
AwayDigestView(
|
||||
digest: digest,
|
||||
isExpanded: gateViewModel.isDigestExpanded,
|
||||
onExpand: { gateViewModel.expandDigest() },
|
||||
onExpand: { expandDigestAndPresentTimeline() },
|
||||
onDismiss: { gateViewModel.dismissDigest() }
|
||||
)
|
||||
}
|
||||
@@ -73,6 +106,21 @@ struct TerminalContainerView: View {
|
||||
.animation(.default, value: gateViewModel.digest)
|
||||
}
|
||||
|
||||
// MARK: - Timeline drill-down (T-iOS-24, additive)
|
||||
|
||||
/// The digest「展开」affordance does BOTH: inline expansion (which cancels
|
||||
/// the auto-fade, T-iOS-14 semantics — the digest must survive under the
|
||||
/// sheet) and the full-timeline sheet. No adopted sessionId yet →
|
||||
/// `forSession` returns nil → inline expand only (defensive; a digest
|
||||
/// only ever arrives after `attached`).
|
||||
private func expandDigestAndPresentTimeline() {
|
||||
controller.gateViewModel.expandDigest()
|
||||
timelineViewModel = TimelineViewModel.forSession(
|
||||
controller.terminalViewModel.sessionId,
|
||||
source: controller.timelineEventsSource
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Plan gate sheet
|
||||
|
||||
private var hasDismissedPendingPlanGate: Bool {
|
||||
|
||||
@@ -37,9 +37,26 @@ final class TerminalSessionController: Identifiable {
|
||||
private(set) var terminalViewModel: TerminalViewModel
|
||||
private(set) var gateViewModel: GateViewModel
|
||||
|
||||
@ObservationIgnored private let host: HostRegistry.Host
|
||||
/// T-iOS-29 · read-only exposure for the coordinator's new-in-cwd flow
|
||||
/// (the host to reopen against — a controller is host-bound for life).
|
||||
@ObservationIgnored let host: HostRegistry.Host
|
||||
@ObservationIgnored private let environment: AppEnvironment
|
||||
@ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void
|
||||
/// T-iOS-23 · OSC-title outlet (adopted sessionId + SANITIZED title) —
|
||||
/// re-attached to every rebuilt TerminalViewModel so a background→
|
||||
/// foreground rebuild never silently drops the list-title feed.
|
||||
@ObservationIgnored private let onTitleChanged: @MainActor (UUID, String) -> Void
|
||||
/// T-iOS-26 · fresh-spawn 变体:新会话的工作目录(`attach(null, cwd)`)。
|
||||
/// 仅在目标 sessionId 为 nil 时生效 —— 服务器对既有会话忽略 cwd。
|
||||
/// (T-iOS-29 起对 coordinator 只读可见:fresh spawn 未进列表轮询时,
|
||||
/// new-in-cwd 以它为 cwd 回退。)
|
||||
@ObservationIgnored let spawnCwd: String?
|
||||
/// T-iOS-26 · attach 后注入的首条输入(如 `claude\r`)。engine 的
|
||||
/// attach-first 队列语义保证它绝不先于 attach 出线;采纳既有会话 id 后
|
||||
/// 的重开(suspend→resume)不再注入。
|
||||
@ObservationIgnored private let bootstrapInput: String?
|
||||
/// 最近一次 open(+bootstrap) 提交任务 —— 测试屏障(ProjectOpenWiringTests)。
|
||||
@ObservationIgnored private(set) var openTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var engine: SessionEngine
|
||||
@ObservationIgnored private var fanOut: EventFanOut<SessionEvent>
|
||||
@ObservationIgnored private var bridge: SessionActivityBridge
|
||||
@@ -62,11 +79,17 @@ final class TerminalSessionController: Identifiable {
|
||||
host: HostRegistry.Host,
|
||||
sessionId: UUID?,
|
||||
environment: AppEnvironment,
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void,
|
||||
onTitleChanged: @escaping @MainActor (UUID, String) -> Void = { _, _ in },
|
||||
spawnCwd: String? = nil,
|
||||
bootstrapInput: String? = nil
|
||||
) {
|
||||
self.host = host
|
||||
self.environment = environment
|
||||
self.onPendingChanged = onPendingChanged
|
||||
self.onTitleChanged = onTitleChanged
|
||||
self.spawnCwd = spawnCwd
|
||||
self.bootstrapInput = bootstrapInput
|
||||
self.targetSessionId = sessionId
|
||||
let stack = Self.makeStack(
|
||||
host: host, environment: environment, onPendingChanged: onPendingChanged
|
||||
@@ -76,6 +99,7 @@ final class TerminalSessionController: Identifiable {
|
||||
terminalViewModel = stack.terminalViewModel
|
||||
gateViewModel = stack.gateViewModel
|
||||
bridge = stack.bridge
|
||||
terminalViewModel.onTitleChanged = onTitleChanged
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle entry points
|
||||
@@ -86,9 +110,7 @@ final class TerminalSessionController: Identifiable {
|
||||
guard !hasStarted, !isTornDown else { return }
|
||||
hasStarted = true
|
||||
startConsumers()
|
||||
let engine = engine
|
||||
let sessionId = targetSessionId
|
||||
Task { await engine.open(sessionId: sessionId, cwd: nil) }
|
||||
openEngineAtTarget()
|
||||
}
|
||||
|
||||
/// scenePhase → `.background`: clean detach (PTY keeps running).
|
||||
@@ -112,9 +134,23 @@ final class TerminalSessionController: Identifiable {
|
||||
isSuspended = false
|
||||
rebuildStack()
|
||||
startConsumers()
|
||||
openEngineAtTarget()
|
||||
}
|
||||
|
||||
/// (Re)open 当前目标:既有会话 → 裸 attach;fresh spawn(目标 id 为
|
||||
/// nil)→ 带 cwd attach + attach 后注入 bootstrap(T-iOS-26)。suspend
|
||||
/// 前已采纳的 id 走前一分支,bootstrap 不会重放。
|
||||
private func openEngineAtTarget() {
|
||||
let engine = engine
|
||||
let sessionId = targetSessionId
|
||||
Task { await engine.open(sessionId: sessionId, cwd: nil) }
|
||||
let cwd = sessionId == nil ? spawnCwd : nil
|
||||
let bootstrap = sessionId == nil ? bootstrapInput : nil
|
||||
openTask = Task {
|
||||
await engine.open(sessionId: sessionId, cwd: cwd)
|
||||
if let bootstrap {
|
||||
await engine.send(.input(data: bootstrap))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alive-engine `.active` hop: feed the engine the last VALID dims the
|
||||
@@ -136,6 +172,15 @@ final class TerminalSessionController: Identifiable {
|
||||
Task { await engine.close() }
|
||||
}
|
||||
|
||||
// MARK: - Timeline drill-down (T-iOS-24, additive)
|
||||
|
||||
/// Per-host timeline fetcher for the container's `TimelineSheet` — the
|
||||
/// same `APIClient.events` wrapper the engine's away-digest uses; the
|
||||
/// single derivation point stays `AppEnvironment.makeEventsSource`.
|
||||
var timelineEventsSource: @Sendable (UUID) async throws -> [TimelineEvent] {
|
||||
environment.makeEventsSource(endpoint: host.endpoint)
|
||||
}
|
||||
|
||||
// MARK: - Stack assembly
|
||||
|
||||
private struct Stack {
|
||||
@@ -186,6 +231,7 @@ final class TerminalSessionController: Identifiable {
|
||||
terminalViewModel = stack.terminalViewModel
|
||||
gateViewModel = stack.gateViewModel
|
||||
bridge = stack.bridge
|
||||
terminalViewModel.onTitleChanged = onTitleChanged // rebuilt VM re-wired
|
||||
generation += 1 // new SwiftUI identity → fresh SwiftTerm view
|
||||
}
|
||||
|
||||
|
||||
44
ios/App/WebTerm/Wiring/UnreadWatermarkStore.swift
Normal file
44
ios/App/WebTerm/Wiring/UnreadWatermarkStore.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// T-iOS-23 · Persistence seam for `SessionCore.UnreadLedger` watermarks.
|
||||
/// The ledger itself is persistence-agnostic (plan §7) — the App layer wires
|
||||
/// UserDefaults here. NON-SECRET UI state only (plan §5.3 split: Keychain =
|
||||
/// secrets, UserDefaults = prefs), same tier as `LastSessionStore`.
|
||||
protocol UnreadWatermarkStore: Sendable {
|
||||
func load() -> [UUID: Int]
|
||||
func save(_ watermarks: [UUID: Int])
|
||||
}
|
||||
|
||||
/// UserDefaults-backed implementation with an injectable suite for tests
|
||||
/// (mirrors `UserDefaultsLastSessionStore`). `@unchecked Sendable`:
|
||||
/// `UserDefaults` is documented thread-safe and this wrapper adds no mutable
|
||||
/// state of its own.
|
||||
struct UserDefaultsUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable {
|
||||
private static let key = "unreadWatermarks"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func load() -> [UUID: Int] {
|
||||
guard let raw = defaults.dictionary(forKey: Self.key) as? [String: Int] else {
|
||||
return [:]
|
||||
}
|
||||
// Stored data crosses a storage boundary — validate every key; garbage
|
||||
// is dropped, never trusted (two case-variant spellings of one UUID
|
||||
// collapse via max, so this can never crash on duplicates).
|
||||
let pairs = raw.compactMap { key, value in
|
||||
UUID(uuidString: key).map { ($0, value) }
|
||||
}
|
||||
return Dictionary(pairs, uniquingKeysWith: max)
|
||||
}
|
||||
|
||||
func save(_ watermarks: [UUID: Int]) {
|
||||
let plist = Dictionary(
|
||||
uniqueKeysWithValues: watermarks.map { ($0.key.uuidString, $0.value) }
|
||||
)
|
||||
defaults.set(plist, forKey: Self.key)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user