Files
2026-02-08 18:23:42 +08:00

304 lines
10 KiB
Swift

#if os(macOS)
import AppKit
import AVFoundation
import SwiftUI
import Vision
@MainActor
final class QRScannerController: NSObject, ObservableObject {
private var captureSession: AVCaptureSession?
private var previewLayer: AVCaptureVideoPreviewLayer?
private var videoOutput: AVCaptureVideoDataOutput?
private var didFinishScanning = false
@Published var availableCameras: [AVCaptureDevice] = []
@Published var selectedCamera: AVCaptureDevice?
@Published var qrsMode = false
@Published var decoder: LubyTransformDecoder?
@Published var progress: Double = 0
@Published var framesScanned = 0
private var seenBlockIds = Set<String>()
private let decodingQueue = DispatchQueue(label: "QRSDecoding", qos: .userInitiated)
let previewView = NSView()
var onScan: ((Result<QRScanResult, QRScanError>) -> Void)?
override init() {
super.init()
previewView.wantsLayer = true
previewView.layer?.backgroundColor = NSColor.black.cgColor
refreshCameraList()
}
func refreshCameraList() {
let discoverySession = AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInWideAngleCamera, .externalUnknown],
mediaType: .video,
position: .unspecified
)
availableCameras = discoverySession.devices
if selectedCamera == nil {
selectedCamera = availableCameras.first
}
}
func selectCamera(_ camera: AVCaptureDevice) {
guard camera.uniqueID != selectedCamera?.uniqueID else { return }
selectedCamera = camera
didFinishScanning = false
if captureSession != nil {
stopScanning()
captureSession = nil
previewLayer?.removeFromSuperlayer()
previewLayer = nil
startScanning()
}
}
func startScanning() {
guard captureSession == nil else {
if captureSession?.isRunning == false {
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
self?.captureSession?.startRunning()
}
}
return
}
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
setupCaptureSession()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
if granted {
self?.setupCaptureSession()
} else {
self?.onScan?(.failure(.permissionDenied))
}
}
}
case .denied, .restricted:
onScan?(.failure(.permissionDenied))
@unknown default:
onScan?(.failure(.cameraUnavailable))
}
}
func stopScanning() {
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
self?.captureSession?.stopRunning()
}
}
func reset() {
didFinishScanning = false
qrsMode = false
decoder = nil
progress = 0
framesScanned = 0
seenBlockIds.removeAll()
}
private func setupCaptureSession() {
let session = AVCaptureSession()
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
onScan?(.failure(.cameraUnavailable))
return
}
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch {
onScan?(.failure(.scanFailed(error)))
return
}
guard session.canAddInput(videoInput) else {
onScan?(.failure(.cameraUnavailable))
return
}
session.addInput(videoInput)
let videoOutput = AVCaptureVideoDataOutput()
videoOutput.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
]
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "QRScannerQueue"))
guard session.canAddOutput(videoOutput) else {
onScan?(.failure(.cameraUnavailable))
return
}
session.addOutput(videoOutput)
self.videoOutput = videoOutput
captureSession = session
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.videoGravity = .resizeAspectFill
previewLayer.frame = previewView.bounds
previewView.layer?.addSublayer(previewLayer)
self.previewLayer = previewLayer
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
self?.captureSession?.startRunning()
}
}
func updatePreviewFrame(_ frame: CGRect) {
previewLayer?.frame = frame
}
private func processScannedContent(_ content: String) {
DispatchQueue.main.async { [weak self] in
guard let self, !didFinishScanning else { return }
if let block = EncodedBlock.fromQRSString(content) {
processQRSBlock(block)
} else if !qrsMode {
didFinishScanning = true
NSSound.beep()
onScan?(.success(.qrCode(string: content, type: .qr)))
}
}
}
private func processQRSBlock(_ block: EncodedBlock) {
if let currentChecksum = decoder?.meta?.checksum,
block.checksum != currentChecksum
{
decoder = LubyTransformDecoder()
seenBlockIds.removeAll()
progress = 0
framesScanned = 0
}
if !qrsMode {
qrsMode = true
decoder = LubyTransformDecoder()
}
let blockId = "\(block.checksum):\(block.indices.sorted().map(String.init).joined(separator: ","))"
guard !seenBlockIds.contains(blockId) else { return }
seenBlockIds.insert(blockId)
framesScanned += 1
guard let decoder else { return }
decodingQueue.async { [weak self] in
do {
let complete = try decoder.addBlock(block)
let currentProgress = decoder.progress
DispatchQueue.main.async {
guard let self, !self.didFinishScanning else { return }
self.progress = currentProgress
if complete {
self.didFinishScanning = true
self.stopScanning()
NSSound.beep()
if let data = try? decoder.getDecoded() {
self.onScan?(.success(.qrsData(data)))
} else {
self.onScan?(.failure(.qrsDecodeFailed))
}
}
}
} catch {
// Checksum mismatch is handled above, ignore other errors
}
}
}
}
extension QRScannerController: AVCaptureVideoDataOutputSampleBufferDelegate {
nonisolated func captureOutput(
_: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from _: AVCaptureConnection
) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let request = VNDetectBarcodesRequest { [weak self] request, _ in
guard let results = request.results as? [VNBarcodeObservation] else { return }
for result in results {
if result.symbology == .qr, let payload = result.payloadStringValue {
self?.processScannedContent(payload)
}
}
}
request.symbologies = [.qr]
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
try? handler.perform([request])
}
}
struct QRScannerControllerView: NSViewControllerRepresentable {
let controller: QRScannerController
func makeNSViewController(context _: Context) -> NSViewController {
QRScannerViewController(controller: controller)
}
func updateNSViewController(_: NSViewController, context _: Context) {}
}
private class QRScannerViewController: NSViewController {
let controller: QRScannerController
init(controller: QRScannerController) {
self.controller = controller
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
view = NSView()
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.black.cgColor
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(controller.previewView)
controller.previewView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
controller.previewView.topAnchor.constraint(equalTo: view.topAnchor),
controller.previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
controller.previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
controller.previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
}
override func viewDidLayout() {
super.viewDidLayout()
controller.updatePreviewFrame(view.bounds)
}
override func viewWillAppear() {
super.viewWillAppear()
controller.startScanning()
}
override func viewWillDisappear() {
super.viewWillDisappear()
controller.stopScanning()
}
}
#endif