Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift
T
NucleicandClaude Opus 4.8 cc97c26256 Signing: rebrand bundle IDs to xyz.blakeslee.* and correct the Team ID
Desktop channels → xyz.blakeslee.nucleic.desktop.{dev,beta,rc,release} (the stable channel keyword is unchanged; only its bundle-id suffix is "release"). iOS remote → xyz.blakeslee.nucleic.remote, including its Keychain account namespaces, the scanner log subsystem, and the coupled APNS topic.

Correct the Apple Developer Team ID to L7UDTQ6F5W across the Xcode project, ExportOptions, the APNS config + tests, and the signing/cloud docs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-26 16:52:14 -07:00

305 lines
14 KiB
Swift

import SwiftUI
import AVFoundation
import NucleicProtocol
import os
/// Filterable scanner diagnostics — in Console.app / Xcode, filter by subsystem
/// `xyz.blakeslee.nucleic.remote` (category `scanner`) or search the "📷" marker.
private let scannerLog = Logger(subsystem: "xyz.blakeslee.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 {
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
/// `PairingPayload` and hands it back exactly once.
struct PairingScannerView: View {
let onScanned: (PairingPayload) -> Void
@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()
if cameraState == .scanning {
RoundedRectangle(cornerRadius: 16)
.stroke(.white.opacity(0.8), lineWidth: 3)
.frame(width: 240, height: 240)
}
Spacer()
statusBanner
Spacer().frame(height: 40)
}
.padding(.horizontal, 24)
}
.navigationTitle("Scan to pair")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
}
}
@ViewBuilder
private var statusBanner: some View {
if let error {
banner(error, tint: .red.opacity(0.85))
} else {
switch cameraState {
case .scanning:
banner("Point at the QR code on your Mac", tint: .black.opacity(0.5))
case .unavailable:
VStack(spacing: 12) {
banner("The camera isn't available here — pairing needs a real iPhone, not the Simulator.",
tint: .black.opacity(0.6))
}
case .denied:
VStack(spacing: 12) {
banner("Camera access is off. Enable it to scan the pairing code.", tint: .black.opacity(0.6))
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.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)
}
}
}
}
private func banner(_ text: String, tint: Color) -> some View {
Text(text)
.multilineTextAlignment(.center)
.foregroundStyle(.white)
.padding(.horizontal, 16).padding(.vertical, 10)
.background(tint, in: RoundedRectangle(cornerRadius: 16))
}
private func handle(_ value: String) {
guard let payload = try? PairingPayload(qrString: value) else {
error = "That isn't a Nucleic pairing code"; return
}
onScanned(payload)
}
}
/// UIKit bridge for the AVCaptureSession metadata scan.
struct QRScannerRepresentable: UIViewControllerRepresentable {
let onFound: (String) -> Void
let onState: (ScannerCameraState) -> Void
func makeUIViewController(context: Context) -> QRScannerController {
let controller = QRScannerController()
controller.onFound = onFound
controller.onState = onState
return controller
}
func updateUIViewController(_ controller: QRScannerController, context: Context) {}
}
/// A view whose *backing layer* is the camera preview, so it always fills the view through the
/// layer hierarchy — no manual frame bookkeeping. The prior version added the preview as a
/// sublayer and set its frame in viewDidLoad/viewDidLayoutSubviews, which raced with the async
/// permission callback and layout and could leave a zero-sized (black) preview on device.
final class CameraPreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}
final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onFound: ((String) -> Void)?
var onState: ((ScannerCameraState) -> Void)?
private let session = AVCaptureSession()
/// AVCaptureSession isn't thread-safe — all configuration + start/stop happen here; only the
/// preview layer and delegate callbacks touch the main thread.
private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session")
private var configured = false
private var startAttempts = 0
private var didFind = false
private var previewView: CameraPreviewView { view as! CameraPreviewView }
override func loadView() { view = CameraPreviewView() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
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)
// Recover the session once it's allowed to run again (interruption cleared, or the app
// returned to the foreground) — the reason-1 interruption at presentation time left it
// stopped with no retry.
nc.addObserver(self, selector: #selector(startSessionIfReady),
name: .AVCaptureSessionInterruptionEnded, object: session)
nc.addObserver(self, selector: #selector(startSessionIfReady),
name: UIApplication.didBecomeActiveNotification, object: nil)
// 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).
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:
report(.denied)
@unknown default:
report(.denied)
}
}
/// Configure + start the capture graph on the session queue. Reports `.unavailable` when
/// there's no usable camera (e.g. the Simulator) and `.scanning` once frames are flowing.
private func configureSession() {
sessionQueue.async { [weak self] in
guard let self else { return }
self.session.beginConfiguration()
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()
scannerLog.error("📷 cannot add metadata output")
self.report(.unavailable); return
}
self.session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
self.session.commitConfiguration()
// availableMetadataObjectTypes is only populated after the output is connected.
output.metadataObjectTypes = output.availableMetadataObjectTypes.contains(.qr)
? [.qr] : output.availableMetadataObjectTypes
self.configured = true
scannerLog.notice("📷 configured inputs=\(self.session.inputs.count, privacy: .public) outputs=\(self.session.outputs.count, privacy: .public)")
self.startSessionIfReady()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startSessionIfReady()
// The presentation transition can leave the session transiently "backgrounded" even
// though the app is foreground; a short delayed retry catches the settled state if the
// immediate start was interrupted.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in self?.startSessionIfReady() }
}
/// Start the camera only once it can actually run — configured, on screen, and
/// foreground-active. Starting during the sheet's presentation made iOS interrupt the
/// session (`videoDeviceNotAvailableInBackground`, reason 1) and leave it stopped → black.
/// Every start trigger (config finished, view appeared, delayed retry, interruption ended,
/// app foregrounded) funnels here; the session-queue hop no-ops if already running.
@objc private func startSessionIfReady() {
DispatchQueue.main.async { [weak self] in
guard let self, self.isViewLoaded else { return }
let appState = UIApplication.shared.applicationState
let sceneState = self.view.window?.windowScene?.activationState
scannerLog.notice("📷 tryStart window=\(self.view.window != nil, privacy: .public) app=\(appState.rawValue, privacy: .public) scene=\(sceneState?.rawValue ?? -1, privacy: .public)")
guard self.view.window != nil, appState == .active else { return }
self.sessionQueue.async {
guard self.configured, !self.session.isRunning else { return }
self.session.startRunning()
scannerLog.notice("📷 startRunning -> isRunning=\(self.session.isRunning, privacy: .public)")
}
}
}
@objc private func sessionDidStart() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.startAttempts = 0
self.report(.scanning)
let connection = self.previewView.previewLayer.connection
scannerLog.notice("📷 didStartRunning: previewConnection=\(connection != nil, privacy: .public) active=\(connection?.isActive ?? false, privacy: .public) viewBounds=\(self.view.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)")
startSessionIfReady() // e.g. media-services reset — try to bring it back
}
@objc private func sessionInterrupted(_ note: Notification) {
let reason = (note.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int) ?? -1
scannerLog.error("📷 sessionInterrupted reason=\(reason, privacy: .public)")
// Reason 1 fires transiently while the sheet's presentation settles even though the app
// 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")
self?.report(.couldNotStart)
return
}
self.startAttempts += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.startSessionIfReady() }
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } }
}
private func report(_ state: ScannerCameraState) {
DispatchQueue.main.async { [weak self] in self?.onState?(state) }
}
func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
guard !didFind,
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let value = object.stringValue else { return }
didFind = true
onFound?(value)
}
}