iOS pairing scanner: add capture diagnostics (temporary)

Black preview persists on a real device though the session reports
running, so instrument the capture lifecycle to pinpoint it: logs the
authorization status, the selected device, isRunning/inputs/outputs after
startRunning, and on AVCaptureSessionDidStartRunning the preview layer's
connection (present/active/enabled) plus the view/layer bounds. Also
observes AVCaptureSessionRuntimeError and ...WasInterrupted.

Filterable via os.Logger subsystem com.nucleic.remote / category scanner
(marker "📷"). To be trimmed once the root cause is fixed. Also sets an
explicit .high preset and gates metadataObjectTypes on availability.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nucleic
2026-06-21 16:53:31 -07:00
co-authored by Claude Opus 4.8
parent b5853eee67
commit 783aac7683
@@ -1,6 +1,11 @@
import SwiftUI
import AVFoundation
import NucleicProtocol
import os
/// Filterable scanner diagnostics — in Console.app / Xcode, filter by subsystem
/// `com.nucleic.remote` (category `scanner`) or search the "📷" marker.
private let scannerLog = Logger(subsystem: "com.nucleic.remote", category: "scanner")
/// Why the live camera feed isn't showing, so the scanner never sits on a silent black screen.
enum ScannerCameraState: Equatable {
@@ -127,13 +132,24 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
previewView.previewLayer.session = session
previewView.previewLayer.videoGravity = .resizeAspectFill
let nc = NotificationCenter.default
nc.addObserver(self, selector: #selector(sessionDidStart),
name: .AVCaptureSessionDidStartRunning, object: session)
nc.addObserver(self, selector: #selector(sessionRuntimeError(_:)),
name: .AVCaptureSessionRuntimeError, object: session)
nc.addObserver(self, selector: #selector(sessionInterrupted(_:)),
name: .AVCaptureSessionWasInterrupted, object: session)
// Gate on camera authorization first — without this the prompt may never appear and the
// feed silently fails (and on a denied device it would just stay black).
switch AVCaptureDevice.authorizationStatus(for: .video) {
let status = AVCaptureDevice.authorizationStatus(for: .video)
scannerLog.notice("📷 authorization status=\(status.rawValue, privacy: .public)")
switch status {
case .authorized:
configureSession()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
scannerLog.notice("📷 requestAccess granted=\(granted, privacy: .public)")
granted ? self?.configureSession() : self?.report(.denied)
}
case .denied, .restricted:
@@ -149,27 +165,57 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
sessionQueue.async { [weak self] in
guard let self else { return }
self.session.beginConfiguration()
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
self.session.canAddInput(input) else {
self.session.commitConfiguration(); self.report(.unavailable); return
if self.session.canSetSessionPreset(.high) { self.session.sessionPreset = .high }
guard let device = AVCaptureDevice.default(for: .video) else {
self.session.commitConfiguration()
scannerLog.error("📷 no video device — reporting unavailable")
self.report(.unavailable); return
}
scannerLog.notice("📷 device=\(device.localizedName, privacy: .public)")
guard let input = try? AVCaptureDeviceInput(device: device), self.session.canAddInput(input) else {
self.session.commitConfiguration()
scannerLog.error("📷 cannot create/add camera input")
self.report(.unavailable); return
}
self.session.addInput(input)
let output = AVCaptureMetadataOutput()
guard self.session.canAddOutput(output) else {
self.session.commitConfiguration(); self.report(.unavailable); return
self.session.commitConfiguration()
scannerLog.error("📷 cannot add metadata output")
self.report(.unavailable); return
}
self.session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
self.session.commitConfiguration()
// availableMetadataObjectTypes is only populated after the output is connected.
output.metadataObjectTypes = output.availableMetadataObjectTypes.contains(.qr)
? [.qr] : output.availableMetadataObjectTypes
self.session.startRunning()
scannerLog.notice("📷 startRunning -> isRunning=\(self.session.isRunning, privacy: .public) inputs=\(self.session.inputs.count, privacy: .public) outputs=\(self.session.outputs.count, privacy: .public)")
self.report(.scanning)
}
}
@objc private func sessionDidStart() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let connection = self.previewView.previewLayer.connection
scannerLog.notice("📷 didStartRunning: previewConnection=\(connection != nil, privacy: .public) active=\(connection?.isActive ?? false, privacy: .public) enabled=\(connection?.isEnabled ?? false, privacy: .public) viewBounds=\(self.view.bounds.debugDescription, privacy: .public) layerBounds=\(self.previewView.previewLayer.bounds.debugDescription, privacy: .public)")
}
}
@objc private func sessionRuntimeError(_ note: Notification) {
let error = note.userInfo?[AVCaptureSessionErrorKey] as? NSError
scannerLog.error("📷 runtimeError=\(error?.description ?? "nil", privacy: .public)")
}
@objc private func sessionInterrupted(_ note: Notification) {
let reason = (note.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int) ?? -1
scannerLog.error("📷 sessionInterrupted reason=\(reason, privacy: .public)")
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } }