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]>
305 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|