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

140 lines
5.2 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 cancellables = Set<AnyCancellable>()
@EnvironmentObject var appState: AppState
var body: some View {
VStack(spacing: 0) {
// Status bar
HStack {
Text(statusText)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Spacer()
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() }
}
// 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 stream terminal
conn.stream
.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)) }
}
connection = conn
await conn.resume(from: conn.scrollback.sizeBytes > 0
? ResumeCursor().lastSeq(for: sessionId)
: nil)
// Send the current terminal size immediately after connecting
// so tmux resizes before the first output frame arrives.
let (cols, rows) = terminalVC.terminalSize
if cols > 0 && rows > 0 {
try? await conn.send(.resize(cols: cols, rows: rows))
}
} 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)
}
}