Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
217 lines
6.2 KiB
Swift
217 lines
6.2 KiB
Swift
import AVFoundation
|
|
import UIKit
|
|
|
|
enum QRCodeScannerError: Error {
|
|
case cancelled
|
|
case emptyCode
|
|
case permissionDenied
|
|
case unavailable
|
|
}
|
|
|
|
enum QRCodeScannerResult {
|
|
case manual
|
|
case scanned(String)
|
|
}
|
|
|
|
final class QRCodeScannerViewController: UIViewController,
|
|
AVCaptureMetadataOutputObjectsDelegate
|
|
{
|
|
var onCompletion: ((Result<QRCodeScannerResult, Error>) -> Void)?
|
|
|
|
private let captureSession = AVCaptureSession()
|
|
private let captureQueue = DispatchQueue(
|
|
label: "sh.tutti.mobile.qr-capture",
|
|
qos: .userInitiated
|
|
)
|
|
private let previewLayer = AVCaptureVideoPreviewLayer()
|
|
private var completed = false
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .black
|
|
previewLayer.session = captureSession
|
|
previewLayer.videoGravity = .resizeAspectFill
|
|
view.layer.addSublayer(previewLayer)
|
|
|
|
let titleLabel = UILabel()
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
titleLabel.text = NSLocalizedString("scanner.title", comment: "")
|
|
titleLabel.textColor = .white
|
|
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
|
titleLabel.textAlignment = .center
|
|
titleLabel.numberOfLines = 0
|
|
view.addSubview(titleLabel)
|
|
|
|
let cancelButton = UIButton(type: .system)
|
|
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
|
cancelButton.setTitle(
|
|
NSLocalizedString("scanner.cancel", comment: ""),
|
|
for: .normal
|
|
)
|
|
cancelButton.setTitleColor(.white, for: .normal)
|
|
cancelButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
|
|
cancelButton.addTarget(self, action: #selector(cancel), for: .touchUpInside)
|
|
view.addSubview(cancelButton)
|
|
|
|
let manualButton = UIButton(type: .system)
|
|
manualButton.translatesAutoresizingMaskIntoConstraints = false
|
|
manualButton.setTitle(
|
|
NSLocalizedString("scanner.manual", comment: ""),
|
|
for: .normal
|
|
)
|
|
manualButton.setTitleColor(.white, for: .normal)
|
|
manualButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
|
|
manualButton.addTarget(self, action: #selector(manualEntry), for: .touchUpInside)
|
|
view.addSubview(manualButton)
|
|
|
|
NSLayoutConstraint.activate([
|
|
titleLabel.topAnchor.constraint(
|
|
equalTo: view.safeAreaLayoutGuide.topAnchor,
|
|
constant: 24
|
|
),
|
|
titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 88),
|
|
titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
|
cancelButton.topAnchor.constraint(
|
|
equalTo: view.safeAreaLayoutGuide.topAnchor,
|
|
constant: 24
|
|
),
|
|
cancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
|
manualButton.bottomAnchor.constraint(
|
|
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
|
constant: -24
|
|
),
|
|
manualButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
])
|
|
|
|
configureCapture()
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
previewLayer.frame = view.bounds
|
|
}
|
|
|
|
override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
stopCapture()
|
|
}
|
|
|
|
func metadataOutput(
|
|
_ output: AVCaptureMetadataOutput,
|
|
didOutput metadataObjects: [AVMetadataObject],
|
|
from connection: AVCaptureConnection
|
|
) {
|
|
guard
|
|
let readable = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
|
readable.type == .qr
|
|
else {
|
|
return
|
|
}
|
|
let value =
|
|
readable.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
?? ""
|
|
complete(
|
|
value.isEmpty
|
|
? .failure(QRCodeScannerError.emptyCode)
|
|
: .success(.scanned(value))
|
|
)
|
|
}
|
|
|
|
private func configureCapture() {
|
|
#if targetEnvironment(simulator)
|
|
complete(.failure(QRCodeScannerError.unavailable), shouldDismiss: false)
|
|
return
|
|
#else
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
case .authorized:
|
|
startCapture()
|
|
case .notDetermined:
|
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
if granted {
|
|
self.startCapture()
|
|
} else {
|
|
self.complete(.failure(QRCodeScannerError.permissionDenied))
|
|
}
|
|
}
|
|
}
|
|
case .denied, .restricted:
|
|
complete(.failure(QRCodeScannerError.permissionDenied))
|
|
@unknown default:
|
|
complete(.failure(QRCodeScannerError.unavailable))
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func startCapture() {
|
|
guard !completed else { return }
|
|
guard
|
|
let camera = AVCaptureDevice.default(for: .video),
|
|
let input = try? AVCaptureDeviceInput(device: camera),
|
|
captureSession.canAddInput(input)
|
|
else {
|
|
complete(.failure(QRCodeScannerError.unavailable))
|
|
return
|
|
}
|
|
captureSession.addInput(input)
|
|
|
|
let output = AVCaptureMetadataOutput()
|
|
guard captureSession.canAddOutput(output) else {
|
|
complete(.failure(QRCodeScannerError.unavailable))
|
|
return
|
|
}
|
|
captureSession.addOutput(output)
|
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
|
output.metadataObjectTypes = [.qr]
|
|
captureQueue.async { [session = captureSession] in
|
|
session.startRunning()
|
|
}
|
|
}
|
|
|
|
@objc private func cancel() {
|
|
complete(.failure(QRCodeScannerError.cancelled))
|
|
}
|
|
|
|
@objc private func manualEntry() {
|
|
complete(.success(.manual))
|
|
}
|
|
|
|
func cancelScanning() {
|
|
complete(.failure(QRCodeScannerError.cancelled))
|
|
}
|
|
|
|
private func complete(
|
|
_ result: Result<QRCodeScannerResult, Error>,
|
|
shouldDismiss: Bool = true
|
|
) {
|
|
guard !completed else { return }
|
|
completed = true
|
|
let completion = onCompletion
|
|
stopCapture { [weak self] in
|
|
guard let self else {
|
|
completion?(result)
|
|
return
|
|
}
|
|
if shouldDismiss {
|
|
self.dismiss(animated: true) {
|
|
completion?(result)
|
|
}
|
|
} else {
|
|
completion?(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopCapture(completion: (() -> Void)? = nil) {
|
|
captureQueue.async { [session = captureSession] in
|
|
if session.isRunning {
|
|
session.stopRunning()
|
|
}
|
|
if let completion {
|
|
DispatchQueue.main.async(execute: completion)
|
|
}
|
|
}
|
|
}
|
|
}
|