pi-remote-ios/Sources/UI/Terminal/MainTerminalView.swift

224 lines
9.3 KiB
Swift

// MainTerminalView.swift
// Wires SessionConnection TerminalViewController + ModifierBar
// MVP: auto-picks the first session (or creates one named "pi")
import Combine
import SwiftUI
@MainActor
struct MainTerminalView: View {
let credential: SidecarCredential
@State private var terminalVC = TerminalViewController()
@State private var connection: SessionConnection? = nil
@State private var statusText = "Connecting…"
@State private var currentPiState: PiState? = nil
@State private var sessionName = ""
@State private var showSwitcher = false
@State private var showSettings = false // T-2.11
@State private var activeSessionId: String? = nil // T-2.6
@State private var cancellables = Set<AnyCancellable>()
@StateObject private var registry = SessionRegistry() // T-2.6
@EnvironmentObject var appState: AppState
var body: some View {
VStack(spacing: 0) {
// Status bar
StatusBar(
sessionName: sessionName,
connectionStatus: statusText,
piState: $currentPiState,
onSwitcher: { showSwitcher = true },
onUnpair: { appState.unpair() },
onSettings: { showSettings = true } // T-2.11
)
// Terminal
TerminalViewRepresentable(controller: terminalVC)
.ignoresSafeArea(edges: .bottom)
Divider()
// Modifier bar
ModifierBar { frame in
Task {
try? await connection?.send(frame)
}
}
.padding(.vertical, 4)
.background(Color(uiColor: .secondarySystemBackground))
}
.task { await initialBootstrap() }
.task { await registry.refresh(credential: credential) } // T-2.6
.onChange(of: activeSessionId) { _, newId in
guard let newId else { return }
// Avoid reconnect storm if id already matches the current connection.
if connection?.id == newId { return }
Task {
if let oldConn = connection { await oldConn.suspend() }
cancellables.removeAll()
connection = nil
currentPiState = nil
sessionName = ""
terminalVC.feed(data: Data("\u{1B}[H\u{1B}[2J".utf8))
await bootstrap(sessionId: newId)
}
}
.sheet(isPresented: $showSwitcher) { // T-2.6
SessionSwitcher(registry: registry, credential: credential) { session in
activeSessionId = session.id
}
}
.sheet(isPresented: $showSettings) { // T-2.11
SettingsView(credential: credential)
.environmentObject(appState)
}
}
// MARK: - Bootstrap
private func initialBootstrap() async {
statusText = "Looking for sessions…"
do {
let sessionId = try await resolveSession()
await bootstrap(sessionId: sessionId)
} catch {
statusText = "Error: \(error.localizedDescription)"
}
}
private func bootstrap(sessionId: String) async {
// Keep activeSessionId in sync. The .onChange handler guards against
// re-entry via `connection?.id == newId` check (connection is still nil here
// on first call, but we set it below before any further state change).
activeSessionId = sessionId
statusText = "Connecting to \(sessionId)"
let conn = SessionConnection(id: sessionId, credential: credential)
// Wire live ANSI stream terminal
conn.stream
.receive(on: DispatchQueue.main)
.sink { [terminalVC] data in
terminalVC.feed(data: data)
}
.store(in: &cancellables)
// Wire snapshots terminal (already contains ESC[H + ESC[2J prefix)
conn.snapshots
.receive(on: DispatchQueue.main)
.sink { [terminalVC] data in
terminalVC.feed(data: data)
}
.store(in: &cancellables)
// Wire connection state status text
conn.$connectionState
.receive(on: DispatchQueue.main)
.sink { state in
switch state {
case .connected: statusText = "\(sessionId)"
case .connecting: statusText = "Connecting…"
case .disconnected: statusText = "Disconnected"
}
}
.store(in: &cancellables)
// Wire resize callback fires on layout + font changes.
terminalVC.onResize = { [weak conn] cols, rows in
guard let conn else { return }
Task { try? await conn.send(.resize(cols: cols, rows: rows)) }
}
// Wire keyboard input from SwiftTerm sidecar.
terminalVC.onInput = { [weak conn] data in
guard let conn,
let text = String(data: data, encoding: .utf8) else { return }
Task { try? await conn.send(.keys(data: text)) }
}
// On first connection:
// 1. Clear SwiftTerm immediately (removes stale content)
// 2. Send resize so tmux + shell know the real dimensions
// 3. Wait for SIGWINCH to propagate and shell to redraw
// 4. Snapshot the now-stable screen state
conn.$connectionState
.filter { $0 == .connected }
.first()
.receive(on: DispatchQueue.main)
.sink { [weak conn, terminalVC] _ in
// Clear immediately don't show stale/mismatched content.
terminalVC.feed(data: Data("\u{1B}[H\u{1B}[2J".utf8))
Task { @MainActor in
let (cols, rows) = terminalVC.terminalSize
if cols > 0 && rows > 0 {
try? await conn?.send(.resize(cols: cols, rows: rows))
}
// 600 ms: SIGWINCH fish/bash redraws tmux pane settles.
// SSH sessions need extra round-trip time.
try? await Task.sleep(nanoseconds: 600_000_000)
try? await conn?.send(.snapshotRequest)
}
}
.store(in: &cancellables)
// Wire stateEvents currentPiState
conn.stateEvents
.compactMap { event -> PiState? in
if case .state(let s, _, _) = event { return s } else { return nil }
}
.receive(on: DispatchQueue.main)
.sink { state in currentPiState = state }
.store(in: &cancellables)
// Wire stateEvents sessionName
conn.stateEvents
.compactMap { event -> String? in
if case .sessionMeta(let name, _, _) = event { return name } else { return nil }
}
.receive(on: DispatchQueue.main)
.sink { name in sessionName = name }
.store(in: &cancellables)
connection = conn
let lastSeq: UInt64? = conn.scrollback.sizeBytes > 0
? ResumeCursor().lastSeq(for: sessionId)
: nil
await conn.resume(from: lastSeq)
}
// MARK: - Session resolution
/// Returns the first existing session id, or creates one named "pi".
private func resolveSession() async throws -> String {
let url = URL(string: "http://\(credential.host):\(credential.port)/sessions")!
var req = URLRequest(url: url)
req.setValue("Bearer \(credential.bearerToken)", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: req)
struct SessionItem: Decodable { let id: String }
let sessions = (try? JSONDecoder().decode([SessionItem].self, from: data)) ?? []
if let first = sessions.first {
return first.id
}
// No sessions create one
var createReq = URLRequest(url: url)
createReq.httpMethod = "POST"
createReq.setValue("application/json", forHTTPHeaderField: "Content-Type")
createReq.setValue("Bearer \(credential.bearerToken)", forHTTPHeaderField: "Authorization")
createReq.httpBody = try? JSONEncoder().encode(["name": "pi"])
let (createData, _) = try await URLSession.shared.data(for: createReq)
struct Created: Decodable { let id: String }
if let created = try? JSONDecoder().decode(Created.self, from: createData) {
return created.id
}
throw URLError(.cannotParseResponse)
}
}