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] = [] private var barriers: [(target: Int, cont: CheckedContinuation)] = [] 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] = [:] 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" }