Add save file option for profile sharing

Add "Save File" and "Save Content JSON" options to profile share menu,
allowing users to save profiles directly to a chosen location using
fileExporter instead of the system share sheet.
This commit is contained in:
世界
2026-01-01 20:25:28 +08:00
parent 55e01a89b5
commit 313cb5d213
11 changed files with 1674 additions and 26 deletions
@@ -98,6 +98,28 @@ public struct ProfileCard: View {
}
}
#endif
.fileExporter(
isPresented: $viewModel.showProfileExporter,
document: viewModel.profileExportDocument,
contentType: .profile,
defaultFilename: viewModel.profileExportDocument?.filename
) { result in
viewModel.profileExportDocument = nil
if case let .failure(error) = result {
viewModel.alert = AlertState(error: error)
}
}
.fileExporter(
isPresented: $viewModel.showJSONExporter,
document: viewModel.profileJSONExportDocument,
contentType: .json,
defaultFilename: viewModel.profileJSONExportDocument?.filename
) { result in
viewModel.profileJSONExportDocument = nil
if case let .failure(error) = result {
viewModel.alert = AlertState(error: error)
}
}
#endif
.alert($viewModel.alert)
}
@@ -244,12 +266,30 @@ public struct ProfileCard: View {
}
#else
Menu {
Button {
exportProfile(profile, type: .file)
} label: {
Label("Save File", systemImage: "square.and.arrow.down")
}
Button {
viewModel.shareItemType = .file
} label: {
Label("Share File", systemImage: "doc")
}
Button {
exportProfile(profile, type: .json)
} label: {
Label("Save Content JSON", systemImage: "square.and.arrow.down")
}
Button {
viewModel.shareItemType = .json
} label: {
Label("Share Content JSON File", systemImage: "curlybraces")
}
if profile.type == .remote {
Button {
viewModel.showQRCode = true
@@ -257,12 +297,6 @@ public struct ProfileCard: View {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
Button {
viewModel.shareItemType = .json
} label: {
Label("Share Content JSON File", systemImage: "curlybraces")
}
} label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16))
@@ -312,6 +346,21 @@ public struct ProfileCard: View {
}
}
private func exportProfile(_ profile: ProfilePreview, type: ExportItemType) {
do {
switch type {
case .file:
viewModel.profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
viewModel.showProfileExporter = true
case .json:
viewModel.profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
viewModel.showJSONExporter = true
}
} catch {
viewModel.alert = AlertState(error: error)
}
}
#if os(iOS)
private func presentShareController(_ item: URL) {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
@@ -403,6 +452,11 @@ extension ProfileCard {
case json
}
enum ExportItemType {
case file
case json
}
@MainActor
class ViewModel: ObservableObject {
@Published var showNewProfile = false
@@ -412,6 +466,10 @@ extension ProfileCard {
@Published var alert: AlertState?
@Published var profileToEdit: Profile?
@Published var shareItemType: ShareItemType?
@Published var profileExportDocument: ProfileExportDocument?
@Published var showProfileExporter = false
@Published var profileJSONExportDocument: ProfileJSONExportDocument?
@Published var showJSONExporter = false
#if os(macOS)
var shareButtonView: NSView?
#endif
@@ -533,8 +533,19 @@ private struct ProfilePickerRow: View {
@State private var showQRCode = false
#if os(macOS)
@State private var shareItemType: ShareItemType?
@State private var exportItemType: ExportItemType?
@State private var profileExportDocument: ProfileExportDocument?
@State private var showProfileExporter = false
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
@State private var showJSONExporter = false
@State private var menuAnchorView: NSView?
#endif
#if os(iOS)
@State private var profileExportDocument: ProfileExportDocument?
@State private var showProfileExporter = false
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
@State private var showJSONExporter = false
#endif
var body: some View {
#if os(tvOS)
@@ -697,6 +708,28 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.fileExporter(
isPresented: $showProfileExporter,
document: profileExportDocument,
contentType: .profile,
defaultFilename: profileExportDocument?.filename
) { result in
profileExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
.fileExporter(
isPresented: $showJSONExporter,
document: profileJSONExportDocument,
contentType: .json,
defaultFilename: profileJSONExportDocument?.filename
) { result in
profileJSONExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
}
private var macOSEditingBody: some View {
@@ -720,6 +753,28 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.fileExporter(
isPresented: $showProfileExporter,
document: profileExportDocument,
contentType: .profile,
defaultFilename: profileExportDocument?.filename
) { result in
profileExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
.fileExporter(
isPresented: $showJSONExporter,
document: profileJSONExportDocument,
contentType: .json,
defaultFilename: profileJSONExportDocument?.filename
) { result in
profileJSONExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
}
#endif
@@ -828,6 +883,11 @@ private struct ProfilePickerRow: View {
self.shareItemType = nil
shareProfile(type: shareItemType)
}
.onChange(of: exportItemType) { exportItemType in
guard let exportItemType else { return }
self.exportItemType = nil
exportProfileMacOS(type: exportItemType)
}
#endif
}
@@ -836,17 +896,53 @@ private struct ProfilePickerRow: View {
private var shareMenu: some View {
Menu {
#if os(macOS)
Button {
exportItemType = .file
} label: {
Label("Save File", systemImage: "square.and.arrow.down")
}
Button {
shareItemType = .file
} label: {
Label("Share File", systemImage: "doc")
}
Button {
exportItemType = .json
} label: {
Label("Save Content JSON", systemImage: "square.and.arrow.down")
}
Button {
shareItemType = .json
} label: {
Label("Share Content JSON File", systemImage: "curlybraces")
}
#else
Button {
exportProfile(type: .file)
} label: {
Label("Save File", systemImage: "square.and.arrow.down")
}
ShareButtonCompat($alert) {
Label("Share File", systemImage: "doc")
} itemURL: {
try profile.origin.toContent().generateShareFile()
}
Button {
exportProfile(type: .json)
} label: {
Label("Save Content JSON", systemImage: "square.and.arrow.down")
}
ShareButtonCompat($alert) {
Label("Share Content JSON File", systemImage: "curlybraces")
} itemURL: {
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
}
#endif
if profile.type == .remote {
@@ -856,24 +952,25 @@ private struct ProfilePickerRow: View {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
#if os(macOS)
Button {
shareItemType = .json
} label: {
Label("Share Content JSON File", systemImage: "curlybraces")
}
#else
ShareButtonCompat($alert) {
Label("Share Content JSON File", systemImage: "curlybraces")
} itemURL: {
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
}
#endif
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
private func exportProfile(type: ExportItemType) {
do {
switch type {
case .file:
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
showProfileExporter = true
case .json:
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
showJSONExporter = true
}
} catch {
alert = AlertState(error: error)
}
}
#endif
private var profileInfo: some View {
@@ -921,6 +1018,21 @@ private struct ProfilePickerRow: View {
}
}
private func exportProfileMacOS(type: ExportItemType) {
do {
switch type {
case .file:
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
showProfileExporter = true
case .json:
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
showJSONExporter = true
}
} catch {
alert = AlertState(error: error)
}
}
static func previewContent(profile: ProfilePreview, width: CGFloat) -> some View {
HStack(spacing: 12) {
Image(systemName: "line.3.horizontal")
@@ -965,6 +1077,15 @@ private struct ProfilePickerRow: View {
#endif
}
// MARK: - Export Helpers
#if !os(tvOS)
private enum ExportItemType {
case file
case json
}
#endif
// MARK: - macOS Helpers
#if os(macOS)
@@ -1015,6 +1136,10 @@ private struct ProfilePickerRow: View {
@State private var isUpdating = false
@State private var showQRCode = false
@State private var profileExportDocument: ProfileExportDocument?
@State private var showProfileExporter = false
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
@State private var showJSONExporter = false
var body: some View {
Group {
@@ -1072,6 +1197,28 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.fileExporter(
isPresented: $showProfileExporter,
document: profileExportDocument,
contentType: .profile,
defaultFilename: profileExportDocument?.filename
) { result in
profileExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
.fileExporter(
isPresented: $showJSONExporter,
document: profileJSONExportDocument,
contentType: .json,
defaultFilename: profileJSONExportDocument?.filename
) { result in
profileJSONExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
}
private var rowMenu: some View {
@@ -1115,12 +1262,30 @@ private struct ProfilePickerRow: View {
@ViewBuilder
private var shareMenu: some View {
Menu {
Button {
exportProfile(type: .file)
} label: {
Label("Save File", systemImage: "square.and.arrow.down")
}
ShareButtonCompat($alert) {
Label("Share File", systemImage: "doc")
} itemURL: {
try profile.origin.toContent().generateShareFile()
}
Button {
exportProfile(type: .json)
} label: {
Label("Save Content JSON", systemImage: "square.and.arrow.down")
}
ShareButtonCompat($alert) {
Label("Share Content JSON File", systemImage: "curlybraces")
} itemURL: {
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
}
if profile.type == .remote {
Button {
showQRCode = true
@@ -1128,17 +1293,26 @@ private struct ProfilePickerRow: View {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
ShareButtonCompat($alert) {
Label("Share Content JSON File", systemImage: "curlybraces")
} itemURL: {
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
private func exportProfile(type: ExportItemType) {
do {
switch type {
case .file:
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
showProfileExporter = true
case .json:
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
showJSONExporter = true
}
} catch {
alert = AlertState(error: error)
}
}
private var profileInfo: some View {
HStack(spacing: 8) {
HStack(spacing: 4) {
@@ -0,0 +1,193 @@
import Foundation
import QRCode
import SwiftUI
private extension CGColor {
static var labelColor: CGColor {
#if canImport(UIKit)
UIColor.label.cgColor
#elseif canImport(AppKit)
NSColor.labelColor.cgColor
#endif
}
}
@MainActor
public struct QRSDisplayView: View {
private let data: Data
@State private var encoder: LubyTransformEncoder?
@State private var currentBlock: EncodedBlock?
@State private var frameCount = 0
@State private var isPlaying = true
@State private var fps: Double = 10
@State private var sliceSize: Int = 500
@State private var timer: Timer?
public init(data: Data) {
self.data = data
}
public var body: some View {
VStack(spacing: 16) {
if let block = currentBlock {
QRCodeViewUI(
content: block.toBase64(),
errorCorrection: .low,
foregroundColor: .labelColor,
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
)
.aspectRatio(1, contentMode: .fit)
#if os(macOS)
.frame(minWidth: 280, minHeight: 280)
#else
.frame(maxWidth: 300, maxHeight: 300)
#endif
} else {
ProgressView()
.frame(width: 280, height: 280)
}
VStack(spacing: 4) {
Text("Frame: \(frameCount)")
.font(.caption)
if let encoder {
Text("Source blocks: \(encoder.k)")
.font(.caption)
Text("Data size: \(encoder.bytes) bytes")
.font(.caption)
}
}
.foregroundStyle(.secondary)
VStack(spacing: 12) {
HStack {
Button {
isPlaying.toggle()
} label: {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
}
#if os(macOS)
.buttonStyle(.bordered)
#endif
Spacer()
Text(String(localized: "Ideal FPS"))
.font(.caption)
Slider(value: $fps, in: 1 ... 30, step: 1)
.frame(maxWidth: 120)
Text("\(Int(fps))")
.font(.caption)
.frame(width: 24, alignment: .trailing)
}
HStack {
Text(String(localized: "Slice Size"))
.font(.caption)
Picker("", selection: $sliceSize) {
Text("200").tag(200)
Text("500").tag(500)
Text("1000").tag(1000)
}
.pickerStyle(.segmented)
.frame(maxWidth: 200)
}
}
.padding(.horizontal)
}
.padding()
.onAppear {
setupEncoder()
startAnimation()
}
.onDisappear {
stopAnimation()
}
.onChange(of: fps) { _ in
if isPlaying {
restartTimer()
}
}
.onChange(of: isPlaying) { playing in
if playing {
startAnimation()
} else {
stopAnimation()
}
}
.onChange(of: sliceSize) { _ in
setupEncoder()
frameCount = 0
}
}
private func setupEncoder() {
encoder = LubyTransformEncoder(data: data, sliceSize: sliceSize, compress: true)
}
private func startAnimation() {
nextFrame()
restartTimer()
}
private func restartTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / fps, repeats: true) { _ in
Task { @MainActor in
nextFrame()
}
}
}
private func stopAnimation() {
timer?.invalidate()
timer = nil
}
private func nextFrame() {
guard let encoder else { return }
currentBlock = encoder.fountain().next()
frameCount += 1
}
}
@MainActor
public struct QRSSheet: View {
@Environment(\.dismiss) private var dismiss
private let profileName: String
private let profileData: Data
public init(profileName: String, profileData: Data) {
self.profileName = profileName
self.profileData = profileData
}
public var body: some View {
#if os(macOS)
NavigationSheet(title: String(localized: "Share as QRS")) {
VStack {
QRSDisplayView(data: profileData)
Text("Ask the receiver to scan continuously until complete.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.bottom)
}
}
.frame(minWidth: 400, minHeight: 520)
#elseif os(iOS) || os(tvOS)
NavigationSheet(title: String(localized: "Share as QRS"), size: .large) {
VStack {
QRSDisplayView(data: profileData)
Text("Ask the receiver to scan continuously until complete.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.bottom)
}
}
#endif
}
}
@@ -0,0 +1,249 @@
#if os(iOS)
import AVFoundation
import SwiftUI
import UIKit
@MainActor
final class QRSScannerController: NSObject, ObservableObject {
private var captureSession: AVCaptureSession?
private var previewLayer: AVCaptureVideoPreviewLayer?
private var metadataOutput: AVCaptureMetadataOutput?
@Published var decoder = LubyTransformDecoder()
@Published var lastError: String?
@Published var isComplete = false
@Published var progress: Double = 0
@Published var framesScanned = 0
@Published var availableCameras: [AVCaptureDevice] = []
@Published var selectedCamera: AVCaptureDevice?
private var seenBlockHashes = Set<Data>()
let previewView = UIView()
var onComplete: ((Data) -> Void)?
override init() {
super.init()
previewView.backgroundColor = .black
refreshCameraList()
}
func refreshCameraList() {
var deviceTypes: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
.builtInUltraWideCamera,
.builtInTelephotoCamera,
]
if #available(iOS 17.0, *) {
deviceTypes.append(.external)
}
let discoverySession = AVCaptureDevice.DiscoverySession(
deviceTypes: deviceTypes,
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
if captureSession != nil {
stopScanning()
captureSession = nil
previewLayer?.removeFromSuperlayer()
previewLayer = nil
startScanning()
}
}
func reset() {
decoder = LubyTransformDecoder()
seenBlockHashes.removeAll()
lastError = nil
isComplete = false
progress = 0
framesScanned = 0
}
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()
}
}
}
default:
break
}
}
func stopScanning() {
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
self?.captureSession?.stopRunning()
}
}
private func setupCaptureSession() {
let session = AVCaptureSession()
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
return
}
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch {
return
}
guard session.canAddInput(videoInput) else { return }
session.addInput(videoInput)
let metadataOutput = AVCaptureMetadataOutput()
guard session.canAddOutput(metadataOutput) else { return }
session.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
metadataOutput.metadataObjectTypes = [.qr]
self.metadataOutput = metadataOutput
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 processQRContent(_ content: String) {
guard !isComplete else { return }
guard let block = EncodedBlock.fromBase64(content) else {
return
}
let hash = block.toBinary()
guard !seenBlockHashes.contains(hash) else { return }
seenBlockHashes.insert(hash)
framesScanned += 1
do {
let complete = try decoder.addBlock(block)
progress = decoder.progress
if complete {
isComplete = true
stopScanning()
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
if let data = try? decoder.getDecoded() {
onComplete?(data)
}
}
} catch {
lastError = error.localizedDescription
}
}
}
extension QRSScannerController: AVCaptureMetadataOutputObjectsDelegate {
nonisolated func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
Task { @MainActor in
for metadataObject in metadataObjects {
guard let readable = metadataObject as? AVMetadataMachineReadableCodeObject,
let content = readable.stringValue
else { continue }
processQRContent(content)
}
}
}
}
struct QRSScannerControllerView: UIViewControllerRepresentable {
let controller: QRSScannerController
func makeUIViewController(context: Context) -> UIViewController {
let viewController = QRSScannerViewController(controller: controller)
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
private class QRSScannerViewController: UIViewController {
let controller: QRSScannerController
init(controller: QRSScannerController) {
self.controller = controller
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
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 viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
controller.updatePreviewFrame(view.bounds)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
controller.startScanning()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
controller.stopScanning()
}
}
#endif
@@ -0,0 +1,261 @@
#if os(macOS)
import AppKit
import AVFoundation
import SwiftUI
import Vision
@MainActor
final class QRSScannerController: NSObject, ObservableObject {
private var captureSession: AVCaptureSession?
private var previewLayer: AVCaptureVideoPreviewLayer?
private var videoOutput: AVCaptureVideoDataOutput?
@Published var decoder = LubyTransformDecoder()
@Published var lastError: String?
@Published var isComplete = false
@Published var progress: Double = 0
@Published var framesScanned = 0
@Published var availableCameras: [AVCaptureDevice] = []
@Published var selectedCamera: AVCaptureDevice?
private var seenBlockHashes = Set<Data>()
let previewView = NSView()
var onComplete: ((Data) -> 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
if captureSession != nil {
stopScanning()
captureSession = nil
previewLayer?.removeFromSuperlayer()
previewLayer = nil
startScanning()
}
}
func reset() {
decoder = LubyTransformDecoder()
seenBlockHashes.removeAll()
lastError = nil
isComplete = false
progress = 0
framesScanned = 0
}
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()
}
}
}
default:
break
}
}
func stopScanning() {
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
self?.captureSession?.stopRunning()
}
}
private func setupCaptureSession() {
let session = AVCaptureSession()
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
return
}
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch {
return
}
guard session.canAddInput(videoInput) else { return }
session.addInput(videoInput)
let videoOutput = AVCaptureVideoDataOutput()
videoOutput.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
]
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "QRSScannerQueue"))
guard session.canAddOutput(videoOutput) else { 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 processQRContent(_ content: String) {
guard !isComplete else { return }
guard let block = EncodedBlock.fromBase64(content) else {
return
}
let hash = block.toBinary()
guard !seenBlockHashes.contains(hash) else { return }
seenBlockHashes.insert(hash)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
framesScanned += 1
do {
let complete = try decoder.addBlock(block)
progress = decoder.progress
if complete {
isComplete = true
stopScanning()
NSSound.beep()
if let data = try? decoder.getDecoded() {
onComplete?(data)
}
}
} catch {
lastError = error.localizedDescription
}
}
}
}
extension QRSScannerController: AVCaptureVideoDataOutputSampleBufferDelegate {
nonisolated func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: 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?.processQRContent(payload)
}
}
}
request.symbologies = [.qr]
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
try? handler.perform([request])
}
}
struct QRSScannerControllerView: NSViewControllerRepresentable {
let controller: QRSScannerController
func makeNSViewController(context: Context) -> NSViewController {
let viewController = QRSScannerViewController(controller: controller)
return viewController
}
func updateNSViewController(_ nsViewController: NSViewController, context: Context) {}
}
private class QRSScannerViewController: NSViewController {
let controller: QRSScannerController
init(controller: QRSScannerController) {
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
@@ -0,0 +1,169 @@
#if !os(tvOS)
import AVFoundation
import Library
import SwiftUI
@MainActor
public struct QRSScannerView: View {
@Environment(\.dismiss) private var dismiss
@State private var alert: AlertState?
@StateObject private var controller = QRSScannerController()
private let onComplete: (Data) -> Void
public init(onComplete: @escaping (Data) -> Void) {
self.onComplete = onComplete
}
public var body: some View {
#if os(iOS)
iOSBody
#elseif os(macOS)
macOSBody
#endif
}
#if os(iOS)
private var iOSBody: some View {
NavigationStackCompat {
ZStack {
QRSScannerControllerView(controller: controller)
.ignoresSafeArea()
VStack {
Spacer()
progressOverlay
.padding()
}
}
.navigationTitle("Scan QRS")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
controller.reset()
controller.startScanning()
} label: {
Label("Reset", systemImage: "arrow.counterclockwise")
}
if controller.availableCameras.count > 1 {
Menu("Camera") {
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
Button {
controller.selectCamera(camera)
} label: {
if camera.uniqueID == controller.selectedCamera?.uniqueID {
Label(camera.localizedName, systemImage: "checkmark")
} else {
Text(camera.localizedName)
}
}
}
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.alert($alert)
.onAppear {
controller.onComplete = { data in
dismiss()
onComplete(data)
}
}
}
#endif
#if os(macOS)
private var macOSBody: some View {
VStack(spacing: 0) {
ZStack {
QRSScannerControllerView(controller: controller)
.frame(minWidth: 400, minHeight: 300)
VStack {
Spacer()
progressOverlay
.padding()
}
}
Divider()
HStack {
if controller.availableCameras.count > 1 {
Picker("Camera", selection: Binding(
get: { controller.selectedCamera },
set: { camera in
if let camera {
controller.selectCamera(camera)
}
}
)) {
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
Text(camera.localizedName).tag(camera as AVCaptureDevice?)
}
}
.frame(maxWidth: 200)
}
Button("Reset") {
controller.reset()
controller.startScanning()
}
Spacer()
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
}
.padding()
}
.alert($alert)
.onAppear {
controller.onComplete = { data in
dismiss()
onComplete(data)
}
}
}
#endif
private var progressOverlay: some View {
VStack(spacing: 8) {
ProgressView(value: controller.progress)
.progressViewStyle(.linear)
.frame(maxWidth: 200)
Text("Decoded: \(Int(controller.progress * 100))%")
.font(.headline)
if controller.decoder.k > 0 {
Text("\(controller.decoder.decodedCount)/\(controller.decoder.k) blocks")
.font(.caption)
}
Text("Frames scanned: \(controller.framesScanned)")
.font(.caption)
if let error = controller.lastError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
#endif