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 }