mirror of
https://github.com/SagerNet/sing-box-for-apple.git
synced 2026-06-03 11:37:44 +00:00
304 lines
10 KiB
Swift
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
|