diff --git a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift index f4db3ff..2d68a78 100644 --- a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift +++ b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift @@ -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() } }