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
366 lines
14 KiB
Swift
366 lines
14 KiB
Swift
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"
|
||
}
|