T-iOS-18 (F-iOS-1..13 walkthrough) + T-iOS-19 (security audit vs built artifacts): both PASS_WITH_FINDINGS, 0 CRITICAL/HIGH; real-device items DEFERRED with manual checklists. Fixes (1 MED + 3 LOW, all closed): - WebTermUITests: the single scripted happy path (manual-entry pair → list → attach → KeyBar ^L → injected held gate via POST /hook/permission → tap Approve → server-side behavior==allow assertion). Green twice locally (fresh + already-paired branches). - ios.yml: ui-test leg (server boot + TEST_RUNNER_ env as PROCESS env) + iOS17-floor leg (loud-skip if runtime absent, hard-fail if present and red); dropped CODE_SIGNING_ALLOWED=NO from app-tests leg (unsigned hosts get -34018 from the real keychain — measured) - URLSessionHTTPTransport defaults to .ephemeral (no disk cache of preview terminal bytes) - TARGETED_DEVICE_FAMILY moved to target level (built plist now UIDeviceFamily [1] only) Final: 306 automated checks green (214 pkg + 76 app + 10 integration + XCUITest); server src/ untouched; P0 complete (19/19 tasks)
239 lines
8.2 KiB
Swift
239 lines
8.2 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`.
|
||
///
|
||
/// 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 = {}
|
||
|
||
var body: some View {
|
||
content
|
||
.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)
|
||
}
|
||
Button {
|
||
viewModel.requestNewSession()
|
||
} label: {
|
||
Label(ScreenCopy.newSession, systemImage: "plus.circle.fill")
|
||
}
|
||
.accessibilityIdentifier("sessions.newButton")
|
||
ForEach(viewModel.rows) { row in
|
||
Button {
|
||
viewModel.openSession(id: row.id)
|
||
} label: {
|
||
SessionRowView(row: row)
|
||
}
|
||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||
Button(role: .destructive) {
|
||
Task { await viewModel.kill(sessionId: row.id) }
|
||
} label: {
|
||
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.refreshable { await viewModel.refresh() }
|
||
}
|
||
|
||
private func errorRow(_ message: String) -> some View {
|
||
Label(message, systemImage: "exclamationmark.triangle")
|
||
.font(.footnote)
|
||
.foregroundStyle(.red)
|
||
}
|
||
|
||
// MARK: - Empty states
|
||
|
||
private var notPairedView: some View {
|
||
ContentUnavailableView {
|
||
Label(ScreenCopy.notPairedTitle, systemImage: "personalhotspot")
|
||
} description: {
|
||
Text(ScreenCopy.notPairedHint)
|
||
} actions: {
|
||
Button(ScreenCopy.addHost) { onAddHost() }
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
|
||
private var noSessionsView: some View {
|
||
ContentUnavailableView {
|
||
Label(ScreenCopy.noSessionsTitle, systemImage: "terminal")
|
||
} description: {
|
||
Text(ScreenCopy.noSessionsHint)
|
||
} actions: {
|
||
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
|
||
.buttonStyle(.borderedProminent)
|
||
.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")
|
||
}
|
||
} label: {
|
||
Label(
|
||
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
||
systemImage: "desktopcomputer"
|
||
)
|
||
}
|
||
.accessibilityIdentifier("sessions.hostMenu")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Row
|
||
|
||
private struct SessionRowView: View {
|
||
let row: SessionListViewModel.SessionRow
|
||
|
||
var body: some View {
|
||
HStack(alignment: .top, spacing: Metrics.rowSpacing) {
|
||
indicator
|
||
.frame(width: Metrics.indicatorWidth)
|
||
VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) {
|
||
Text(title)
|
||
.font(.body)
|
||
.lineLimit(1)
|
||
Text(meta)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
if let telemetry = row.telemetry, !telemetry.isEmpty {
|
||
TelemetryChips(model: telemetry)
|
||
}
|
||
}
|
||
}
|
||
.opacity(row.info.exited ? Metrics.exitedOpacity : 1)
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
|
||
/// ⚠ badge outranks the status dot (VM decides via `indicator`).
|
||
@ViewBuilder private var indicator: some View {
|
||
switch row.indicator {
|
||
case .pendingApproval:
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(.orange)
|
||
.accessibilityLabel(ScreenCopy.pendingBadgeLabel)
|
||
case .status(let status):
|
||
Circle()
|
||
.fill(Self.dotColor(status))
|
||
.frame(width: Metrics.dotSize, height: Metrics.dotSize)
|
||
.padding(.top, Metrics.dotTopPadding)
|
||
.accessibilityLabel(Text(status.rawValue))
|
||
}
|
||
}
|
||
|
||
/// `cwd` is server-supplied display text (untrusted → plain Text only).
|
||
private var title: String {
|
||
guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory }
|
||
return URL(fileURLWithPath: cwd).lastPathComponent
|
||
}
|
||
|
||
private var meta: String {
|
||
var parts = [
|
||
ScreenCopy.clientCount(row.info.clientCount),
|
||
"\(row.info.cols)×\(row.info.rows)",
|
||
]
|
||
if row.info.exited {
|
||
parts.append(ScreenCopy.exitedTag)
|
||
}
|
||
return parts.joined(separator: " · ")
|
||
}
|
||
|
||
private static func dotColor(_ status: ClaudeStatus) -> Color {
|
||
switch status {
|
||
case .working: return .green
|
||
case .waiting: return .orange
|
||
case .idle: return .blue
|
||
case .stuck: return .red
|
||
case .unknown: return .gray
|
||
}
|
||
}
|
||
|
||
private enum Metrics {
|
||
static let rowSpacing: CGFloat = 10
|
||
static let rowInnerSpacing: CGFloat = 3
|
||
static let indicatorWidth: CGFloat = 18
|
||
static let dotSize: CGFloat = 10
|
||
static let dotTopPadding: CGFloat = 5
|
||
static let exitedOpacity = 0.55
|
||
}
|
||
}
|
||
|
||
// MARK: - Screen copy
|
||
|
||
private enum ScreenCopy {
|
||
static let title = "会话"
|
||
static let newSession = "新建会话"
|
||
static let kill = "结束"
|
||
static let addHost = "配对新主机"
|
||
static let hostMenuFallback = "主机"
|
||
static let notPairedTitle = "还没有配对的主机"
|
||
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
||
static let noSessionsTitle = "主机上没有运行中的会话"
|
||
static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。"
|
||
static let unknownDirectory = "未知目录"
|
||
static let exitedTag = "已退出"
|
||
static let pendingBadgeLabel = "等待审批"
|
||
|
||
static func clientCount(_ count: Int) -> String {
|
||
"\(count) 台设备在看"
|
||
}
|
||
}
|