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

194 lines
7.6 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.

// 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 cancellables = Set<AnyCancellable>()
@EnvironmentObject var appState: AppState
@StateObject private var registry = SessionRegistry()
@State private var showSwitcher = false
@State private var activeSessionId: String? = nil
var body: some View {
VStack(spacing: 0) {
// Status bar
HStack {
Text(statusText)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Spacer()
Button { showSwitcher = true } label: {
Image(systemName: "list.bullet")
}
.font(.caption)
Button("Unpair") {
appState.unpair()
}
.font(.caption)
.foregroundStyle(.red)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(uiColor: .systemBackground))
Divider()
// 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 bootstrap() }
.task { await registry.refresh(credential: credential) }
.sheet(isPresented: $showSwitcher) {
SessionSwitcher(
registry: registry,
credential: credential,
onSelect: { session in
activeSessionId = session.id
showSwitcher = false
}
)
}
}
// MARK: - Bootstrap
private func bootstrap() async {
statusText = "Looking for sessions…"
do {
let sessionId = try await resolveSession()
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 dont 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)
connection = conn
let lastSeq: UInt64? = conn.scrollback.sizeBytes > 0
? ResumeCursor().lastSeq(for: sessionId)
: nil
await conn.resume(from: lastSeq)
} catch {
statusText = "Error: \(error.localizedDescription)"
}
}
// 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)
}
}