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.
This commit is contained in:
Felix-Ayush 2026-08-01 14:33:48 +05:30 committed by GitHub
parent 948d6a85b9
commit 8f9489790d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 425 additions and 113 deletions

View File

@ -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" : {

View File

@ -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)

View File

@ -10,6 +10,8 @@
</array>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.personal-information.location</key>

View File

@ -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
}