Files
web-terminal/ios/App/WebTerm/Screens/SessionListScreen.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

397 lines
16 KiB
Swift
Raw 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 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 = {}
/// B3 · "" entry presents `EnrollmentScreen` (the zero-`.p12`
/// enrollment flow: login Secure-Enclave key + CSR device cert). Same
/// host-menu surface as `onDeviceCert`; that screen imports a `.p12`, this
/// one obtains a hardware-bound cert with no file at all.
var onEnroll: () -> 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")
}
// C1 · Same sheet as `PairingScreen` is the host
// management surface (access token + ), and it already
// receives the real `HostStore` through its VM. A second entry
// with the management label is what makes those two actions
// discoverable without a second navigation path to maintain.
Button {
onAddHost()
} label: {
Label(ScreenCopy.manageHosts, systemImage: "key")
}
.accessibilityIdentifier("sessions.manageHosts")
Button {
onEnroll()
} label: {
Label(ScreenCopy.enroll, systemImage: "checkmark.shield")
}
.accessibilityIdentifier("sessions.enroll")
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 = "配对新主机"
/// C1 · Access token + (same sheet as `addHost` see the menu).
static let manageHosts = "管理主机与令牌"
static let deviceCert = "设备证书"
static let enroll = "自动获取证书"
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) 台设备在看"
}
}