feat: app wiring — ContentView + AppState + MainTerminalView

- AppState: loads credential from Keychain on launch, persists on pair
- ContentView: switches PairingFlowView ↔ MainTerminalView on credential
- PairingFlowView: onDismiss → onSuccess(credential) callback
- MainTerminalView: auto-resolves/creates session, connects WebSocket stream
  to TerminalViewController, ModifierBar wired to SessionConnection.send()
- piRemoteApp: AppState injected as environmentObject
This commit is contained in:
Johannes Merz 2026-05-16 02:48:08 +02:00
parent 333797ea36
commit 480a06981c
6 changed files with 179 additions and 18 deletions

View File

@ -0,0 +1,25 @@
// AppState.swift global app state, credential lifecycle
import SwiftUI
@MainActor
final class AppState: ObservableObject {
static let shared = AppState()
@Published var credential: SidecarCredential? = nil
private init() {
// Try loading persisted credential on launch
credential = try? Keychain.shared.load(key: "piremote.credential")
}
func didPair(credential: SidecarCredential) {
self.credential = credential
try? Keychain.shared.save(credential, key: "piremote.credential")
}
func unpair() {
credential = nil
Keychain.shared.delete(key: "piremote.credential")
}
}

View File

@ -1,22 +1,19 @@
// ContentView.swift root view, switches on pairing state
import SwiftUI
struct ContentView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal")
.imageScale(.large)
.font(.system(size: 60))
.foregroundStyle(.green)
Text("pi remote")
.font(.largeTitle.monospaced())
Text("Phase 2 — in development")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
}
}
@EnvironmentObject var appState: AppState
#Preview {
ContentView()
var body: some View {
Group {
if let credential = appState.credential {
MainTerminalView(credential: credential)
} else {
PairingFlowView { credential in
appState.didPair(credential: credential)
}
}
}
}
}

View File

