feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push

T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
This commit is contained in:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

View File

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