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,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 =
/// = nilP1
/// 5s
///
/// - **** spawn SwiftTerm
/// `SessionThumbnailRenderGate`FIFOpermit +
/// 线 `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 KiBsrc/config.ts:46
/// DEFAULT_PREVIEW_BYTESenv `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: - FIFOpermit
/// 线`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 URLSessionpreview
/// 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 nilmin 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"
}