feat(ios): W3 UI layer + integration CI + ntfy docs

T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM)
T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling
T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field)
T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics
T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed)
T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites)
Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
Yaojia Wang
2026-07-05 00:13:14 +02:00
parent 5c8c87fb7a
commit 5098643355
33 changed files with 5946 additions and 616 deletions

View File

@@ -0,0 +1,351 @@
import APIClient
import Foundation
import HostRegistry
import Observation
import WireProtocol
/// T-iOS-13 · Session list state (merged chooser + dashboard, plan §7):
/// one glance answers "do I need to step in?" status dot / pending badge,
/// telemetry chips with staleness, swipe-to-kill, and the navigation signal
/// into `TerminalScreen`.
///
/// Polling: while the screen is visible, `GET /live-sessions` every
/// `Tunables.listPollInterval` (mirrors public/launcher.ts `REFRESH_MS`),
/// paced on an injected `Clock` (FakeClock in tests zero real waits).
/// `disappeared()` cancels the poll task; the leak test asserts the parked
/// sleeper is reclaimed.
///
/// Documented decisions:
/// - **Pending is an overlay, not a list field**: `LiveSessionInfo`
/// (src/types.ts:246-256) carries NO `pending` that signal only exists on
/// the WS status side-channel (the server's pendingApprovals), exactly like
/// the web dashboard reads it from attached tabs (public/tabs.ts snapshot()).
/// The T-iOS-15 wiring forwards gate/status events into
/// `setPendingApproval`; this VM owns the merge and the badge PRIORITY.
/// - **Kill is optimistic via a hidden-id set**: rows derive from the last
/// fetch minus `hiddenKilledIds`, so rollback restores the exact original
/// position, and a poll racing the DELETE cannot resurrect the row. A 404
/// on DELETE means "already gone" and counts as success.
/// - **A failed poll never wipes the list**: stale rows + explicit copy beat
/// a blank screen on one dropped packet.
@MainActor
@Observable
final class SessionListViewModel {
// MARK: - Row model
/// What the row leads with: the held-approval badge outranks the dot.
enum StatusIndicator: Equatable, Sendable {
case pendingApproval
case status(ClaudeStatus)
/// Mirrors public/tabs.ts:74-80 `claudeIcon`; for a held approval.
var glyph: String {
switch self {
case .pendingApproval: return ""
case .status(.working): return ""
case .status(.waiting): return ""
case .status(.idle): return ""
case .status(.stuck): return ""
case .status(.unknown): return ""
}
}
}
/// Immutable render snapshot of one session (rebuilt wholesale per fetch).
struct SessionRow: Equatable, Identifiable, Sendable {
let info: LiveSessionInfo
let isPendingApproval: Bool
let telemetry: TelemetryChips.Model?
var id: UUID { info.id }
var indicator: StatusIndicator {
isPendingApproval ? .pendingApproval : .status(info.status)
}
}
/// Navigation signal consumed by the T-iOS-15 wiring. `sessionId == nil`
/// = "+ New session" (`attach(null)` downstream). A fresh `id` per request
/// means repeated taps re-fire `onChange` observers.
struct OpenRequest: Equatable, Sendable, Identifiable {
let id: UUID
let host: HostRegistry.Host
let sessionId: UUID?
}
enum EmptyState: Equatable {
/// No hosts in the store pairing is the only next step.
case notPaired
/// Paired + fetched successfully, but nothing is running.
case noSessions
}
// MARK: - Observable state
private(set) var hosts: [HostRegistry.Host] = []
private(set) var activeHost: HostRegistry.Host?
private(set) var rows: [SessionRow] = []
/// Last poll failed (stale rows stay visible). Cleared by the next success.
private(set) var fetchErrorMessage: String?
/// Last kill failed (row already rolled back).
private(set) var killErrorMessage: String?
/// Host store read failed explicit, never a silent empty list.
private(set) var hostsErrorMessage: String?
private(set) var openRequest: OpenRequest?
var emptyState: EmptyState? {
if hasAttemptedHostLoad && hosts.isEmpty { return .notPaired }
if hasLoadedOnce && rows.isEmpty && fetchErrorMessage == nil { return .noSessions }
return nil
}
// MARK: - Dependencies & internal state (not observed)
@ObservationIgnored private let hostStore: any HostStore
@ObservationIgnored private let http: any HTTPTransport
@ObservationIgnored private let clock: any Clock<Duration>
/// Injected time source for telemetry staleness (ms since epoch).
@ObservationIgnored private let nowMs: @Sendable () -> Int
@ObservationIgnored private var pollTask: Task<Void, Never>?
@ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
@ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
@ObservationIgnored private var pendingSessionIds: Set<UUID> = []
@ObservationIgnored private var hasAttemptedHostLoad = false
@ObservationIgnored private var hasLoadedOnce = false
private var apiClient: APIClient? {
activeHost.map { APIClient(endpoint: $0.endpoint, http: http) }
}
init(
hostStore: any HostStore,
http: any HTTPTransport,
clock: any Clock<Duration>,
nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs
) {
self.hostStore = hostStore
self.http = http
self.clock = clock
self.nowMs = nowMs
}
// MARK: - Visibility lifecycle (poll task owner)
/// Screen became visible: load hosts, then poll every `listPollInterval`.
/// Idempotent a second call while polling is a no-op.
func appeared() {
guard pollTask == nil else { return }
pollTask = Task { [weak self] in
await self?.runPollLoop()
}
}
/// Screen left: cancel the poll task (its parked sleep throws
/// `CancellationError` and the loop exits no leaked timer).
func disappeared() {
pollTask?.cancel()
pollTask = nil
releaseFetchWaiters()
}
private func runPollLoop() async {
await reloadHosts()
while !Task.isCancelled {
await refreshOnce()
do {
try await clock.sleep(for: Tunables.listPollInterval, tolerance: nil)
} catch {
return // cancelled while parked the only way sleep throws
}
}
}
// MARK: - Hosts (multi-host header hook)
/// (Re)read the paired hosts. Also called by the T-iOS-15 wiring after the
/// pairing sheet adds one. Keeps `activeHost` if it still exists (picking
/// up renames), else falls back to the first host.
func reloadHosts() async {
do {
hosts = try await hostStore.loadAll()
hostsErrorMessage = nil
} catch {
hosts = []
hostsErrorMessage = SessionListCopy.hostsLoadFailed
}
hasAttemptedHostLoad = true
activeHost = hosts.first(where: { $0.id == activeHost?.id }) ?? hosts.first
}
/// Switch the list to another paired host: reset per-host state and fetch
/// immediately (the poll loop keeps its own cadence).
func selectHost(id: UUID) async {
guard let host = hosts.first(where: { $0.id == id }),
host.id != activeHost?.id else { return }
activeHost = host
latestFetched = []
hiddenKilledIds = []
pendingSessionIds = []
hasLoadedOnce = false
fetchErrorMessage = nil
killErrorMessage = nil
rebuildRows()
await refreshOnce()
}
// MARK: - Fetch (poll tick + pull-to-refresh share one path)
/// Manual refresh (pull-to-refresh).
func refresh() async {
await refreshOnce()
}
private func refreshOnce() async {
defer {
completedFetchCount += 1
resumeFetchWaiters()
}
guard let client = apiClient else { return }
do {
let sessions = try await client.liveSessions()
let fetchedIds = Set(sessions.map(\.id))
latestFetched = sessions
// Prune overlays for sessions the server no longer reports.
hiddenKilledIds = hiddenKilledIds.intersection(fetchedIds)
pendingSessionIds = pendingSessionIds.intersection(fetchedIds)
fetchErrorMessage = nil
hasLoadedOnce = true
} catch {
// Keep the stale rows a blank list on one dropped poll is worse.
fetchErrorMessage = SessionListCopy.fetchFailed(Self.errorDetail(error))
}
rebuildRows()
}
// MARK: - Pending overlay (fed by the WS status side-channel, T-iOS-15)
func setPendingApproval(sessionId: UUID, pending: Bool) {
pendingSessionIds = pending
? pendingSessionIds.union([sessionId])
: pendingSessionIds.subtracting([sessionId])
rebuildRows()
}
// MARK: - Swipe-to-kill (optimistic + rollback)
func kill(sessionId: UUID) async {
guard let client = apiClient,
latestFetched.contains(where: { $0.id == sessionId }) else { return }
killErrorMessage = nil
hiddenKilledIds = hiddenKilledIds.union([sessionId]) // optimistic removal
rebuildRows()
do {
try await client.killSession(id: sessionId)
} catch APIClientError.sessionNotFound {
// Already gone (exited/reaped/killed elsewhere) removal stands.
} catch {
hiddenKilledIds = hiddenKilledIds.subtracting([sessionId]) // rollback
rebuildRows()
killErrorMessage = SessionListCopy.killFailed(Self.errorDetail(error))
}
}
// MARK: - Navigation signals
/// "+ New session" `sessionId: nil` (server spawns a fresh PTY).
func requestNewSession() {
guard let activeHost else { return }
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: nil)
}
/// Open a listed session. Unknown ids are refused at the boundary.
func openSession(id: UUID) {
guard let activeHost, rows.contains(where: { $0.id == id }) else { return }
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: id)
}
// MARK: - Row derivation (immutable rebuild, single owner)
private func rebuildRows() {
let visible = latestFetched.filter { !hiddenKilledIds.contains($0.id) }
let now = nowMs()
rows = Self.groupingExitedLast(visible).map { info in
SessionRow(
info: info,
isPendingApproval: pendingSessionIds.contains(info.id),
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now)
)
}
}
/// Keep the server's order (newest-first, src/session/manager.ts:194) but
/// group `exited:true` at the bottom order preserved inside each group.
static func groupingExitedLast(_ sessions: [LiveSessionInfo]) -> [LiveSessionInfo] {
sessions.filter { !$0.exited } + sessions.filter(\.exited)
}
// MARK: - Error copy helpers
/// `APIClientError` already carries user-facing copy; transport-level
/// errors fall back to their localized description.
private static func errorDetail(_ error: any Error) -> String {
(error as? APIClientError)?.message ?? error.localizedDescription
}
private nonisolated static let millisecondsPerSecond = 1_000.0
nonisolated static func currentTimeMs() -> Int {
Int(Date().timeIntervalSince1970 * millisecondsPerSecond)
}
// MARK: - Deterministic test barriers (same pattern as TerminalViewModel)
/// Completed fetch attempts (successful or not, including no-host ticks).
@ObservationIgnored private(set) var completedFetchCount = 0
private struct CountWaiter {
let target: Int
let continuation: CheckedContinuation<Void, Never>
}
@ObservationIgnored private var fetchWaiters: [CountWaiter] = []
/// Suspends until at least `count` fetches have completed.
func waitUntilFetches(count target: Int) async {
await withCheckedContinuation { continuation in
guard completedFetchCount < target else {
continuation.resume()
return
}
fetchWaiters = fetchWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
private func resumeFetchWaiters() {
let satisfied = fetchWaiters.filter { $0.target <= completedFetchCount }
fetchWaiters = fetchWaiters.filter { $0.target > completedFetchCount }
for waiter in satisfied {
waiter.continuation.resume()
}
}
private func releaseFetchWaiters() {
let all = fetchWaiters
fetchWaiters = []
for waiter in all {
waiter.continuation.resume()
}
}
}
/// User-facing session-list copy (plan §4: explicit errors, actionable ).
enum SessionListCopy {
static let hostsLoadFailed = "读取已配对主机失败,请关闭本页后重进。"
static func fetchFailed(_ detail: String) -> String {
"刷新会话列表失败:\(detail)"
}
static func killFailed(_ detail: String) -> String {
"结束会话失败:\(detail)"
}
}