From cfa823bc13730a5edf8a4f890fc87e1acc5998aa Mon Sep 17 00:00:00 2001 From: Nucleic Date: Sun, 21 Jun 2026 17:16:08 -0700 Subject: [PATCH] iOS pairing scanner: surface "could not start" + Try Again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-device logs proved the camera is being blocked by device state, not the app: every retry showed window=true app=active scene=foregroundActive yet reason-1 (videoDeviceNotAvailableInBackground) persisted for 6s — the signature of iPhone Mirroring / a locked tethered device, where iOS disables the camera (the recurring com.apple.PointerUI log is the Mac pointer driving the phone). Rather than sit on a black screen after retries are exhausted, report a new .couldNotStart state explaining the likely cause (locked / mirrored) with a "Try Again" button that rebuilds the capture session from scratch (via .id(retryToken)). App-side capture code is correct; this is a graceful fallback for an environment that withholds the camera. Co-Authored-By: Claude Opus 4.8 --- .../Views/PairingScannerView.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift index 3a0cf81..99dd3db 100644 --- a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift +++ b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift @@ -12,6 +12,8 @@ enum ScannerCameraState: Equatable { case scanning // feed is live case denied // permission off — needs Settings case unavailable // no camera on this device (e.g. the Simulator) + case couldNotStart // camera exists + authorized but the session won't run + // (iPhone locked / mirrored to a Mac / in use elsewhere) } /// Camera QR scanner (UX_IOS §7). Decodes the Mac's `nucleic://pair?d=…` QR into a @@ -21,12 +23,16 @@ struct PairingScannerView: View { @Environment(\.dismiss) private var dismiss @State private var error: String? @State private var cameraState: ScannerCameraState = .scanning + /// Bumping this rebuilds the scanner controller from scratch (fresh session) — the "Try + /// Again" path after the camera couldn't start. + @State private var retryToken = 0 var body: some View { NavigationStack { ZStack { Color.black.ignoresSafeArea() QRScannerRepresentable(onFound: handle, onState: { cameraState = $0 }) + .id(retryToken) .ignoresSafeArea() VStack { Spacer() @@ -70,6 +76,13 @@ struct PairingScannerView: View { } .buttonStyle(.borderedProminent) } + case .couldNotStart: + VStack(spacing: 12) { + banner("Couldn't start the camera. If your iPhone is locked or being mirrored to a Mac, the camera is blocked — open Nucleic directly on your unlocked iPhone, then try again.", + tint: .black.opacity(0.6)) + Button("Try Again") { cameraState = .scanning; retryToken += 1 } + .buttonStyle(.borderedProminent) + } } } } @@ -259,7 +272,9 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject // is foreground; back off briefly and retry until it sticks (bounded). DispatchQueue.main.async { [weak self] in guard let self, self.startAttempts < 12 else { - scannerLog.error("📷 gave up starting after retries"); return + scannerLog.error("📷 gave up starting after retries") + self?.report(.couldNotStart) + return } self.startAttempts += 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.startSessionIfReady() }