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,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)
}
}
}