- 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.
372 lines
14 KiB
Swift
372 lines
14 KiB
Swift
import HostRegistry
|
||
import SwiftUI
|
||
import WireProtocol
|
||
|
||
/// T-iOS-13 · Session list screen (merged chooser + dashboard). Pure
|
||
/// presentation over `SessionListViewModel` — polling cadence, badge priority,
|
||
/// staleness, optimistic kill and the navigation signal are all VM logic,
|
||
/// unit-tested in `SessionListViewModelTests`.
|
||
///
|
||
/// UX polish (G1-list): the row is restructured for the 5-second glance —
|
||
/// prominent title, a `StatusBadge` (semantic color + distinct SF Symbol, never
|
||
/// color alone), a monospaced meta line, and telemetry as proper
|
||
/// `TelemetryChip`s. Rows are DS `Card`s; active/exited sessions are split under
|
||
/// `SectionHeader`s (presentation-only — the VM already groups exited last).
|
||
/// Every color/spacing/radius/font/motion comes from `DS.*`.
|
||
///
|
||
/// The T-iOS-15 wiring provides `onOpen` (push `TerminalScreen`, open with
|
||
/// `request.sessionId` — nil = new session) and `onAddHost` (present the
|
||
/// pairing sheet; call `viewModel.reloadHosts()` when it completes).
|
||
struct SessionListScreen: View {
|
||
var viewModel: SessionListViewModel
|
||
/// Navigation hook: fired once per `OpenRequest` (unique id per tap).
|
||
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
|
||
/// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15).
|
||
var onAddHost: () -> Void = {}
|
||
/// C-iOS-3 (HIGH reachability fix) · "设备证书" entry — presents
|
||
/// `ClientCertScreen` (import / rotate the mTLS device cert). The device
|
||
/// cert is a global concern, but the toolbar host-menu is the app's only
|
||
/// settings-like surface and is present in every empty state, so this is the
|
||
/// nav idiom that makes the orphaned screen reachable.
|
||
var onDeviceCert: () -> Void = {}
|
||
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
|
||
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
||
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
||
/// rebuilds; `live()` is cheap (the URLSession is a static shared).
|
||
@State private var thumbnails = SessionThumbnailPipeline.live()
|
||
|
||
var body: some View {
|
||
content
|
||
// Accent is not injected app-wide; scope it here so native chrome
|
||
// (nav bar, bordered controls) picks up the DS indigo. Presentation
|
||
// only — no behavior change.
|
||
.tint(DS.Palette.accent)
|
||
.navigationTitle(ScreenCopy.title)
|
||
.toolbar { hostMenu }
|
||
.onAppear { viewModel.appeared() }
|
||
.onDisappear { viewModel.disappeared() }
|
||
.onChange(of: viewModel.openRequest) { _, request in
|
||
guard let request else { return }
|
||
onOpen(request)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var content: some View {
|
||
switch viewModel.emptyState {
|
||
case .notPaired:
|
||
notPairedView
|
||
case .noSessions:
|
||
noSessionsView
|
||
case nil:
|
||
sessionList
|
||
}
|
||
}
|
||
|
||
// MARK: - List
|
||
|
||
private var sessionList: some View {
|
||
List {
|
||
if let message = viewModel.fetchErrorMessage {
|
||
errorRow(message)
|
||
}
|
||
if let message = viewModel.killErrorMessage {
|
||
errorRow(message)
|
||
}
|
||
|
||
newSessionRow
|
||
|
||
if !activeRows.isEmpty {
|
||
Section {
|
||
ForEach(activeRows) { row in sessionRow(row) }
|
||
} header: {
|
||
SectionHeader(title: ScreenCopy.activeSection)
|
||
}
|
||
}
|
||
if !exitedRows.isEmpty {
|
||
Section {
|
||
ForEach(exitedRows) { row in sessionRow(row) }
|
||
} header: {
|
||
SectionHeader(title: ScreenCopy.exitedSection)
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.refreshable { await viewModel.refresh() }
|
||
}
|
||
|
||
/// Split the VM's (already exited-last) rows into the two visual groups.
|
||
/// Presentation only — no ordering/logic decision lives here.
|
||
private var activeRows: [SessionListViewModel.SessionRow] {
|
||
viewModel.rows.filter { !$0.info.exited }
|
||
}
|
||
|
||
private var exitedRows: [SessionListViewModel.SessionRow] {
|
||
viewModel.rows.filter { $0.info.exited }
|
||
}
|
||
|
||
/// Inviting primary entry — accent-tinted card row.
|
||
private var newSessionRow: some View {
|
||
Button {
|
||
viewModel.requestNewSession()
|
||
} label: {
|
||
NewSessionRow()
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityIdentifier("sessions.newButton")
|
||
.listRowSeparator(.hidden)
|
||
.listRowInsets(rowInsets(vertical: DS.Space.sm8))
|
||
.listRowBackground(Color.clear)
|
||
}
|
||
|
||
private func sessionRow(_ row: SessionListViewModel.SessionRow) -> some View {
|
||
Button {
|
||
viewModel.openSession(id: row.id)
|
||
} label: {
|
||
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.listRowSeparator(.hidden)
|
||
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
|
||
.listRowBackground(Color.clear)
|
||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||
Button(role: .destructive) {
|
||
Task { await viewModel.kill(sessionId: row.id) }
|
||
} label: {
|
||
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Carded-list row inset: full-bleed gutter (`lg16`) + a small vertical gap
|
||
/// so adjacent cards breathe.
|
||
private func rowInsets(vertical: CGFloat) -> EdgeInsets {
|
||
EdgeInsets(
|
||
top: vertical, leading: DS.Space.lg16,
|
||
bottom: vertical, trailing: DS.Space.lg16
|
||
)
|
||
}
|
||
|
||
/// T-iOS-28 · build one row's thumbnail slot. No paired host (defensive —
|
||
/// rows imply a host) → no slot; the request key carries `lastOutputAt`
|
||
/// so unchanged sessions render exactly once (pipeline cache).
|
||
private func thumbnailSlot(
|
||
for row: SessionListViewModel.SessionRow
|
||
) -> SessionThumbnailView? {
|
||
guard let endpoint = viewModel.activeHost?.endpoint else { return nil }
|
||
return SessionThumbnailView(
|
||
request: SessionThumbnailRequest(
|
||
endpoint: endpoint,
|
||
sessionId: row.id,
|
||
lastOutputAt: row.info.lastOutputAt
|
||
),
|
||
pipeline: thumbnails
|
||
)
|
||
}
|
||
|
||
private func errorRow(_ message: String) -> some View {
|
||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.statusStuck)
|
||
.listRowSeparator(.hidden)
|
||
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
|
||
.listRowBackground(Color.clear)
|
||
}
|
||
|
||
// MARK: - Empty states
|
||
|
||
private var notPairedView: some View {
|
||
ContentUnavailableView {
|
||
Label(ScreenCopy.notPairedTitle, systemImage: "personalhotspot")
|
||
} description: {
|
||
Text(ScreenCopy.notPairedHint)
|
||
} actions: {
|
||
Button(ScreenCopy.addHost) { onAddHost() }
|
||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||
}
|
||
}
|
||
|
||
private var noSessionsView: some View {
|
||
ContentUnavailableView {
|
||
Label(ScreenCopy.noSessionsTitle, systemImage: "terminal")
|
||
} description: {
|
||
Text(ScreenCopy.noSessionsHint)
|
||
} actions: {
|
||
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
|
||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||
.accessibilityIdentifier("sessions.newButton")
|
||
}
|
||
}
|
||
|
||
// MARK: - Host-switch header (multi-host from HostStore)
|
||
|
||
private var hostMenu: some ToolbarContent {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Menu {
|
||
ForEach(viewModel.hosts) { host in
|
||
Button {
|
||
Task { await viewModel.selectHost(id: host.id) }
|
||
} label: {
|
||
if host.id == viewModel.activeHost?.id {
|
||
Label(host.name, systemImage: "checkmark")
|
||
} else {
|
||
Text(host.name)
|
||
}
|
||
}
|
||
}
|
||
Divider()
|
||
Button {
|
||
onAddHost()
|
||
} label: {
|
||
Label(ScreenCopy.addHost, systemImage: "plus")
|
||
}
|
||
Button {
|
||
onDeviceCert()
|
||
} label: {
|
||
Label(ScreenCopy.deviceCert, systemImage: "lock.shield")
|
||
}
|
||
.accessibilityIdentifier("sessions.deviceCert")
|
||
} label: {
|
||
Label(
|
||
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
||
systemImage: "desktopcomputer"
|
||
)
|
||
}
|
||
.accessibilityIdentifier("sessions.hostMenu")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Row
|
||
|
||
/// Inviting "新建会话" card — accent icon + accent title on a DS `Card`.
|
||
private struct NewSessionRow: View {
|
||
var body: some View {
|
||
Card(padding: DS.Space.md12) {
|
||
HStack(spacing: DS.Space.md12) {
|
||
Image(systemName: "plus.circle.fill")
|
||
.font(DS.Typography.title)
|
||
.foregroundStyle(DS.Palette.accent)
|
||
Text(ScreenCopy.newSession)
|
||
.font(DS.Typography.headline)
|
||
.foregroundStyle(DS.Palette.accent)
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One session row: `StatusBadge` · title · mono meta · telemetry chips ·
|
||
/// optional live thumbnail — laid out inside a DS `Card`.
|
||
private struct SessionRowView: View {
|
||
let row: SessionListViewModel.SessionRow
|
||
/// T-iOS-28 (additive) · trailing live-preview thumbnail; nil = no slot
|
||
/// (no host / preview-less contexts) and the row lays out as before.
|
||
var thumbnail: SessionThumbnailView?
|
||
|
||
var body: some View {
|
||
Card(padding: DS.Space.md12) {
|
||
HStack(alignment: .top, spacing: DS.Space.md12) {
|
||
// ⚠ pending / exited outrank the raw status (VM decides pending;
|
||
// exited is a row fact) — resolved to a single DisplayStatus so
|
||
// the badge shows color + distinct shape + Chinese VoiceOver.
|
||
StatusBadge(status: displayStatus)
|
||
.padding(.top, DS.Space.xs2)
|
||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||
titleLine
|
||
Text(meta)
|
||
.dsMetaText()
|
||
.lineLimit(1)
|
||
if let telemetry = row.telemetry, !telemetry.isEmpty {
|
||
TelemetryChips(model: telemetry)
|
||
}
|
||
}
|
||
if let thumbnail {
|
||
Spacer(minLength: DS.Space.sm8)
|
||
thumbnail
|
||
}
|
||
}
|
||
}
|
||
.opacity(row.info.exited ? DS.Opacity.exited : 1)
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
|
||
private var titleLine: some View {
|
||
HStack(spacing: DS.Space.sm8) {
|
||
// T-iOS-23: OSC titles are attacker-controlled — already sanitized
|
||
// in the VM, rendered verbatim (no Markdown / LocalizedStringKey
|
||
// interpretation), one line only.
|
||
Text(verbatim: title)
|
||
.font(DS.Typography.headline)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
.lineLimit(1)
|
||
if row.isUnread {
|
||
unreadDot
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
|
||
/// Unread dot (T-iOS-23): output newer than the local last-seen watermark.
|
||
/// Accent (indigo) continues the web selection color — distinct from the
|
||
/// gray/green status shapes.
|
||
private var unreadDot: some View {
|
||
Circle()
|
||
.fill(DS.Palette.accent)
|
||
.frame(width: DS.Space.sm8, height: DS.Space.sm8)
|
||
.accessibilityLabel(ScreenCopy.unreadLabel)
|
||
}
|
||
|
||
/// Resolve the row's status to one `DisplayStatus`. Exited is a terminal
|
||
/// fact (top precedence); otherwise the VM's badge priority stands
|
||
/// (pending ⚠ outranks the live status). No VM logic re-implemented here.
|
||
private var displayStatus: DisplayStatus {
|
||
if row.info.exited { return .exited }
|
||
switch row.indicator {
|
||
case .pendingApproval: return .pendingApproval
|
||
case .status(let status): return DisplayStatus(status)
|
||
}
|
||
}
|
||
|
||
/// Sanitized OSC title first (T-iOS-23, mirrors web autoTitle precedence,
|
||
/// public/tabs.ts:570), else the cwd-derived name. Both are server-supplied
|
||
/// display text (untrusted → verbatim Text only).
|
||
private var title: String {
|
||
if let osc = row.title, !osc.isEmpty { return osc }
|
||
guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory }
|
||
return URL(fileURLWithPath: cwd).lastPathComponent
|
||
}
|
||
|
||
/// Client count + `cols×rows`, rendered mono-tabular via `.dsMetaText()`.
|
||
/// The exited state is conveyed by the badge + section + dimming, so it is
|
||
/// not duplicated here.
|
||
private var meta: String {
|
||
[
|
||
ScreenCopy.clientCount(row.info.clientCount),
|
||
"\(row.info.cols)×\(row.info.rows)",
|
||
].joined(separator: " · ")
|
||
}
|
||
}
|
||
|
||
// MARK: - Screen copy
|
||
|
||
private enum ScreenCopy {
|
||
static let title = "会话"
|
||
static let newSession = "新建会话"
|
||
static let kill = "结束"
|
||
static let addHost = "配对新主机"
|
||
static let deviceCert = "设备证书"
|
||
static let hostMenuFallback = "主机"
|
||
static let notPairedTitle = "还没有配对的主机"
|
||
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
||
static let noSessionsTitle = "主机上没有运行中的会话"
|
||
static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。"
|
||
static let unknownDirectory = "未知目录"
|
||
static let unreadLabel = "有新输出"
|
||
static let activeSection = "运行中"
|
||
static let exitedSection = "已结束"
|
||
|
||
static func clientCount(_ count: Int) -> String {
|
||
"\(count) 台设备在看"
|
||
}
|
||
}
|