From 480a06981c535afb4cb7bbe950e0398cd3f74e96 Mon Sep 17 00:00:00 2001 From: jay Date: Sat, 16 May 2026 02:48:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20app=20wiring=20=E2=80=94=20ContentView?= =?UTF-8?q?=20+=20AppState=20+=20MainTerminalView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Sources/App/AppState.swift | 25 ++++ Sources/App/ContentView.swift | 27 ++--- Sources/App/piRemoteApp.swift | 2 + Sources/UI/Pairing/PairingFlowView.swift | 6 +- Sources/UI/Terminal/MainTerminalView.swift | 129 +++++++++++++++++++++ piRemote.xcodeproj/project.pbxproj | 8 ++ 6 files changed, 179 insertions(+), 18 deletions(-) create mode 100644 Sources/App/AppState.swift create mode 100644 Sources/UI/Terminal/MainTerminalView.swift diff --git a/Sources/App/AppState.swift b/Sources/App/AppState.swift new file mode 100644 index 0000000..9caf945 --- /dev/null +++ b/Sources/App/AppState.swift @@ -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") + } +} diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index e1d0fab..de57dfa 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -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() -} diff --git a/Sources/App/piRemoteApp.swift b/Sources/App/piRemoteApp.swift index 70f9c42..25c4d0a 100644 --- a/Sources/App/piRemoteApp.swift +++ b/Sources/App/piRemoteApp.swift @@ -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() diff --git a/Sources/UI/Pairing/PairingFlowView.swift b/Sources/UI/Pairing/PairingFlowView.swift index e8b8caf..12891d7 100644 --- a/Sources/UI/Pairing/PairingFlowView.swift +++ b/Sources/UI/Pairing/PairingFlowView.swift @@ -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) diff --git a/Sources/UI/Terminal/MainTerminalView.swift b/Sources/UI/Terminal/MainTerminalView.swift new file mode 100644 index 0000000..3c3fda9 --- /dev/null +++ b/Sources/UI/Terminal/MainTerminalView.swift @@ -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() + @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) + } +} diff --git a/piRemote.xcodeproj/project.pbxproj b/piRemote.xcodeproj/project.pbxproj index f29c9ae..d1b77b0 100644 --- a/piRemote.xcodeproj/project.pbxproj +++ b/piRemote.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; + 3D4E4BB86FBFBD80287048C1 /* MainTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTerminalView.swift; sourceTree = ""; }; 5205F823929F91450C58D4CA /* WebSocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketClient.swift; sourceTree = ""; }; 55DAE4BC86AE950146CD7B94 /* REVIEW_NOTES_2.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = REVIEW_NOTES_2.md; sourceTree = ""; }; + 62DD6C03615573E339057EBF /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 658CB2FCA96A8913B1753B1C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 67F95D26CD899B18D07AB0B2 /* SessionConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionConnection.swift; sourceTree = ""; }; 6BDDFB0C0D1D6D6FB490BA8D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -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 */,