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