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

130 lines
4.8 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 {
Circle()
.fill(connection != nil ? Color.green : Color.orange)
.frame(width: 8, height: 8)
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)
connection = conn
await conn.resume(from: conn.scrollback.sizeBytes > 0
? ResumeCursor().lastSeq(for: sessionId)
: nil)
} 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)
}
}