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:
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"
|
||||
}
|
||||
Reference in New Issue
Block a user