From 8f9489790dc91284dc0066c2a27c91f17c3a5e6d Mon Sep 17 00:00:00 2001
From: Felix-Ayush <67006255+Ayush7614@users.noreply.github.com>
Date: Sat, 1 Aug 2026 14:33:48 +0530
Subject: [PATCH] Add macOS camera QR scanning for peer verification (#1477)
* Add macOS camera QR scanning for peer verification.
Mac verification previously only supported paste/validate. Reuse the same AVCapture metadata pipeline as iOS, keep paste as a fallback, and extend the scanner smoke test to macOS.
* Grant sandboxed macOS camera access for QR scanning.
NSCameraUsageDescription alone is not enough under App Sandbox. Add com.apple.security.device.camera so the new macOS AVCapture QR path can open the camera after user permission.
* Check camera auth before capture input for QR scanning.
Consult AVCaptureDevice.authorizationStatus before creating AVCaptureDeviceInput so smoke tests and cold launches do not trigger TCC prompts, gate the smoke test on prior authorization, and show a one-line hint when the camera is unavailable.
---
bitchat/Localizable.xcstrings | 186 +++++++++++++++
bitchat/Views/VerificationViews.swift | 329 +++++++++++++++++---------
bitchat/bitchat-macOS.entitlements | 2 +
bitchatTests/ViewSmokeTests.swift | 21 +-
4 files changed, 425 insertions(+), 113 deletions(-)
diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings
index f34ed351..8abd2d9a 100644
--- a/bitchat/Localizable.xcstrings
+++ b/bitchat/Localizable.xcstrings
@@ -74196,6 +74196,192 @@
}
}
},
+ "verification.scan.camera_unavailable" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "الكاميرا غير متاحة — الصق رمز QR أدناه."
+ }
+ },
+ "bn" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera unavailable — paste a QR below."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kamera nicht verfügbar — QR unten einfügen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera unavailable — paste a QR below."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cámara no disponible — pega un QR abajo."
+ }
+ },
+ "fa" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera unavailable — paste a QR below."
+ }
+ },
+ "fil" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera unavailable — paste a QR below."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Caméra indisponible — collez un QR ci-dessous."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "המצלמה אינה זמינה — הדבק QR למטה."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "कैमरा उपलब्ध नहीं — नीचे QR पेस्ट करें।"
+ }
+ },
+ "id" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kamera tidak tersedia — tempel QR di bawah."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fotocamera non disponibile — incolla un QR sotto."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "カメラを利用できません — 下にQRを貼り付けてください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "카메라를 사용할 수 없습니다 — 아래에 QR을 붙여넣으세요."
+ }
+ },
+ "ms" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera unavailable — paste a QR below."
+ }
+ },
+ "ne" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera unavailable — paste a QR below."
+ }
+ },
+ "nl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera niet beschikbaar — plak hieronder een QR."
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aparat niedostępny — wklej QR poniżej."
+ }
+ },
+ "pt" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Câmara indisponível — cole um QR abaixo."
+ }
+ },
+ "pt-BR" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Câmera indisponível — cole um QR abaixo."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Камера недоступна — вставьте QR ниже."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kamera otillgänglig — klistra in en QR nedan."
+ }
+ },
+ "ta" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera unavailable — paste a QR below."
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera unavailable — paste a QR below."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kamera kullanılamıyor — aşağıya bir QR yapıştırın."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Камера недоступна — вставте QR нижче."
+ }
+ },
+ "ur" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera unavailable — paste a QR below."
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Không dùng được camera — dán mã QR bên dưới."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法使用相机 — 请在下方粘贴二维码。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "無法使用相機 — 請在下方貼上 QR。"
+ }
+ }
+ }
+ },
+
"verification.scan.paste_prompt" : {
"extractionState" : "manual",
"localizations" : {
diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift
index 94aaf211..a3f9d82b 100644
--- a/bitchat/Views/VerificationViews.swift
+++ b/bitchat/Views/VerificationViews.swift
@@ -1,6 +1,7 @@
import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins
+import AVFoundation
#if os(iOS)
import UIKit
#else
@@ -109,19 +110,27 @@ struct ImageWrapper: View {
}
}
-/// Placeholder scanner UI; real camera scanning will be added later.
+/// Peer verification QR scanner. Uses the camera on iOS and macOS; macOS also
+/// keeps a paste/validate fallback for machines without a usable camera.
struct QRScanView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@ThemedPalette private var palette
var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = ""
- @State private var result: String = "" // not shown for iOS scanner
+ @State private var result: String = ""
@State private var lastValid: String = ""
+ @State private var cameraUnavailable = false
+
private enum Strings {
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
static let validate: LocalizedStringKey = "verification.scan.validate"
+ static let cameraUnavailable = String(
+ localized: "verification.scan.camera_unavailable",
+ defaultValue: "Camera unavailable — paste a QR below.",
+ comment: "Shown over the scanner preview when no camera is available or permission was denied"
+ )
static func requested(_ nickname: String) -> String {
String(
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
@@ -135,69 +144,83 @@ struct QRScanView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
- #if os(iOS)
- CameraScannerView(isActive: isActive) { code in
- // Deduplicate: ignore if we just processed this exact QR code
- guard code != lastValid else { return }
-
- switch verificationModel.verifyScannedPayload(code) {
- case .requested:
- // Successfully initiated verification; remember this QR to prevent re-scanning
- lastValid = code
- // Close scanner and return to "My QR" view
- onSuccess?()
- case .notFound, .invalid:
- // Ignore invalid/no-match reads and keep scanning
- break
+ ZStack {
+ CameraScannerView(isActive: isActive, onUnavailable: { cameraUnavailable = true }) { code in
+ handleScannedCode(code, announceResult: false)
+ }
+ if cameraUnavailable {
+ Text(Strings.cameraUnavailable)
+ .bitchatFont(size: 13, weight: .medium)
+ .foregroundColor(palette.secondary)
+ .multilineTextAlignment(.center)
+ .padding(16)
}
}
.frame(height: 260)
.clipShape(RoundedRectangle(cornerRadius: 8))
- #else
+
+ #if os(macOS)
Text(Strings.pastePrompt)
.bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input)
.frame(height: 100)
.border(palette.secondary.opacity(0.4))
Button(Strings.validate) {
- // Deduplicate: ignore if we just processed this exact QR
- guard input != lastValid else {
- result = Strings.requested("") // Already processed
- return
- }
-
- switch verificationModel.verifyScannedPayload(input) {
- case .requested(let nickname):
- result = Strings.requested(nickname)
- lastValid = input
- // Close scanner and return to "My QR" view
- onSuccess?()
- case .notFound:
- result = Strings.notFound
- case .invalid:
- result = Strings.invalid
- }
+ handleScannedCode(input, announceResult: true)
}
.buttonStyle(.bordered)
+ if !result.isEmpty {
+ Text(result)
+ .bitchatFont(size: 12)
+ .foregroundColor(palette.secondary)
+ }
#endif
- // No status text under camera per design
Spacer()
}
.padding()
}
+
+ private func handleScannedCode(_ code: String, announceResult: Bool) {
+ guard code != lastValid else {
+ if announceResult {
+ result = Strings.requested("")
+ }
+ return
+ }
+
+ switch verificationModel.verifyScannedPayload(code) {
+ case .requested(let nickname):
+ lastValid = code
+ if announceResult {
+ result = Strings.requested(nickname)
+ }
+ onSuccess?()
+ case .notFound:
+ if announceResult {
+ result = Strings.notFound
+ }
+ case .invalid:
+ if announceResult {
+ result = Strings.invalid
+ }
+ }
+ }
}
#if os(iOS)
-import AVFoundation
-
struct CameraScannerView: UIViewRepresentable {
typealias UIViewType = PreviewView
var isActive: Bool
+ var onUnavailable: (() -> Void)? = nil
var onCode: (String) -> Void
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
- context.coordinator.setup(sessionOwner: view, onCode: onCode)
+ context.coordinator.setup(
+ previewLayer: view.videoPreviewLayer,
+ onCode: onCode,
+ onUnavailable: onUnavailable
+ )
context.coordinator.setActive(isActive)
return view
}
@@ -206,68 +229,7 @@ struct CameraScannerView: UIViewRepresentable {
context.coordinator.setActive(isActive)
}
- func makeCoordinator() -> Coordinator { Coordinator() }
-
- final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
- private var onCode: ((String) -> Void)?
- private weak var owner: PreviewView?
- private let session = AVCaptureSession()
- private var isRunning = false
- private var permissionGranted = false
- private var desiredActive = false
-
- func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
- self.owner = sessionOwner
- self.onCode = onCode
- session.beginConfiguration()
- session.sessionPreset = .high
- guard let device = AVCaptureDevice.default(for: .video),
- let input = try? AVCaptureDeviceInput(device: device),
- session.canAddInput(input) else { return }
- session.addInput(input)
- let output = AVCaptureMetadataOutput()
- guard session.canAddOutput(output) else { return }
- session.addOutput(output)
- output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
- if output.availableMetadataObjectTypes.contains(.qr) {
- output.metadataObjectTypes = [.qr]
- }
- session.commitConfiguration()
- sessionOwner.videoPreviewLayer.session = session
- // Request permission and start
- AVCaptureDevice.requestAccess(for: .video) { granted in
- self.permissionGranted = granted
- if granted && self.desiredActive && !self.isRunning {
- self.setActive(true)
- }
- }
- }
-
- func setActive(_ active: Bool) {
- desiredActive = active
- guard permissionGranted else { return }
- if active && !isRunning {
- isRunning = true
- DispatchQueue.global(qos: .userInitiated).async {
- if !self.session.isRunning { self.session.startRunning() }
- }
- } else if !active && isRunning {
- isRunning = false
- DispatchQueue.global(qos: .userInitiated).async {
- if self.session.isRunning { self.session.stopRunning() }
- }
- }
- }
-
- func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
- for obj in metadataObjects {
- guard let m = obj as? AVMetadataMachineReadableCodeObject,
- m.type == .qr,
- let str = m.stringValue else { continue }
- onCode?(str)
- }
- }
- }
+ func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() }
final class PreviewView: UIView {
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
@@ -279,8 +241,166 @@ struct CameraScannerView: UIViewRepresentable {
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
}
+#elseif os(macOS)
+struct CameraScannerView: NSViewRepresentable {
+ typealias NSViewType = PreviewView
+ var isActive: Bool
+ var onUnavailable: (() -> Void)? = nil
+ var onCode: (String) -> Void
+
+ func makeNSView(context: Context) -> PreviewView {
+ let view = PreviewView()
+ context.coordinator.setup(
+ previewLayer: view.videoPreviewLayer,
+ onCode: onCode,
+ onUnavailable: onUnavailable
+ )
+ context.coordinator.setActive(isActive)
+ return view
+ }
+
+ func updateNSView(_ nsView: PreviewView, context: Context) {
+ context.coordinator.setActive(isActive)
+ }
+
+ func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() }
+
+ final class PreviewView: NSView {
+ let videoPreviewLayer = AVCaptureVideoPreviewLayer()
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ wantsLayer = true
+ videoPreviewLayer.videoGravity = .resizeAspectFill
+ layer = CALayer()
+ layer?.addSublayer(videoPreviewLayer)
+ }
+
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ override func layout() {
+ super.layout()
+ videoPreviewLayer.frame = bounds
+ }
+ }
+}
#endif
+final class CameraScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
+ private var onCode: ((String) -> Void)?
+ private var onUnavailable: (() -> Void)?
+ private let session = AVCaptureSession()
+ private var isRunning = false
+ private var permissionGranted = false
+ private var desiredActive = false
+ private var didConfigureSession = false
+ private weak var previewLayer: AVCaptureVideoPreviewLayer?
+
+ func setup(
+ previewLayer: AVCaptureVideoPreviewLayer,
+ onCode: @escaping (String) -> Void,
+ onUnavailable: (() -> Void)? = nil
+ ) {
+ self.onCode = onCode
+ self.onUnavailable = onUnavailable
+ self.previewLayer = previewLayer
+ previewLayer.session = session
+
+ // Check authorization before creating AVCaptureDeviceInput so tests and
+ // cold launches do not trigger a TCC prompt just by constructing input.
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
+ case .authorized:
+ permissionGranted = true
+ if !configureSessionIfNeeded() {
+ reportUnavailable()
+ }
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .video) { granted in
+ DispatchQueue.main.async {
+ self.permissionGranted = granted
+ if granted {
+ if !self.configureSessionIfNeeded() {
+ self.reportUnavailable()
+ return
+ }
+ if self.desiredActive && !self.isRunning {
+ self.setActive(true)
+ }
+ } else {
+ self.reportUnavailable()
+ }
+ }
+ }
+ default:
+ permissionGranted = false
+ reportUnavailable()
+ }
+ }
+
+ @discardableResult
+ private func configureSessionIfNeeded() -> Bool {
+ guard !didConfigureSession else { return true }
+ session.beginConfiguration()
+ session.sessionPreset = .high
+ guard let device = AVCaptureDevice.default(for: .video),
+ let input = try? AVCaptureDeviceInput(device: device),
+ session.canAddInput(input) else {
+ session.commitConfiguration()
+ return false
+ }
+ session.addInput(input)
+ let output = AVCaptureMetadataOutput()
+ guard session.canAddOutput(output) else {
+ session.commitConfiguration()
+ return false
+ }
+ session.addOutput(output)
+ output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
+ if output.availableMetadataObjectTypes.contains(.qr) {
+ output.metadataObjectTypes = [.qr]
+ }
+ session.commitConfiguration()
+ previewLayer?.session = session
+ didConfigureSession = true
+ return true
+ }
+
+ private func reportUnavailable() {
+ DispatchQueue.main.async {
+ self.onUnavailable?()
+ }
+ }
+
+ func setActive(_ active: Bool) {
+ desiredActive = active
+ guard permissionGranted, didConfigureSession else { return }
+ if active && !isRunning {
+ isRunning = true
+ DispatchQueue.global(qos: .userInitiated).async {
+ if !self.session.isRunning { self.session.startRunning() }
+ }
+ } else if !active && isRunning {
+ isRunning = false
+ DispatchQueue.global(qos: .userInitiated).async {
+ if self.session.isRunning { self.session.stopRunning() }
+ }
+ }
+ }
+
+ func metadataOutput(
+ _ output: AVCaptureMetadataOutput,
+ didOutput metadataObjects: [AVMetadataObject],
+ from connection: AVCaptureConnection
+ ) {
+ for obj in metadataObjects {
+ guard let m = obj as? AVMetadataMachineReadableCodeObject,
+ m.type == .qr,
+ let str = m.stringValue else { continue }
+ onCode?(str)
+ }
+ }
+}
+
// Combined sheet: shows my QR by default with a button to scan instead
struct VerificationSheetView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@@ -320,19 +440,12 @@ struct VerificationSheetView: View {
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.foregroundColor(accentColor)
- #if os(iOS)
QRScanView(isActive: showingScanner, onSuccess: {
showingScanner = false
})
.environmentObject(verificationModel)
- .frame(height: 280)
+ .frame(minHeight: 280)
.clipShape(RoundedRectangle(cornerRadius: 10))
- #else
- QRScanView(onSuccess: {
- showingScanner = false
- })
- .environmentObject(verificationModel)
- #endif
}
.padding()
.frame(maxWidth: .infinity)
diff --git a/bitchat/bitchat-macOS.entitlements b/bitchat/bitchat-macOS.entitlements
index 2369c18b..f3310f75 100644
--- a/bitchat/bitchat-macOS.entitlements
+++ b/bitchat/bitchat-macOS.entitlements
@@ -10,6 +10,8 @@
com.apple.security.device.bluetooth
+ com.apple.security.device.camera
+
com.apple.security.device.microphone
com.apple.security.personal-information.location
diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift
index eab6d989..6ab5290e 100644
--- a/bitchatTests/ViewSmokeTests.swift
+++ b/bitchatTests/ViewSmokeTests.swift
@@ -803,18 +803,29 @@ struct ViewSmokeTests {
#expect(deliveryStatusSnapshot(of: mediaRow) == read)
}
- #if os(iOS)
@Test
func cameraScannerView_previewAndCoordinatorSmoke() {
+ #if os(iOS) || os(macOS)
+ // Avoid constructing AVCaptureDeviceInput (and the TCC prompt it can
+ // trigger) unless the host process already has camera authorization —
+ // same class of isolation as keeping tests off the login keychain.
+ let status = AVCaptureDevice.authorizationStatus(for: .video)
let preview = CameraScannerView.PreviewView(frame: .zero)
- let coordinator = CameraScannerView.Coordinator()
+ let coordinator = CameraScannerCoordinator()
+ #if os(iOS)
_ = CameraScannerView.PreviewView.layerClass
+ #elseif os(macOS)
+ preview.layout()
+ #endif
_ = preview.videoPreviewLayer
- coordinator.setup(sessionOwner: preview) { _ in }
- coordinator.setActive(false)
+
+ if status == .authorized {
+ coordinator.setup(previewLayer: preview.videoPreviewLayer) { _ in }
+ coordinator.setActive(false)
+ }
#expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill)
+ #endif
}
- #endif
}