diff --git a/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift b/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift new file mode 100644 index 0000000..1ac2b44 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Represents a saved connection to a ppg serve instance. +struct ServerConnection: Codable, Identifiable, Hashable { + let id: UUID + var name: String + var host: String + var port: Int + var token: String + var ca: String? + var isDefault: Bool + + init(name: String = "My Mac", host: String, port: Int = 7700, token: String, ca: String? = nil, isDefault: Bool = false) { + self.id = UUID() + self.name = name + self.host = host + self.port = port + self.token = token + self.ca = ca + self.isDefault = isDefault + } + + private var usesTLS: Bool { + ca != nil + } + + var baseURL: URL? { + makeURL(scheme: usesTLS ? "https" : "http") + } + + var wsURL: URL? { + makeURL( + scheme: usesTLS ? "wss" : "ws", + path: "/ws", + queryItems: [URLQueryItem(name: "token", value: token)] + ) + } + + var apiURL: URL? { + baseURL?.appendingPathComponent("api") + } + + /// Parse a ppg serve QR code payload. + /// Format: ppg://connect?host=&port=&token=[&ca=] + static func fromQRCode(_ payload: String) -> ServerConnection? { + guard let components = URLComponents(string: payload), + components.scheme?.lowercased() == "ppg", + components.host?.lowercased() == "connect" + else { + return nil + } + + let params = Dictionary( + (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }, + uniquingKeysWith: { _, last in last } + ) + + guard let host = params["host"], isValidHost(host), + let token = params["token"], !token.isEmpty + else { + return nil + } + + let port = params["port"].flatMap(Int.init) ?? 7700 + guard (1...65_535).contains(port) else { return nil } + let ca = params["ca"].flatMap { Data(base64Encoded: $0) != nil ? $0 : nil } + + return ServerConnection( + name: host == "0.0.0.0" ? "Local Mac" : host, + host: host, + port: port, + token: token, + ca: ca + ) + } + + private func makeURL( + scheme: String, + path: String = "", + queryItems: [URLQueryItem] = [] + ) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = port + components.path = path + components.queryItems = queryItems.isEmpty ? nil : queryItems + return components.url + } + + private static func isValidHost(_ host: String) -> Bool { + guard !host.isEmpty, + host.rangeOfCharacter(from: .whitespacesAndNewlines) == nil + else { + return false + } + + var components = URLComponents() + components.scheme = "http" + components.host = host + return components.url != nil + } +} diff --git a/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift b/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift new file mode 100644 index 0000000..a1ff952 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift @@ -0,0 +1,151 @@ +import XCTest +@testable import PPGMobile + +final class ServerConnectionTests: XCTestCase { + + // MARK: - fromQRCode + + func testValidQRCodeParsesCorrectly() { + let qr = "ppg://connect?host=192.168.1.10&port=7700&token=abc123" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertNotNil(conn) + XCTAssertEqual(conn?.host, "192.168.1.10") + XCTAssertEqual(conn?.port, 7700) + XCTAssertEqual(conn?.token, "abc123") + XCTAssertNil(conn?.ca) + } + + func testValidQRCodeWithCAParsesCorrectly() { + // "dGVzdA==" is base64 for "test" + let qr = "ppg://connect?host=myhost&port=8080&token=secret&ca=dGVzdA==" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertNotNil(conn) + XCTAssertEqual(conn?.host, "myhost") + XCTAssertEqual(conn?.port, 8080) + XCTAssertEqual(conn?.token, "secret") + XCTAssertEqual(conn?.ca, "dGVzdA==") + } + + func testMissingHostReturnsNil() { + let qr = "ppg://connect?port=7700&token=abc123" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testEmptyHostReturnsNil() { + let qr = "ppg://connect?host=&port=7700&token=abc123" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testMissingTokenReturnsNil() { + let qr = "ppg://connect?host=myhost&port=7700" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testEmptyTokenReturnsNil() { + let qr = "ppg://connect?host=myhost&port=7700&token=" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testMissingPortDefaultsTo7700() { + let qr = "ppg://connect?host=myhost&token=abc123" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertNotNil(conn) + XCTAssertEqual(conn?.port, 7700) + } + + func testInvalidPortReturnsNil() { + XCTAssertNil(ServerConnection.fromQRCode("ppg://connect?host=myhost&port=0&token=abc123")) + XCTAssertNil(ServerConnection.fromQRCode("ppg://connect?host=myhost&port=70000&token=abc123")) + } + + func testInvalidHostReturnsNil() { + let qr = "ppg://connect?host=my%20host&port=7700&token=abc123" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testWrongSchemeReturnsNil() { + let qr = "http://connect?host=myhost&port=7700&token=abc123" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testWrongHostReturnsNil() { + let qr = "ppg://pair?host=myhost&port=7700&token=abc123" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testNonPPGStringReturnsNil() { + XCTAssertNil(ServerConnection.fromQRCode("https://example.com")) + XCTAssertNil(ServerConnection.fromQRCode("just some text")) + XCTAssertNil(ServerConnection.fromQRCode("")) + } + + func testDuplicateQueryParamsDoNotCrash() { + let qr = "ppg://connect?host=myhost&token=first&token=second&port=7700" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertNotNil(conn) + // Last value wins per uniquingKeysWith + XCTAssertEqual(conn?.token, "second") + } + + func testInvalidBase64CAIsDiscarded() { + let qr = "ppg://connect?host=myhost&port=7700&token=abc&ca=not-valid-base64!!!" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertNotNil(conn) + XCTAssertNil(conn?.ca) + } + + func testLocalhostNameMapping() { + let qr = "ppg://connect?host=0.0.0.0&port=7700&token=abc123" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertEqual(conn?.name, "Local Mac") + } + + func testNonLocalhostUsesHostAsName() { + let qr = "ppg://connect?host=workstation.local&port=7700&token=abc123" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertEqual(conn?.name, "workstation.local") + } + + // MARK: - URL construction + + func testBaseURLUsesHTTPWithoutCA() { + let conn = ServerConnection(host: "myhost", port: 7700, token: "abc") + XCTAssertEqual(conn.baseURL?.absoluteString, "http://myhost:7700") + } + + func testBaseURLUsesHTTPSWithCA() { + let conn = ServerConnection(host: "myhost", port: 7700, token: "abc", ca: "dGVzdA==") + XCTAssertEqual(conn.baseURL?.absoluteString, "https://myhost:7700") + } + + func testWsURLUsesWSSWithCA() { + let conn = ServerConnection(host: "myhost", port: 7700, token: "abc", ca: "dGVzdA==") + XCTAssertEqual(conn.wsURL?.scheme, "wss") + } + + func testWsURLPercentEncodesToken() { + let conn = ServerConnection(host: "myhost", port: 7700, token: "abc+def&ghi=jkl") + guard let url = conn.wsURL else { + XCTFail("Expected wsURL to be generated") + return + } + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let tokenValue = components?.queryItems?.first(where: { $0.name == "token" })?.value + XCTAssertEqual(tokenValue, "abc+def&ghi=jkl") + XCTAssertEqual(components?.queryItems?.count, 1) + } + + func testInvalidHostDoesNotCrashURLBuilding() { + let conn = ServerConnection(host: "bad host", port: 7700, token: "abc") + XCTAssertNil(conn.baseURL) + XCTAssertNil(conn.wsURL) + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift new file mode 100644 index 0000000..4c69ed1 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift @@ -0,0 +1,220 @@ +import SwiftUI +import AVFoundation + +/// QR code scanner for pairing with ppg serve. +/// Scans for ppg://connect URLs and creates a ServerConnection. +struct QRScannerView: View { + let onScan: (ServerConnection) -> Void + @Environment(\.dismiss) private var dismiss + @State private var scannedCode: String? + @State private var scannerResetToken = UUID() + @State private var showError = false + @State private var errorMessage = "" + @State private var permissionDenied = false + + var body: some View { + NavigationStack { + ZStack { + if permissionDenied { + cameraPermissionView + } else { + QRCameraView(onCodeScanned: handleScan) + .id(scannerResetToken) + .ignoresSafeArea() + + scanOverlay + } + } + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .alert("Invalid QR Code", isPresented: $showError) { + Button("OK") { restartScanner() } + } message: { + Text(errorMessage) + } + .task { + await checkCameraPermission() + } + } + } + + private var scanOverlay: some View { + VStack { + Spacer() + + VStack(spacing: 12) { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 48)) + .foregroundStyle(.white) + + Text("Point camera at the QR code shown by `ppg serve`") + .font(.subheadline) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding() + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding() + } + } + + private var cameraPermissionView: some View { + ContentUnavailableView { + Label("Camera Access Required", systemImage: "camera.fill") + } description: { + Text("PPG Mobile needs camera access to scan QR codes for server pairing.") + } actions: { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.borderedProminent) + } + } + + @MainActor + private func checkCameraPermission() async { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + permissionDenied = false + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: .video) + permissionDenied = !granted + case .denied, .restricted: + permissionDenied = true + @unknown default: + permissionDenied = true + } + } + + private func handleScan(_ code: String) { + guard scannedCode == nil else { return } + scannedCode = code + + if let connection = ServerConnection.fromQRCode(code) { + onScan(connection) + } else { + errorMessage = "This QR code doesn't contain a valid ppg server connection.\n\nExpected format: ppg://connect?host=...&port=...&token=..." + showError = true + } + } + + private func restartScanner() { + scannedCode = nil + scannerResetToken = UUID() + } +} + +// MARK: - Camera UIViewRepresentable + +/// UIViewRepresentable wrapper for AVCaptureSession QR code scanning. +/// Manages session lifecycle on appear/disappear and handles preview bounds correctly. +struct QRCameraView: UIViewRepresentable { + let onCodeScanned: (String) -> Void + + func makeUIView(context: Context) -> CameraPreviewView { + let view = CameraPreviewView() + let coordinator = context.coordinator + + let session = AVCaptureSession() + coordinator.session = session + + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device) + else { return view } + + if session.canAddInput(input) { + session.addInput(input) + } + + let output = AVCaptureMetadataOutput() + if session.canAddOutput(output) { + session.addOutput(output) + output.setMetadataObjectsDelegate(coordinator, queue: .main) + output.metadataObjectTypes = [.qr] + } + + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + view.previewLayer = previewLayer + view.layer.addSublayer(previewLayer) + + coordinator.startSession() + + return view + } + + func updateUIView(_ uiView: CameraPreviewView, context: Context) { + uiView.previewLayer?.frame = uiView.bounds + } + + static func dismantleUIView(_ uiView: CameraPreviewView, coordinator: Coordinator) { + coordinator.stopSession() + } + + func makeCoordinator() -> Coordinator { + Coordinator(onCodeScanned: onCodeScanned) + } + + // MARK: - Preview UIView + + /// Custom UIView that keeps the preview layer sized to its bounds. + class CameraPreviewView: UIView { + var previewLayer: AVCaptureVideoPreviewLayer? + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer?.frame = bounds + } + } + + // MARK: - Coordinator + + class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + let onCodeScanned: (String) -> Void + var session: AVCaptureSession? + private var hasScanned = false + + init(onCodeScanned: @escaping (String) -> Void) { + self.onCodeScanned = onCodeScanned + } + + func startSession() { + guard let session, !session.isRunning else { return } + DispatchQueue.global(qos: .userInitiated).async { + session.startRunning() + } + } + + func stopSession() { + guard let session, session.isRunning else { return } + DispatchQueue.global(qos: .userInitiated).async { + session.stopRunning() + } + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard !hasScanned, + let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + object.type == .qr, + let value = object.stringValue + else { return } + + hasScanned = true + stopSession() + onCodeScanned(value) + } + } +} diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..ba5a085 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -7,6 +7,7 @@ import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; import * as tmux from '../core/tmux.js'; +import type { Manifest } from '../types/manifest.js'; vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo', @@ -93,7 +94,7 @@ function createManifest(tmuxWindow = '') { baseBranch: 'main', status: 'active' as const, tmuxWindow, - agents: {} as Record, + agents: {}, createdAt: '2026-02-27T00:00:00.000Z', }, }, @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') { } describe('spawnCommand', () => { - let manifestState = createManifest(); + let manifestState: Manifest = createManifest(); let nextAgent = 1; let nextSession = 1; @@ -137,7 +138,7 @@ describe('spawnCommand', () => { mockedResolveWorktree.mockImplementation((manifest, ref) => (manifest as any).worktrees[ref as string]); mockedUpdateManifest.mockImplementation(async (_projectRoot, updater) => { manifestState = await updater(structuredClone(manifestState)); - return manifestState as any; + return manifestState; }); mockedAgentId.mockImplementation(() => `ag-${nextAgent++}`); mockedSessionId.mockImplementation(() => `session-${nextSession++}`);