@ -3,11 +3,13 @@ import UserNotifications
@main
struct piRemoteApp: App {
@StateObject private var appState = AppState.shared
@StateObject private var notificationDelegate = NotificationDelegate.shared
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appState)
.onAppear {
notificationDelegate.setup()
UIApplication.shared.registerForRemoteNotifications()

View File

@ -26,8 +26,8 @@ private struct QRPayload: Sendable {
struct PairingFlowView: View {
/// Called after the credential is saved to Keychain.
var onDismiss: (() -> Void)?
/// Called with the new credential after pairing succeeds.
var onSuccess: ((SidecarCredential) -> Void)?
@State private var state: PairingState = .idle
@ -137,7 +137,7 @@ struct PairingFlowView: View {
.font(.subheadline)
Button("Done") {
onDismiss?()
onSuccess?(credential)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)

View File

@ -0,0 +1,129 @@
// 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)
}
}

View File

@ -10,6 +10,7 @@
05CD861F694B84577A4B5A27 /* PairingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BAF4FBE6CC23FDD9B40040 /* PairingTests.swift */; };
09AC16350B4E83B71B05A9D5 /* ResumeCursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7961BE126AFEEE4B7AA6621 /* ResumeCursor.swift */; };
16095F16FAB72320676A729D /* ResumeCursorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7607FF3804A2602B1C6A05D4 /* ResumeCursorTests.swift */; };
19E584DD72E8F6DE3AF4E77F /* MainTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E4BB86FBFBD80287048C1 /* MainTerminalView.swift */; };
1F353AB548615ECD7D241EF7 /* SessionConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67F95D26CD899B18D07AB0B2 /* SessionConnection.swift */; };
2AA3AC859917D32C1444FC5B /* FrameCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15B6B497329B98A4508D963B /* FrameCodec.swift */; };
2D8C05476A83F5CAB9A55A11 /* SidecarCredential.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3FCCEE1BAA0983D83FC84DD /* SidecarCredential.swift */; };
@ -21,6 +22,7 @@
56096DB64F700FC00C4D58CE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AFF032BC30D513204211ADA5 /* Assets.xcassets */; };
5F82D50C477F47893FADA8CB /* PasteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F776605A4109C047E44A89 /* PasteSheet.swift */; };
5F8F5E6D2D5277CB90FA98A0 /* ThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99FA0A0FD737901834AD5705 /* ThemeTests.swift */; };
734F2FECD358816F695D26CD /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DD6C03615573E339057EBF /* AppState.swift */; };
7936EDE3DC79D02CF66F8863 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AF0B5FBC3ACEC8EF5C3FF12 /* QRScannerView.swift */; };
7BD37B4A99532FD542D21526 /* TerminalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75BE928FA90D8AF2C56615D /* TerminalViewController.swift */; };
873DC9D5342E8F4AF2C5BEE9 /* Starscream in Frameworks */ = {isa = PBXBuildFile; productRef = CCBD990EEA7AD9DCF714DF97 /* Starscream */; };
@ -68,8 +70,10 @@
278215F3FD64C681C55F23A4 /* ModifierStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifierStateTests.swift; sourceTree = "<group>"; };
2E2370A3190FDC144C822FF6 /* piRemote.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = piRemote.app; sourceTree = BUILT_PRODUCTS_DIR; };
39536FD31585716EF30C84C6 /* TerminalViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalViewRepresentable.swift; sourceTree = "<group>"; };
3D4E4BB86FBFBD80287048C1 /* MainTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTerminalView.swift; sourceTree = "<group>"; };
5205F823929F91450C58D4CA /* WebSocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketClient.swift; sourceTree = "<group>"; };
55DAE4BC86AE950146CD7B94 /* REVIEW_NOTES_2.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = REVIEW_NOTES_2.md; sourceTree = "<group>"; };
62DD6C03615573E339057EBF /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
658CB2FCA96A8913B1753B1C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
67F95D26CD899B18D07AB0B2 /* SessionConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionConnection.swift; sourceTree = "<group>"; };
6BDDFB0C0D1D6D6FB490BA8D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
@ -144,6 +148,7 @@
isa = PBXGroup;
children = (
12767F24EC6ECFA77B280A8D /* FontStore.swift */,
3D4E4BB86FBFBD80287048C1 /* MainTerminalView.swift */,
C5B05BBDD469F51657ED89B0 /* TerminalFont.swift */,
E3A7FB4B9C4D2B63B016E11A /* TerminalTheme.swift */,
A75BE928FA90D8AF2C56615D /* TerminalViewController.swift */,
@ -264,6 +269,7 @@
ECEA8716C9698DDD14367AC9 /* App */ = {
isa = PBXGroup;
children = (
62DD6C03615573E339057EBF /* AppState.swift */,
AFF032BC30D513204211ADA5 /* Assets.xcassets */,
188683139B863ED1AC03A1BB /* ContentView.swift */,
658CB2FCA96A8913B1753B1C /* Info.plist */,
@ -396,11 +402,13 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
734F2FECD358816F695D26CD /* AppState.swift in Sources */,
F6C311D17A8DAA4F19464E25 /* ContentView.swift in Sources */,
909A26B85FA298A870E407CD /* DeviceTokenRegistrar.swift in Sources */,
B3809456CF2E96F1B1B862C2 /* FontStore.swift in Sources */,
2AA3AC859917D32C1444FC5B /* FrameCodec.swift in Sources */,
C776D609DB29E5B4C90881F9 /* Keychain.swift in Sources */,
19E584DD72E8F6DE3AF4E77F /* MainTerminalView.swift in Sources */,
E9126D5D059DAD3717FA2398 /* ModifierBar.swift in Sources */,
B8800C5E81FBB0C3CE9C6E7D /* ModifierState.swift in Sources */,
9855E1E1C856E20B339F2A0A /* NotificationDelegate.swift in Sources */,