Files
web-terminal/ios/App/WebTerm/Components/SessionThumbnail.swift
Yaojia Wang e38e6d1689 feat(ios): device client-cert mTLS — ClientTLS package, transport wiring, install UX (C-iOS)
- ios/Packages/ClientTLS: SecIdentity wrapper, PKCS12 importer (typed errors), keychain store
  (AfterFirstUnlockThisDeviceOnly), pure MutualTLSChallengeResponder truth table, cross-platform
  X.509 DER summary. 14/14 tests.
- Both transports (SessionCore URLSessionTermTransport, App URLSessionHTTPTransport) + SessionThumbnail
  take a lazy @Sendable ()->ClientIdentity? provider: WS resolves per-connect, HTTP per client-cert
  challenge, so a freshly-imported cert applies without an app relaunch. AppEnvironment injects
  { store.loadedIdentityOrNil() }.
- ClientCertScreen (.fileImporter([.pkcs12]) + passphrase -> import -> keychain), reachable via a
  设备证书 entry in SessionListScreen.hostMenu. PairingViewModel gates tunnel-host probes on cert
  presence and re-maps mTLS-reject to a clientCertRejected message.
Verified: ClientTLS 14/14, SessionCore 93/93, xcodegen + xcodebuild BUILD SUCCEEDED.
2026-07-07 09:42:12 +02:00

381 lines
15 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import APIClient
import ClientTLS
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 C-iOS-2
/// mTLS nginx
/// provider keychain MEDIUM
/// =nil
static func live() -> SessionThumbnailPipeline {
let identityStore = KeychainClientIdentityStore()
let transport = URLSessionHTTPTransport(identityProvider: {
identityStore.loadedIdentityOrNil()
})
return SessionThumbnailPipeline(
loader: { request in
try await APIClient(endpoint: request.endpoint, http: transport)
.preview(id: request.sessionId)
},
renderer: { data, cols, rows in
SessionThumbnailRenderer.render(data: data, cols: cols, rows: rows)
}
)
}
/// `.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?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// `SessionThumbnailRenderer.snapshotTargetWidth`=2×88
/// 2x token
private enum Metrics {
static let width: CGFloat = 88
static let height: CGFloat = 56
}
var body: some View {
ZStack {
placeholder
if let snapshot = image?.uiImage {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fill)
.transition(.opacity)
}
}
.frame(width: Metrics.width, height: Metrics.height)
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm8))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.sm8)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
// Reduce Motion
.animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: image)
.accessibilityLabel(SessionThumbnailCopy.thumbnailLabel)
.task(id: request.key) {
image = await pipeline.thumbnail(for: request)
}
}
/// / / + overlay +
/// DS direction: "not a raw black rect"
private var placeholder: some View {
ZStack {
DS.Palette.card
Image(systemName: "terminal")
.imageScale(.large)
.foregroundStyle(DS.Palette.textTertiary)
}
}
}
///
enum SessionThumbnailCopy {
static let thumbnailLabel = "会话画面缩略图"
}
private enum SessionThumbnailLog {
static let subsystem = "com.yaojia.webterm"
static let category = "session-thumbnail"
}