From c5d3ae4c5f43c993de228a18b43d2e0d8517afd8 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 00:07:59 -0600 Subject: [PATCH 1/3] feat: implement QR scanner with camera permissions and session lifecycle - QRScannerView with AVCaptureSession wrapped in UIViewRepresentable - QR code detection via AVCaptureMetadataOutput for .qr type - Parse ppg://connect?host=...&port=...&ca=...&token=... scheme - Double-scan prevention via hasScanned flag in coordinator - Error alerts for invalid QR codes with expected format hint - CameraPreviewView subclass for proper bounds management via layoutSubviews - Camera permission request handling with denied state UI and Settings link - Session lifecycle: start on create, stop on dismantle via dismantleUIView - ServerConnection updated with optional ca field for TLS certificate pinning --- .../PPGMobile/Models/ServerConnection.swift | 70 ++++++ .../Views/Settings/QRScannerView.swift | 211 ++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 ios/PPGMobile/PPGMobile/Models/ServerConnection.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift diff --git a/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift b/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift new file mode 100644 index 0000000..fa2de60 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift @@ -0,0 +1,70 @@ +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 + } + + var baseURL: URL { + let scheme = ca != nil ? "https" : "http" + return URL(string: "\(scheme)://\(host):\(port)")! + } + + var wsURL: URL { + let scheme = ca != nil ? "wss" : "ws" + return URL(string: "\(scheme)://\(host):\(port)/ws?token=\(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 == "ppg", + components.host == "connect" + else { + return nil + } + + let params = Dictionary( + uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + } + ) + + guard let host = params["host"], !host.isEmpty, + let token = params["token"], !token.isEmpty + else { + return nil + } + + let port = params["port"].flatMap(Int.init) ?? 7700 + let ca = params["ca"] + + return ServerConnection( + name: host == "0.0.0.0" ? "Local Mac" : host, + host: host, + port: port, + token: token, + ca: ca + ) + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift new file mode 100644 index 0000000..690b281 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift @@ -0,0 +1,211 @@ +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 showError = false + @State private var errorMessage = "" + @State private var permissionDenied = false + + var body: some View { + NavigationStack { + ZStack { + if permissionDenied { + cameraPermissionView + } else { + QRCameraView(onCodeScanned: handleScan) + .ignoresSafeArea() + + scanOverlay + } + } + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .alert("Invalid QR Code", isPresented: $showError) { + Button("OK") {} + } 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) + } + } + + 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 + scannedCode = nil + } + } +} + +// 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 } + 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 + session?.stopRunning() + onCodeScanned(value) + } + } +} From 97913e4a853dd3846ee3fd306de15724b347b4e2 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:55:33 -0600 Subject: [PATCH 2/3] fix: address review findings for QR scanner - Percent-encode token in wsURL to prevent malformed URLs with special chars - Use Dictionary(uniquingKeysWith:) to prevent crash on duplicate query params - Validate ca field as base64 before accepting it - Dispatch stopSession to background queue to avoid main thread blocking - Add ServerConnectionTests with comprehensive fromQRCode parser coverage --- .../PPGMobile/Models/ServerConnection.swift | 10 +- .../Models/ServerConnectionTests.swift | 129 ++++++++++++++++++ .../Views/Settings/QRScannerView.swift | 4 +- 3 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift diff --git a/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift b/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift index fa2de60..f53ec7f 100644 --- a/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift +++ b/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift @@ -27,7 +27,8 @@ struct ServerConnection: Codable, Identifiable, Hashable { var wsURL: URL { let scheme = ca != nil ? "wss" : "ws" - return URL(string: "\(scheme)://\(host):\(port)/ws?token=\(token)")! + let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? token + return URL(string: "\(scheme)://\(host):\(port)/ws?token=\(encodedToken)")! } var apiURL: URL { @@ -45,9 +46,10 @@ struct ServerConnection: Codable, Identifiable, Hashable { } let params = Dictionary( - uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + (components.queryItems ?? []).compactMap { item in item.value.map { (item.name, $0) } - } + }, + uniquingKeysWith: { _, last in last } ) guard let host = params["host"], !host.isEmpty, @@ -57,7 +59,7 @@ struct ServerConnection: Codable, Identifiable, Hashable { } let port = params["port"].flatMap(Int.init) ?? 7700 - let ca = params["ca"] + let ca = params["ca"].flatMap { Data(base64Encoded: $0) != nil ? $0 : nil } return ServerConnection( name: host == "0.0.0.0" ? "Local Mac" : host, diff --git a/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift b/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift new file mode 100644 index 0000000..706ea93 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift @@ -0,0 +1,129 @@ +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 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==") + XCTAssertTrue(conn.wsURL.absoluteString.hasPrefix("wss://")) + } + + func testWsURLPercentEncodesToken() { + let conn = ServerConnection(host: "myhost", port: 7700, token: "abc+def&ghi=jkl") + let url = conn.wsURL.absoluteString + XCTAssertFalse(url.contains("abc+def&ghi=jkl")) + XCTAssertTrue(url.contains("token=")) + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift index 690b281..7d7e4c8 100644 --- a/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift @@ -189,7 +189,9 @@ struct QRCameraView: UIViewRepresentable { func stopSession() { guard let session, session.isRunning else { return } - session.stopRunning() + DispatchQueue.global(qos: .userInitiated).async { + session.stopRunning() + } } func metadataOutput( From 81129df231f0851e633474087d8c40736e458d81 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:31:57 -0600 Subject: [PATCH 3/3] Fix QR parsing safety and scanner retry behavior --- .../PPGMobile/Models/ServerConnection.swift | 57 +++++++++++++++---- .../Models/ServerConnectionTests.swift | 34 +++++++++-- .../Views/Settings/QRScannerView.swift | 13 ++++- src/commands/spawn.test.ts | 9 +-- 4 files changed, 88 insertions(+), 25 deletions(-) diff --git a/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift b/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift index f53ec7f..1ac2b44 100644 --- a/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift +++ b/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift @@ -20,27 +20,32 @@ struct ServerConnection: Codable, Identifiable, Hashable { self.isDefault = isDefault } - var baseURL: URL { - let scheme = ca != nil ? "https" : "http" - return URL(string: "\(scheme)://\(host):\(port)")! + private var usesTLS: Bool { + ca != nil } - var wsURL: URL { - let scheme = ca != nil ? "wss" : "ws" - let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? token - return URL(string: "\(scheme)://\(host):\(port)/ws?token=\(encodedToken)")! + var baseURL: URL? { + makeURL(scheme: usesTLS ? "https" : "http") } - var apiURL: URL { - baseURL.appendingPathComponent("api") + 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 == "ppg", - components.host == "connect" + components.scheme?.lowercased() == "ppg", + components.host?.lowercased() == "connect" else { return nil } @@ -52,13 +57,14 @@ struct ServerConnection: Codable, Identifiable, Hashable { uniquingKeysWith: { _, last in last } ) - guard let host = params["host"], !host.isEmpty, + 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( @@ -69,4 +75,31 @@ struct ServerConnection: Codable, Identifiable, Hashable { 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 index 706ea93..a1ff952 100644 --- a/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift +++ b/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift @@ -56,6 +56,16 @@ final class ServerConnectionTests: XCTestCase { 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)) @@ -107,23 +117,35 @@ final class ServerConnectionTests: XCTestCase { func testBaseURLUsesHTTPWithoutCA() { let conn = ServerConnection(host: "myhost", port: 7700, token: "abc") - XCTAssertEqual(conn.baseURL.absoluteString, "http://myhost:7700") + 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") + XCTAssertEqual(conn.baseURL?.absoluteString, "https://myhost:7700") } func testWsURLUsesWSSWithCA() { let conn = ServerConnection(host: "myhost", port: 7700, token: "abc", ca: "dGVzdA==") - XCTAssertTrue(conn.wsURL.absoluteString.hasPrefix("wss://")) + XCTAssertEqual(conn.wsURL?.scheme, "wss") } func testWsURLPercentEncodesToken() { let conn = ServerConnection(host: "myhost", port: 7700, token: "abc+def&ghi=jkl") - let url = conn.wsURL.absoluteString - XCTAssertFalse(url.contains("abc+def&ghi=jkl")) - XCTAssertTrue(url.contains("token=")) + 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 index 7d7e4c8..4c69ed1 100644 --- a/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift @@ -7,6 +7,7 @@ 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 @@ -18,6 +19,7 @@ struct QRScannerView: View { cameraPermissionView } else { QRCameraView(onCodeScanned: handleScan) + .id(scannerResetToken) .ignoresSafeArea() scanOverlay @@ -31,7 +33,7 @@ struct QRScannerView: View { } } .alert("Invalid QR Code", isPresented: $showError) { - Button("OK") {} + Button("OK") { restartScanner() } } message: { Text(errorMessage) } @@ -78,6 +80,7 @@ struct QRScannerView: View { } } + @MainActor private func checkCameraPermission() async { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: @@ -101,9 +104,13 @@ struct QRScannerView: View { } else { errorMessage = "This QR code doesn't contain a valid ppg server connection.\n\nExpected format: ppg://connect?host=...&port=...&token=..." showError = true - scannedCode = nil } } + + private func restartScanner() { + scannedCode = nil + scannerResetToken = UUID() + } } // MARK: - Camera UIViewRepresentable @@ -206,7 +213,7 @@ struct QRCameraView: UIViewRepresentable { else { return } hasScanned = true - session?.stopRunning() + 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++}`);