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:
251
ios/App/WebTerm/Components/QuickReply.swift
Normal file
251
ios/App/WebTerm/Components/QuickReply.swift
Normal file
@@ -0,0 +1,251 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-25 · Quick-reply chip row + 常用语面板 (plan §7; behavior mirror of
|
||||
/// `public/quick-reply.ts`, data layer in `QuickReplyStore.swift`).
|
||||
///
|
||||
/// Visibility decision (documented): the plan step says "waiting 状态才浮出".
|
||||
/// On the iOS `SessionEvent` stream the raw `ClaudeStatus` is folded away by
|
||||
/// the engine (`SessionEngine.applyGateFrame` keeps only pending/gate), so the
|
||||
/// stream's ONLY waiting projection is the HELD GATE — a `status:'waiting',
|
||||
/// pending:true` frame surfaces as `.gate(GateState)` and the lift as
|
||||
/// `.gate(nil)`. Chips therefore float while a gate is held AND the terminal
|
||||
/// is live (not exited/failed). Waiting-without-pending (a Notification
|
||||
/// permission_prompt with no held relay) never reaches the stream — accepted
|
||||
/// limitation; the observation point stays the SAME fan-out branches the
|
||||
/// Gate/Terminal VMs already consume (no new SessionCore surface, no extra
|
||||
/// fan-out branch needed).
|
||||
///
|
||||
/// Send path: a chip tap goes through `TerminalViewModel.sendInput` — the one
|
||||
/// ordered send pump — so a rapid double-tap yields two frames in tap order,
|
||||
/// never interleaved, and the read-only guard drops taps on a dead terminal.
|
||||
struct QuickReplyBar: View {
|
||||
enum Copy {
|
||||
static let managePhrases = "管理常用语"
|
||||
}
|
||||
|
||||
private enum Metrics {
|
||||
static let chipSpacing: CGFloat = 6
|
||||
static let rowHorizontalPadding: CGFloat = 8
|
||||
static let rowVerticalPadding: CGFloat = 6
|
||||
}
|
||||
|
||||
let terminalViewModel: TerminalViewModel
|
||||
let gateViewModel: GateViewModel
|
||||
let store: QuickReplyStore
|
||||
|
||||
@State private var isPanelPresented = false
|
||||
|
||||
/// Pure visibility rule (see type doc): held gate = the stream's waiting
|
||||
/// signal; read-only (exited/failed) always hides.
|
||||
static func isVisible(gate: GateState?, isReadOnly: Bool) -> Bool {
|
||||
gate != nil && !isReadOnly
|
||||
}
|
||||
|
||||
/// The production tap path (also exercised directly by tests): compose the
|
||||
/// payload via `QuickReplyPalette` and hand it to the VM's ordered pump.
|
||||
static func send(_ chip: QuickReplyChip, through viewModel: TerminalViewModel) {
|
||||
viewModel.sendInput(QuickReplyPalette.payload(for: chip))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if Self.isVisible(
|
||||
gate: gateViewModel.currentGate,
|
||||
isReadOnly: terminalViewModel.isReadOnly
|
||||
) {
|
||||
chipRow
|
||||
.sheet(isPresented: $isPanelPresented) {
|
||||
QuickReplyPanel(store: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chipRow: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Metrics.chipSpacing) {
|
||||
ForEach(store.allChips) { chip in
|
||||
chipButton(chip)
|
||||
}
|
||||
managePhrasesButton
|
||||
}
|
||||
.padding(.horizontal, Metrics.rowHorizontalPadding)
|
||||
.padding(.vertical, Metrics.rowVerticalPadding)
|
||||
}
|
||||
.background(.thinMaterial, in: Capsule())
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
private func chipButton(_ chip: QuickReplyChip) -> some View {
|
||||
Button {
|
||||
Self.send(chip, through: terminalViewModel)
|
||||
} label: {
|
||||
// Text(verbatim:) — user/label strings render as inert text, never
|
||||
// LocalizedStringKey/Markdown (SEC-L3 mirror; T-iOS-23 convention).
|
||||
Text(verbatim: chip.label)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
private var managePhrasesButton: some View {
|
||||
Button {
|
||||
isPanelPresented = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel(Copy.managePhrases)
|
||||
}
|
||||
}
|
||||
|
||||
/// 常用语面板: user-phrase CRUD + drag reorder (增删改序), presented from the
|
||||
/// chip row's `+`. The add/edit form mirrors the web inline editor: text,
|
||||
/// optional label (defaults to text) and an append-Enter toggle that starts
|
||||
/// on. Tapping an existing row loads it into the form for editing (改).
|
||||
struct QuickReplyPanel: View {
|
||||
enum Copy {
|
||||
static let title = "常用语"
|
||||
static let addSection = "添加新常用语"
|
||||
static let customSection = "自定义常用语"
|
||||
static let textPlaceholder = "要发送的文本"
|
||||
static let labelPlaceholder = "标签(可选,默认同文本)"
|
||||
static let appendEnterToggle = "发送后自动回车"
|
||||
static let addButton = "添加"
|
||||
static let saveButton = "保存修改"
|
||||
static let cancelEditButton = "取消编辑"
|
||||
static let doneButton = "完成"
|
||||
static let emptyState = "还没有自定义常用语"
|
||||
/// Payload preview suffix for append-Enter chips (web: `${text}⏎`).
|
||||
static let enterSuffixSymbol = "⏎"
|
||||
}
|
||||
|
||||
let store: QuickReplyStore
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draftText = ""
|
||||
@State private var draftLabel = ""
|
||||
/// Mirrors the web editor default: `enterCheck.checked = true`.
|
||||
@State private var draftAppendEnter = true
|
||||
/// Non-nil while the form edits an existing chip instead of adding.
|
||||
@State private var editingChipId: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
customSection
|
||||
editorSection
|
||||
}
|
||||
.navigationTitle(Copy.title)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { EditButton() }
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(Copy.doneButton) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom phrases (delete / reorder / tap-to-edit)
|
||||
|
||||
@ViewBuilder private var customSection: some View {
|
||||
Section(Copy.customSection) {
|
||||
if store.userChips.isEmpty {
|
||||
Text(Copy.emptyState)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(store.userChips) { chip in
|
||||
Button {
|
||||
beginEditing(chip)
|
||||
} label: {
|
||||
chipRow(chip)
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
.onDelete { offsets in
|
||||
// Resolve ids FIRST — removing while iterating offsets
|
||||
// would shift indices under us.
|
||||
let ids = offsets.map { store.userChips[$0].id }
|
||||
for id in ids {
|
||||
store.removeChip(id: id)
|
||||
}
|
||||
}
|
||||
.onMove { fromOffsets, toOffset in
|
||||
store.moveChips(fromOffsets: fromOffsets, toOffset: toOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chipRow(_ chip: QuickReplyChip) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
// Text(verbatim:) — stored strings are boundary data (SEC-L3 mirror).
|
||||
Text(verbatim: chip.label)
|
||||
.lineLimit(1)
|
||||
Text(verbatim: chip.appendEnter
|
||||
? chip.text + Copy.enterSuffixSymbol
|
||||
: chip.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add / edit form
|
||||
|
||||
@ViewBuilder private var editorSection: some View {
|
||||
Section(Copy.addSection) {
|
||||
TextField(Copy.textPlaceholder, text: $draftText)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField(Copy.labelPlaceholder, text: $draftLabel)
|
||||
Toggle(Copy.appendEnterToggle, isOn: $draftAppendEnter)
|
||||
Button(editingChipId == nil ? Copy.addButton : Copy.saveButton) {
|
||||
commitDraft()
|
||||
}
|
||||
.disabled(trimmedDraftText.isEmpty)
|
||||
if editingChipId != nil {
|
||||
Button(Copy.cancelEditButton, role: .cancel) {
|
||||
clearDraft()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var trimmedDraftText: String {
|
||||
draftText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func beginEditing(_ chip: QuickReplyChip) {
|
||||
editingChipId = chip.id
|
||||
draftText = chip.text
|
||||
draftLabel = chip.label
|
||||
draftAppendEnter = chip.appendEnter
|
||||
}
|
||||
|
||||
/// Web `onSaveClick` semantics: empty text is a no-op (button is disabled
|
||||
/// anyway — belt and braces), empty label defaults to the text.
|
||||
private func commitDraft() {
|
||||
let text = trimmedDraftText
|
||||
guard !text.isEmpty else { return }
|
||||
let trimmedLabel = draftLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let label = trimmedLabel.isEmpty ? text : trimmedLabel
|
||||
if let id = editingChipId {
|
||||
store.updateChip(id: id, text: text, label: label,
|
||||
appendEnter: draftAppendEnter)
|
||||
} else {
|
||||
store.addChip(text: text, label: label, appendEnter: draftAppendEnter)
|
||||
}
|
||||
clearDraft()
|
||||
}
|
||||
|
||||
private func clearDraft() {
|
||||
editingChipId = nil
|
||||
draftText = ""
|
||||
draftLabel = ""
|
||||
draftAppendEnter = true
|
||||
}
|
||||
}
|
||||
200
ios/App/WebTerm/Components/QuickReplyStore.swift
Normal file
200
ios/App/WebTerm/Components/QuickReplyStore.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SessionCore
|
||||
|
||||
/// T-iOS-25 · Quick-reply data layer: chip model, pure immutable CRUD and the
|
||||
/// UserDefaults-backed palette store — a behavior mirror of
|
||||
/// `public/quick-reply.ts` (Chip / addChip / removeChip / reorderChip /
|
||||
/// updateChip / loadPalette / savePalette).
|
||||
///
|
||||
/// Storage decision (plan §5.3 split): the palette is NON-SECRET UI prefs →
|
||||
/// UserDefaults with an injectable suite (same pattern as
|
||||
/// `UserDefaultsLastSessionStore`), never Keychain.
|
||||
|
||||
/// One sendable snippet (mirror of the web `Chip` interface).
|
||||
struct QuickReplyChip: Sendable, Equatable, Codable, Identifiable {
|
||||
/// Stable identifier (built-ins use the `__` prefix, user chips `user_`).
|
||||
let id: String
|
||||
/// Raw bytes to send (without the Enter suffix).
|
||||
let text: String
|
||||
/// Display label on the chip button. Rendered via `Text(verbatim:)` only —
|
||||
/// the SwiftUI analogue of the web's textContent-never-innerHTML (SEC-L3).
|
||||
let label: String
|
||||
/// If true, Enter (`\r`) is appended to `text` when sending.
|
||||
let appendEnter: Bool
|
||||
}
|
||||
|
||||
/// Pure palette operations — every function returns a NEW array and never
|
||||
/// mutates its input (web quick-reply.ts CRUD, verbatim semantics).
|
||||
enum QuickReplyPalette {
|
||||
/// The six built-in chips for Claude Code interaction, order and values
|
||||
/// mirroring web `BUILT_IN_CHIPS`. Esc bytes resolve through `KeyByteMap`
|
||||
/// — hand-writing an escape sequence in the App layer is a review finding.
|
||||
static let builtInChips: [QuickReplyChip] = [
|
||||
QuickReplyChip(id: "__yes", text: "yes", label: "yes", appendEnter: true),
|
||||
QuickReplyChip(id: "__continue", text: "continue", label: "continue",
|
||||
appendEnter: true),
|
||||
QuickReplyChip(id: "__1", text: "1", label: "1", appendEnter: true),
|
||||
QuickReplyChip(id: "__2", text: "2", label: "2", appendEnter: true),
|
||||
QuickReplyChip(id: "__3", text: "3", label: "3", appendEnter: true),
|
||||
QuickReplyChip(id: "__esc", text: KeyByteMap.bytes(for: .esc), label: "Esc",
|
||||
appendEnter: false),
|
||||
]
|
||||
|
||||
/// Prefix for user-created chip ids (web: `user_<ts>_<rand>`; iOS: UUID).
|
||||
static let userChipIdPrefix = "user_"
|
||||
|
||||
/// The full byte string a chip tap sends: text plus — when `appendEnter` —
|
||||
/// the Enter byte from `KeyByteMap` (`\r`, 0x0D, NOT `\n`; CLAUDE.md gotcha).
|
||||
static func payload(for chip: QuickReplyChip) -> String {
|
||||
chip.text + (chip.appendEnter ? KeyByteMap.bytes(for: .enter) : "")
|
||||
}
|
||||
|
||||
/// New array with `chip` appended (web `addChip`).
|
||||
static func adding(_ chips: [QuickReplyChip], _ chip: QuickReplyChip) -> [QuickReplyChip] {
|
||||
chips + [chip]
|
||||
}
|
||||
|
||||
/// New array with the chip matching `id` removed (web `removeChip`).
|
||||
static func removing(_ chips: [QuickReplyChip], id: String) -> [QuickReplyChip] {
|
||||
chips.filter { $0.id != id }
|
||||
}
|
||||
|
||||
/// New array with the chip at `fromIndex` moved to `toIndex` (web
|
||||
/// `reorderChip` splice semantics). Either index out of bounds → the input
|
||||
/// returned unchanged. Operates on a local copy — value semantics
|
||||
/// guarantee the caller's array is never touched.
|
||||
static func reordering(
|
||||
_ chips: [QuickReplyChip], fromIndex: Int, toIndex: Int
|
||||
) -> [QuickReplyChip] {
|
||||
guard chips.indices.contains(fromIndex), chips.indices.contains(toIndex) else {
|
||||
return chips
|
||||
}
|
||||
var result = chips
|
||||
let moved = result.remove(at: fromIndex)
|
||||
result.insert(moved, at: toIndex)
|
||||
return result
|
||||
}
|
||||
|
||||
/// New array where the chip with `id` has non-nil fields patched (web
|
||||
/// `updateChip`: shallow merge, id immutable). Unknown id → unchanged.
|
||||
static func updating(
|
||||
_ chips: [QuickReplyChip], id: String,
|
||||
text: String? = nil, label: String? = nil, appendEnter: Bool? = nil
|
||||
) -> [QuickReplyChip] {
|
||||
chips.map { chip in
|
||||
guard chip.id == id else { return chip }
|
||||
return QuickReplyChip(
|
||||
id: chip.id,
|
||||
text: text ?? chip.text,
|
||||
label: label ?? chip.label,
|
||||
appendEnter: appendEnter ?? chip.appendEnter
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-item lossy decode wrapper (mirror of the web `filter(isValidChip)`):
|
||||
/// one malformed entry is dropped, the well-formed rest survive — stored data
|
||||
/// crosses a storage boundary and is never trusted (LastSessionStore pattern).
|
||||
private struct LossyQuickReplyChip: Decodable {
|
||||
let chip: QuickReplyChip?
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
chip = try? QuickReplyChip(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable palette store: holds the user chips, persists every change to
|
||||
/// the injected `UserDefaults` and exposes the render list (built-ins first,
|
||||
/// then user chips — web render order).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class QuickReplyStore {
|
||||
/// Single UserDefaults key for the encoded user palette
|
||||
/// (web: localStorage `web-terminal:quick-reply-palette`).
|
||||
static let paletteDefaultsKey = "quickReplyPalette"
|
||||
|
||||
private(set) var userChips: [QuickReplyChip]
|
||||
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
|
||||
/// Built-in chips first, then the user palette (web `render()` order).
|
||||
var allChips: [QuickReplyChip] {
|
||||
QuickReplyPalette.builtInChips + userChips
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
userChips = Self.loadPalette(from: defaults)
|
||||
}
|
||||
|
||||
/// Add a new phrase (web editor `onSaveClick` semantics): text/label are
|
||||
/// trimmed, empty text is a no-op (returns false), empty label defaults to
|
||||
/// the text, the id is minted with the `user_` prefix.
|
||||
@discardableResult
|
||||
func addChip(text: String, label: String, appendEnter: Bool) -> Bool {
|
||||
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedText.isEmpty else { return false }
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let chip = QuickReplyChip(
|
||||
id: QuickReplyPalette.userChipIdPrefix + UUID().uuidString,
|
||||
text: trimmedText,
|
||||
label: trimmedLabel.isEmpty ? trimmedText : trimmedLabel,
|
||||
appendEnter: appendEnter
|
||||
)
|
||||
setUserChips(QuickReplyPalette.adding(userChips, chip))
|
||||
return true
|
||||
}
|
||||
|
||||
func removeChip(id: String) {
|
||||
setUserChips(QuickReplyPalette.removing(userChips, id: id))
|
||||
}
|
||||
|
||||
func moveChip(fromIndex: Int, toIndex: Int) {
|
||||
setUserChips(QuickReplyPalette.reordering(
|
||||
userChips, fromIndex: fromIndex, toIndex: toIndex
|
||||
))
|
||||
}
|
||||
|
||||
/// SwiftUI `List.onMove` adapter (IndexSet + insert-before destination on
|
||||
/// the ORIGINAL array — `Array.move` implements that convention).
|
||||
func moveChips(fromOffsets: IndexSet, toOffset: Int) {
|
||||
var next = userChips
|
||||
next.move(fromOffsets: fromOffsets, toOffset: toOffset)
|
||||
setUserChips(next)
|
||||
}
|
||||
|
||||
func updateChip(
|
||||
id: String, text: String? = nil, label: String? = nil, appendEnter: Bool? = nil
|
||||
) {
|
||||
setUserChips(QuickReplyPalette.updating(
|
||||
userChips, id: id, text: text, label: label, appendEnter: appendEnter
|
||||
))
|
||||
}
|
||||
|
||||
/// Load the persisted palette. Missing key, non-JSON data or a non-array
|
||||
/// root → `[]`; malformed items are dropped per-entry. Never throws
|
||||
/// (web `loadPalette` contract).
|
||||
static func loadPalette(from defaults: UserDefaults) -> [QuickReplyChip] {
|
||||
guard let data = defaults.data(forKey: paletteDefaultsKey) else { return [] }
|
||||
guard let lossy = try? JSONDecoder().decode([LossyQuickReplyChip].self, from: data)
|
||||
else { return [] }
|
||||
return lossy.compactMap(\.chip)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setUserChips(_ chips: [QuickReplyChip]) {
|
||||
userChips = chips
|
||||
persist(chips)
|
||||
}
|
||||
|
||||
/// Best-effort save (web `savePalette` contract: storage failure never
|
||||
/// throws or crashes — the in-memory palette stays authoritative for the
|
||||
/// session). Encoding a `[QuickReplyChip]` cannot practically fail.
|
||||
private func persist(_ chips: [QuickReplyChip]) {
|
||||
guard let data = try? JSONEncoder().encode(chips) else { return }
|
||||
defaults.set(data, forKey: Self.paletteDefaultsKey)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@ struct ReconnectBanner: View {
|
||||
}
|
||||
|
||||
let model: Model
|
||||
/// T-iOS-29 · "开新会话" on the EXITED banner only (session over → the
|
||||
/// natural next step; manager.ts:145-153 — the old session is done for
|
||||
/// good). nil = affordance hidden. Not offered on `.failed`: that copy
|
||||
/// asks the user to change a knob first, not to spawn again.
|
||||
var onNewSession: (@MainActor () -> Void)? = nil
|
||||
|
||||
private enum Metrics {
|
||||
static let contentSpacing: CGFloat = 8
|
||||
@@ -27,6 +32,10 @@ struct ReconnectBanner: View {
|
||||
static let backgroundOpacity = 0.9
|
||||
}
|
||||
|
||||
private enum Copy {
|
||||
static let newSession = "开新会话"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Metrics.contentSpacing) {
|
||||
if isSpinning {
|
||||
@@ -38,6 +47,12 @@ struct ReconnectBanner: View {
|
||||
Text(text)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.leading)
|
||||
if let onNewSession, Self.isNewSessionActionAvailable(for: model) {
|
||||
Button(Copy.newSession) { onNewSession() }
|
||||
.font(.footnote.bold())
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("terminal.exitNewSessionButton")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Metrics.horizontalPadding)
|
||||
.padding(.vertical, Metrics.verticalPadding)
|
||||
@@ -46,6 +61,13 @@ struct ReconnectBanner: View {
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// The new-session affordance appears on the `.exited` banner only —
|
||||
/// pure predicate so the T-iOS-29 tests pin the rule.
|
||||
static func isNewSessionActionAvailable(for model: Model) -> Bool {
|
||||
if case .exited = model { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private var isSpinning: Bool {
|
||||
switch model {
|
||||
case .connecting, .reconnecting: return true
|
||||
|
||||
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"
|
||||
}
|
||||
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