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:
parent
333797ea36
commit
480a06981c
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,19 @@
|
|||
// ContentView.swift — root view, switches on pairing state
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
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)
|
||||
Group {
|
||||
if let credential = appState.credential {
|
||||
MainTerminalView(credential: credential)
|
||||
} else {
|
||||
PairingFlowView { credential in
|
||||
appState.didPair(credential: credential)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */,
|
||||
|
|
|
|||
Loading…
Reference in New Issue