Refactor QR scan and share and add QRS support

This commit is contained in:
世界
2026-01-02 16:27:26 +08:00
parent 313cb5d213
commit 52db9bbb39
23 changed files with 1115 additions and 1411 deletions
@@ -78,6 +78,11 @@ public struct ProfileCard: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.sheet(isPresented: $viewModel.showQRSShare) {
if let profile = selectedProfile, let data = try? profile.origin.toContent().encode() {
QRSSheet(profileName: profile.name, profileData: data)
}
}
#else
.sheet(isPresented: $viewModel.showNewProfile, onDismiss: {
environments.profileUpdate.send()
@@ -98,6 +103,11 @@ public struct ProfileCard: View {
}
}
#endif
.sheet(isPresented: $viewModel.showQRSShare) {
if let profile = selectedProfile, let data = try? profile.origin.toContent().encode() {
QRSSheet(profileName: profile.name, profileData: data)
}
}
.fileExporter(
isPresented: $viewModel.showProfileExporter,
document: viewModel.profileExportDocument,
@@ -250,20 +260,26 @@ public struct ProfileCard: View {
@ViewBuilder
private func shareMenu(for profile: ProfilePreview) -> some View {
#if os(tvOS)
if profile.type == .remote {
Menu {
Menu {
if profile.type == .remote {
Button {
viewModel.showQRCode = true
} label: {
Label("Share URL as QR Code", systemImage: "qrcode")
}
} label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
Button {
viewModel.showQRSShare = true
} label: {
Label("Share as QRS Code", systemImage: "barcode")
}
} label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
#else
Menu {
Button {
@@ -297,9 +313,17 @@ public struct ProfileCard: View {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
Button {
viewModel.showQRSShare = true
} label: {
Label("Share as QRS Code", systemImage: "barcode")
}
} label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16))
.frame(width: 44, height: 32)
.contentShape(Rectangle())
}
.menuIndicator(.hidden)
.foregroundStyle(.primary)
@@ -351,10 +375,16 @@ public struct ProfileCard: View {
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
viewModel.profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
}
DispatchQueue.main.async {
switch type {
case .file:
viewModel.showProfileExporter = true
case .json:
viewModel.showJSONExporter = true
}
}
} catch {
viewModel.alert = AlertState(error: error)
@@ -462,6 +492,7 @@ extension ProfileCard {
@Published var showNewProfile = false
@Published var showProfilePicker = false
@Published var showQRCode = false
@Published var showQRSShare = false
@Published var isUpdating = false
@Published var alert: AlertState?
@Published var profileToEdit: Profile?
@@ -531,6 +531,7 @@ private struct ProfilePickerRow: View {
@State private var isUpdating = false
@State private var showQRCode = false
@State private var showQRSShare = false
#if os(macOS)
@State private var shareItemType: ShareItemType?
@State private var exportItemType: ExportItemType?
@@ -569,6 +570,11 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.sheet(isPresented: $showQRSShare) {
if let data = try? profile.origin.toContent().encode() {
QRSSheet(profileName: profile.name, profileData: data)
}
}
}
private var tvOSNormalBody: some View {
@@ -615,16 +621,24 @@ private struct ProfilePickerRow: View {
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
}
Menu {
Menu {
if profile.type == .remote {
Button {
showQRCode = true
} label: {
Label("Share URL as QR Code", systemImage: "qrcode")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button {
showQRSShare = true
} label: {
Label("Share as QRS Code", systemImage: "barcode")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
} label: {
Image(systemName: "ellipsis")
@@ -708,6 +722,11 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.sheet(isPresented: $showQRSShare) {
if let data = try? profile.origin.toContent().encode() {
QRSSheet(profileName: profile.name, profileData: data)
}
}
.fileExporter(
isPresented: $showProfileExporter,
document: profileExportDocument,
@@ -753,6 +772,11 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.sheet(isPresented: $showQRSShare) {
if let data = try? profile.origin.toContent().encode() {
QRSSheet(profileName: profile.name, profileData: data)
}
}
.fileExporter(
isPresented: $showProfileExporter,
document: profileExportDocument,
@@ -952,6 +976,12 @@ private struct ProfilePickerRow: View {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
Button {
showQRSShare = true
} label: {
Label("Share as QRS Code", systemImage: "barcode")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
@@ -962,10 +992,16 @@ private struct ProfilePickerRow: View {
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
profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
}
DispatchQueue.main.async {
switch type {
case .file:
showProfileExporter = true
case .json:
showJSONExporter = true
}
}
} catch {
alert = AlertState(error: error)
@@ -1025,7 +1061,7 @@ private struct ProfilePickerRow: View {
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
showProfileExporter = true
case .json:
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
showJSONExporter = true
}
} catch {
@@ -1136,6 +1172,7 @@ private struct ProfilePickerRow: View {
@State private var isUpdating = false
@State private var showQRCode = false
@State private var showQRSShare = false
@State private var profileExportDocument: ProfileExportDocument?
@State private var showProfileExporter = false
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
@@ -1197,6 +1234,11 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.sheet(isPresented: $showQRSShare) {
if let data = try? profile.origin.toContent().encode() {
QRSSheet(profileName: profile.name, profileData: data)
}
}
.fileExporter(
isPresented: $showProfileExporter,
document: profileExportDocument,
@@ -1293,6 +1335,12 @@ private struct ProfilePickerRow: View {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
Button {
showQRSShare = true
} label: {
Label("Share as QRS Code", systemImage: "barcode")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
@@ -1303,10 +1351,16 @@ private struct ProfilePickerRow: View {
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
profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
}
DispatchQueue.main.async {
switch type {
case .file:
showProfileExporter = true
case .json:
showJSONExporter = true
}
}
} catch {
alert = AlertState(error: error)
@@ -140,7 +140,7 @@
try content.config.write(to: profileConfig, atomically: true, encoding: .utf8)
var lastUpdated: Date?
if content.lastUpdated > 0 {
lastUpdated = Date(timeIntervalSince1970: Double(content.lastUpdated))
lastUpdated = dateFromTimestamp(content.lastUpdated)
}
let uniqueProfileName = try await ProfileManager.uniqueName(content.name)
let profile = Profile(name: uniqueProfileName, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated)
@@ -231,8 +231,17 @@ public struct NewProfileMenuView: View {
#if !os(tvOS)
private func handleQRScanResult(_ result: QRScanResult) {
switch result {
case let .qrCode(string, _):
handleQRCodeString(string)
case let .qrsData(data):
handleQRSData(data)
}
}
private func handleQRCodeString(_ string: String) {
var error: NSError?
let remoteProfile = LibboxParseRemoteProfileImportLink(result.string, &error)
let remoteProfile = LibboxParseRemoteProfileImportLink(string, &error)
if let error {
alert = AlertState(
title: String(localized: "Invalid QR Code"),
@@ -249,5 +258,30 @@ public struct NewProfileMenuView: View {
}
importRequest = NewProfileView.ImportRequest(name: remoteProfile.name, url: remoteProfile.url)
}
private func handleQRSData(_ data: Data) {
do {
let (actualData, _, _) = try BinaryMeta.readFileHeaderMeta(buffer: data)
let content = try LibboxProfileContent.from(actualData)
alert = AlertState(
title: String(localized: "Import Profile"),
message: String(localized: "Are you sure to import profile \(content.name)?"),
primaryButton: .default(String(localized: "Import")) {
Task {
do {
try await content.importProfile()
environments.profileUpdate.send()
dismiss()
} catch {
alert = AlertState(error: error)
}
}
},
secondaryButton: .cancel()
)
} catch {
alert = AlertState(error: error)
}
}
#endif
}
@@ -1,282 +0,0 @@
import Foundation
import Libbox
import Library
import Network
import SwiftUI
@MainActor
public struct ProfileView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.importProfile) private var importProfile
@Environment(\.importRemoteProfile) private var importRemoteProfile
@StateObject private var viewModel = ProfileViewModel()
#if os(tvOS)
@Environment(\.devicePickerSupports) private var devicePickerSupports
#endif
public init() {}
public var body: some View {
VStack {
if viewModel.isLoading {
ProgressView().onAppear {
viewModel.setEnvironments(environments)
Task {
await viewModel.doReload()
}
}
} else {
ZStack {
if let importRemoteProfileRequest = viewModel.importRemoteProfileRequest {
NavigationDestinationCompat(isPresented: $viewModel.importRemoteProfilePresented) {
NewProfileView(importRemoteProfileRequest)
}
}
FormView {
#if os(iOS)
FormNavigationLink {
NewProfileView()
} label: {
Text("New Profile").foregroundColor(.accentColor)
}
.disabled(viewModel.editMode.isEditing)
#elseif os(macOS)
FormNavigationLink {
NewProfileView()
} label: {
Text("New Profile")
}
#elseif os(tvOS)
Section {
FormNavigationLink {
NewProfileView()
} label: {
Text("New Profile").foregroundColor(.accentColor)
}
if ApplicationLibrary.inPreview || devicePickerSupports(.applicationService(name: "sing-box"), parameters: { .applicationService }) {
FormNavigationLink {
ImportProfileView()
} label: {
Text("Import Profile").foregroundColor(.accentColor)
}
}
}
#endif
if viewModel.profileList.isEmpty {
Text("Empty profiles")
} else {
List {
ForEach(viewModel.profileList, id: \.id) { profile in
Group {
#if os(iOS) || os(tvOS)
if viewModel.editMode.isEditing == true {
Text(profile.name)
} else {
ProfileItem(viewModel, profile)
}
#else
ProfileItem(viewModel, profile)
#endif
}
}
.onMove(perform: moveProfile)
.onDelete(perform: deleteProfile)
}
}
}
}
}
}
.disabled(viewModel.isUpdating)
.alert($viewModel.alert, isLoading: $viewModel.isLoading)
.onAppear {
if let profile = importProfile.wrappedValue {
importProfile.wrappedValue = nil
viewModel.createImportProfileDialog(profile)
}
if let remoteProfile = importRemoteProfile.wrappedValue {
importRemoteProfile.wrappedValue = nil
viewModel.createImportRemoteProfileDialog(remoteProfile)
}
}
.onChangeCompat(of: importProfile.wrappedValue) { newValue in
if let newValue {
importProfile.wrappedValue = nil
viewModel.createImportProfileDialog(newValue)
}
}
.onChangeCompat(of: importRemoteProfile.wrappedValue) { newValue in
if let newValue {
importRemoteProfile.wrappedValue = nil
viewModel.createImportRemoteProfileDialog(newValue)
}
}
.onReceive(environments.profileUpdate) { _ in
Task {
await viewModel.doReload()
}
}
#if os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton().disabled(viewModel.profileList.isEmpty && !viewModel.editMode.isEditing)
}
}
#elseif os(tvOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if viewModel.editMode == .inactive {
Button(action: {
viewModel.editMode = .active
}, label: {
Image(systemName: "square.and.pencil")
})
.tint(.accentColor)
.disabled(viewModel.profileList.isEmpty)
} else {
Button(action: {
viewModel.editMode = .inactive
}, label: {
Image(systemName: "checkmark.square.fill")
})
.tint(.accentColor)
}
}
}
#endif
#if os(iOS) || os(tvOS)
.environment(\.editMode, $viewModel.editMode)
#endif
}
private func moveProfile(from source: IndexSet, to destination: Int) {
viewModel.moveProfile(from: source, to: destination)
}
private func deleteProfile(where profileIndex: IndexSet) {
viewModel.deleteProfile(where: profileIndex)
}
@MainActor
public struct ProfileItem: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@ObservedObject private var viewModel: ProfileViewModel
@State private var profile: ProfilePreview
@State private var shareLinkPresented = false
public init(_ viewModel: ProfileViewModel, _ profile: ProfilePreview) {
self.viewModel = viewModel
_profile = State(initialValue: profile)
}
public var body: some View {
#if os(iOS) || os(macOS)
if #available(iOS 16.0, macOS 13.0, *) {
draggableBody.draggable(profile.origin)
} else {
draggableBody
}
#else
draggableBody
#endif
}
private var draggableBody: some View {
Group {
#if !os(macOS)
FormNavigationLink {
EditProfileView().environmentObject(profile.origin)
} label: {
Text(profile.name)
}
.sheet(isPresented: $shareLinkPresented) {
QRCodeSheet(profileName: profile.name, remoteURL: profile.remoteURL!)
}
.contextMenu {
ProfileShareButton($viewModel.alert, profile.origin) {
Label("Share", systemImage: "square.and.arrow.up.fill")
}
if profile.type == .remote {
Button {
shareLinkPresented = true
} label: {
Label("Share URL as QR Code", systemImage: "qrcode")
}
Button {
viewModel.isUpdating = true
Task {
await viewModel.updateProfile(profile.origin)
profile = ProfilePreview(profile.origin)
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
}
Button(role: .destructive) {
Task {
await viewModel.deleteProfile(profile.origin)
}
} label: {
Label("Delete", systemImage: "trash.fill")
}
}
#else
FormNavigationLink {
EditProfileView().environmentObject(profile.origin)
} label: {
HStack {
VStack(alignment: .leading) {
Text(profile.name)
if profile.type == .remote {
Spacer(minLength: 4)
Text("Last Updated: \(profile.origin.lastUpdated!.relativeFormat)").font(.caption)
}
}
HStack {
if profile.type == .remote {
Button {
viewModel.isUpdating = true
Task {
await viewModel.updateProfile(profile.origin)
profile = ProfilePreview(profile.origin)
}
} label: {
Image(systemName: "arrow.clockwise")
}
.padding(.leading, 4)
Button {
shareLinkPresented = true
} label: {
Image(systemName: "qrcode")
}
.padding(.leading, 4)
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
QRCodeContentView(profileName: profile.name, remoteURL: profile.remoteURL!)
}
}
ProfileShareButton($viewModel.alert, profile.origin) {
Image(systemName: "square.and.arrow.up.fill")
}
.padding(.leading, 4)
Button {
Task {
await viewModel.deleteProfile(profile.origin)
}
} label: {
Image(systemName: "trash.fill")
}
.padding([.leading, .trailing], 4)
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
#endif
}
}
}
}
@@ -1,136 +0,0 @@
import Foundation
import Libbox
import Library
import SwiftUI
@MainActor
public class ProfileViewModel: BaseViewModel {
@Published public var importRemoteProfileRequest: NewProfileView.ImportRequest?
@Published public var importRemoteProfilePresented = false
@Published public var isUpdating = false
@Published public var profileList: [ProfilePreview] = []
#if os(iOS) || os(tvOS)
@Published public var editMode = EditMode.inactive
#endif
private weak var environments: ExtensionEnvironments?
override public init() {
super.init()
isLoading = true
}
public func setEnvironments(_ environments: ExtensionEnvironments) {
self.environments = environments
}
public func createImportProfileDialog(_ profile: LibboxProfileContent) {
alert = AlertState(
title: String(localized: "Import Profile"),
message: String(localized: "Are you sure to import profile \(profile.name)?"),
primaryButton: .default(String(localized: "Import")) {
Task {
do {
try await profile.importProfile()
} catch {
self.alert = AlertState(error: error)
return
}
await self.doReload()
self.environments?.emptyProfiles = self.profileList.isEmpty
}
},
secondaryButton: .cancel()
)
}
public func createImportRemoteProfileDialog(_ newValue: LibboxImportRemoteProfile) {
importRemoteProfileRequest = .init(name: newValue.name, url: newValue.url)
alert = AlertState(
title: String(localized: "Import Remote Profile"),
message: String(localized: "Are you sure to import remote profile \(newValue.name)? You will connect to \(newValue.host) to download the configuration."),
primaryButton: .default(String(localized: "Import")) {
self.importRemoteProfilePresented = true
},
secondaryButton: .cancel()
)
}
public func doReload() async {
defer {
isLoading = false
}
if ApplicationLibrary.inPreview {
profileList = [
ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")),
ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))),
]
} else {
do {
profileList = try await ProfileManager.list().map { ProfilePreview($0) }
} catch {
alert = AlertState(error: error)
return
}
}
environments?.emptyProfiles = profileList.isEmpty
}
public func updateProfile(_ profile: Profile) async {
await updateProfileBackground(profile)
isUpdating = false
}
private nonisolated func updateProfileBackground(_ profile: Profile) async {
do {
_ = try await profile.updateRemoteProfile()
} catch {
await MainActor.run {
alert = AlertState(error: error)
}
}
}
public func deleteProfile(_ profile: Profile) async {
do {
_ = try await ProfileManager.delete(profile)
environments?.profileUpdate.send()
environments?.emptyProfiles = profileList.isEmpty
} catch {
alert = AlertState(error: error)
}
}
public func moveProfile(from source: IndexSet, to destination: Int) {
profileList.move(fromOffsets: source, toOffset: destination)
for (index, profile) in profileList.enumerated() {
profileList[index].order = UInt32(index)
profile.origin.order = UInt32(index)
}
Task {
do {
try await ProfileManager.update(profileList.map(\.origin))
environments?.profileUpdate.send()
} catch {
alert = AlertState(error: error)
}
}
}
public func deleteProfile(where profileIndex: IndexSet) {
let profileToDelete = profileIndex.map { index in
profileList[index].origin
}
profileList.remove(atOffsets: profileIndex)
Task {
do {
_ = try await ProfileManager.delete(profileToDelete)
environments?.emptyProfiles = profileList.isEmpty
environments?.profileUpdate.send()
} catch {
alert = AlertState(error: error)
}
}
}
}
@@ -31,7 +31,8 @@ public struct QRCodeContentView: View {
content: LibboxGenerateRemoteProfileImportLink(profileName, remoteURL),
errorCorrection: .low,
foregroundColor: .labelColor,
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
backgroundColor: CGColor(gray: 1.0, alpha: 0.0),
additionalQuietZonePixels: 4
)
#if os(macOS)
.frame(minWidth: 300, minHeight: 300)
@@ -73,3 +74,64 @@ public struct QRCodeSheet: View {
#endif
}
}
@MainActor
public struct URLQRCodeContentView: View {
private let url: String
public init(url: String) {
self.url = url
}
public var body: some View {
VStack {
Spacer()
QRCodeViewUI(
content: url,
errorCorrection: .low,
foregroundColor: .labelColor,
backgroundColor: CGColor(gray: 1.0, alpha: 0.0),
additionalQuietZonePixels: 4
)
#if os(macOS)
.frame(minWidth: 300, minHeight: 300)
#endif
Spacer()
}
.padding()
}
}
@MainActor
public struct URLQRCodeSheet: View {
private let url: String
private let title: String
public init(url: String, title: String) {
self.url = url
self.title = title
}
public var body: some View {
#if os(macOS)
NavigationSheet(title: title) {
URLQRCodeContentView(url: url)
}
.frame(minWidth: 400, minHeight: 400)
#elseif os(iOS) || os(tvOS)
if #available(iOS 16.0, tvOS 17.0, *) {
NavigationStackCompat {
URLQRCodeContentView(url: url)
.navigationTitle(title)
}
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
} else {
NavigationStackCompat {
URLQRCodeContentView(url: url)
.navigationTitle(title)
}
}
#endif
}
}
@@ -2,162 +2,167 @@ 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 static let recoveryFactor = 1.3
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
private let data: Data
private let filename: String?
@State private var encoder: LubyTransformEncoder?
@State private var currentBlock: EncodedBlock?
@State private var frameCount = 0
@State private var isPlaying = true
@State private var generator: QRSImageGenerator?
@State private var fps: Double = 10
@State private var sliceSize: Int = 500
@State private var timer: Timer?
@State private var sliceSize: Double = 500
@State private var generationTask: Task<Void, Never>?
#if os(tvOS)
@State private var showQRSInfoQRCode = false
#endif
public init(data: Data) {
public init(data: Data, filename: String? = nil) {
self.data = data
self.filename = filename
}
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")
TimelineView(.periodic(from: .now, by: 1.0 / fps)) { context in
Group {
if let image = generator?.currentImage {
Image(decorative: image, scale: 1.0)
.resizable()
.interpolation(.none)
.aspectRatio(1, contentMode: .fit)
} else {
ProgressView()
.frame(width: 280, height: 280)
}
#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)
}
.onChange(of: context.date) { _ in
generator?.advanceFrame()
}
}
VStack(spacing: 8) {
HStack {
Text(String(localized: "FPS"))
Spacer()
Text(verbatim: "\(Int(fps))")
.foregroundStyle(.secondary)
}
Slider(value: $fps, in: 1 ... 60, step: 1)
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)
Spacer()
Text("\(Int(sliceSize))")
.foregroundStyle(.secondary)
}
Slider(value: $sliceSize, in: 100 ... 1500, step: 100)
}
.padding(.horizontal)
HStack(spacing: 12) {
Button {
#if os(tvOS)
showQRSInfoQRCode = true
#else
openURL(URL(string: "https://github.com/qifi-dev/qrs")!)
#endif
} label: {
HStack(spacing: 4) {
Image(systemName: "info.circle")
Text(String(localized: "What is QRS"))
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
dismiss()
} label: {
HStack(spacing: 4) {
Image(systemName: "xmark")
Text(String(localized: "Close"))
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.padding(.horizontal)
}
.padding()
#if os(macOS)
.padding()
#else
.padding([.horizontal, .bottom])
#endif
.onAppear {
setupEncoder()
startAnimation()
setupGenerator()
}
.onDisappear {
stopAnimation()
}
.onChange(of: fps) { _ in
if isPlaying {
restartTimer()
}
}
.onChange(of: isPlaying) { playing in
if playing {
startAnimation()
} else {
stopAnimation()
}
generator?.cancel()
generationTask?.cancel()
}
.onChange(of: sliceSize) { _ in
setupEncoder()
frameCount = 0
setupGenerator()
}
#if os(tvOS)
.sheet(isPresented: $showQRSInfoQRCode) {
URLQRCodeSheet(url: "https://github.com/qifi-dev/qrs", title: String(localized: "What is QRS"))
}
#endif
}
private func setupEncoder() {
encoder = LubyTransformEncoder(data: data, sliceSize: sliceSize, compress: true)
}
private func setupGenerator() {
generator?.cancel()
generationTask?.cancel()
private func startAnimation() {
nextFrame()
restartTimer()
}
let newGenerator = QRSImageGenerator(
foregroundColor: CGColor(gray: 0.0, alpha: 1.0),
bufferSize: 30
)
generator = newGenerator
private func restartTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / fps, repeats: true) { _ in
Task { @MainActor in
nextFrame()
generationTask = Task {
let (encoder, requiredFrames) = await createEncoder()
if Task.isCancelled { return }
newGenerator.setExpectedFrames(requiredFrames)
let fountain = encoder.fountain()
for _ in 0 ..< requiredFrames {
if Task.isCancelled { return }
guard let block = fountain.next() else { continue }
await newGenerator.addFrame(block)
}
}
}
private func stopAnimation() {
timer?.invalidate()
timer = nil
private nonisolated func createEncoder() async -> (LubyTransformEncoder, Int) {
await Task.detached(priority: .userInitiated) { [data, filename, sliceSize] in
let wrappedData = BinaryMeta.appendFileHeaderMeta(
data: data,
filename: filename,
contentType: "application/octet-stream"
)
let sliceSizeInt = Int(sliceSize)
let encoder = LubyTransformEncoder(data: wrappedData, sliceSize: sliceSizeInt, compress: true)
let requiredFrames = Self.calculateRequiredFrames(dataSize: wrappedData.count, sliceSize: sliceSizeInt)
return (encoder, requiredFrames)
}.value
}
private func nextFrame() {
guard let encoder else { return }
currentBlock = encoder.fountain().next()
frameCount += 1
private nonisolated static func calculateRequiredFrames(dataSize: Int, sliceSize: Int) -> Int {
let k = (dataSize + sliceSize - 1) / sliceSize
if k == 0 { return 1 }
return max(Int(Double(k) * recoveryFactor), k + 5)
}
}
@MainActor
public struct QRSSheet: View {
@Environment(\.dismiss) private var dismiss
private let profileName: String
private let profileData: Data
@@ -168,26 +173,25 @@ public struct QRSSheet: View {
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)
QRSDisplayView(data: profileData, filename: "\(profileName).bpf")
.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)
}
}
QRSDisplayView(data: profileData, filename: "\(profileName).bpf")
.modifier(LargeSheetModifier())
#endif
}
}
#if os(iOS) || os(tvOS)
private struct LargeSheetModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 16.0, tvOS 17.0, *) {
content
.presentationDetents([.large])
.presentationDragIndicator(.visible)
} else {
content
}
}
}
#endif
@@ -1,249 +0,0 @@
#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
@@ -1,261 +0,0 @@
#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
@@ -1,169 +0,0 @@
#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
@@ -6,6 +6,7 @@ public enum QRScanError: Error, LocalizedError {
case permissionDenied
case scanFailed(Error)
case invalidCode
case qrsDecodeFailed
public var errorDescription: String? {
switch self {
@@ -17,16 +18,22 @@ public enum QRScanError: Error, LocalizedError {
return error.localizedDescription
case .invalidCode:
return String(localized: "Invalid QR code")
case .qrsDecodeFailed:
return String(localized: "Failed to decode QRS data")
}
}
}
public struct QRScanResult: Sendable {
public let string: String
public let type: AVMetadataObject.ObjectType
public enum QRScanResult: Sendable {
case qrCode(string: String, type: AVMetadataObject.ObjectType)
case qrsData(Data)
public init(string: String, type: AVMetadataObject.ObjectType) {
self.string = string
self.type = type
public var string: String? {
switch self {
case let .qrCode(string, _):
return string
case .qrsData:
return nil
}
}
}
@@ -14,6 +14,13 @@
@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 = UIView()
var onScan: ((Result<QRScanResult, QRScanError>) -> Void)?
@@ -95,6 +102,11 @@
func reset() {
didFinishScanning = false
qrsMode = false
decoder = nil
progress = 0
framesScanned = 0
seenBlockIds.removeAll()
}
private func setupCaptureSession() {
@@ -129,7 +141,7 @@
metadataOutput.metadataObjectTypes = [.qr]
self.metadataOutput = metadataOutput
self.captureSession = session
captureSession = session
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.videoGravity = .resizeAspectFill
@@ -149,24 +161,79 @@
extension QRScannerController: AVCaptureMetadataOutputObjectsDelegate {
nonisolated func metadataOutput(
_ output: AVCaptureMetadataOutput,
_: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
from _: AVCaptureConnection
) {
Task { @MainActor in
guard !didFinishScanning,
let metadataObject = metadataObjects.first,
let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
let stringValue = readableObject.stringValue
else {
return
}
guard !didFinishScanning else { return }
for metadataObject in metadataObjects {
guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
let stringValue = readableObject.stringValue
else { continue }
processScannedContent(stringValue, type: readableObject.type)
}
}
}
private func processScannedContent(_ content: String, type: AVMetadataObject.ObjectType) {
if let block = EncodedBlock.fromQRSString(content) {
processQRSBlock(block)
} else if !qrsMode {
didFinishScanning = true
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
onScan?(.success(.qrCode(string: content, type: type)))
}
}
let result = QRScanResult(string: stringValue, type: readableObject.type)
onScan?(.success(result))
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()
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
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
}
}
}
}
@@ -174,12 +241,12 @@
struct QRScannerControllerView: UIViewControllerRepresentable {
let controller: QRScannerController
func makeUIViewController(context: Context) -> UIViewController {
func makeUIViewController(context _: Context) -> UIViewController {
let viewController = QRScannerViewController(controller: controller)
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
func updateUIViewController(_: UIViewController, context _: Context) {}
}
private class QRScannerViewController: UIViewController {
@@ -191,7 +258,7 @@
}
@available(*, unavailable)
required init?(coder: NSCoder) {
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@@ -15,6 +15,13 @@
@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)?
@@ -89,6 +96,11 @@
func reset() {
didFinishScanning = false
qrsMode = false
decoder = nil
progress = 0
framesScanned = 0
seenBlockIds.removeAll()
}
private func setupCaptureSession() {
@@ -126,7 +138,7 @@
session.addOutput(videoOutput)
self.videoOutput = videoOutput
self.captureSession = session
captureSession = session
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.videoGravity = .resizeAspectFill
@@ -143,23 +155,75 @@
previewLayer?.frame = frame
}
private func processQRCode(_ payloadString: String) {
guard !didFinishScanning else { return }
didFinishScanning = true
private func processScannedContent(_ content: String) {
DispatchQueue.main.async { [weak self] in
NSSound.beep()
let result = QRScanResult(string: payloadString, type: .qr)
self?.onScan?(.success(result))
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(
_ output: AVCaptureOutput,
_: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
from _: AVCaptureConnection
) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
@@ -168,8 +232,7 @@
for result in results {
if result.symbology == .qr, let payload = result.payloadStringValue {
self?.processQRCode(payload)
return
self?.processScannedContent(payload)
}
}
}
@@ -183,12 +246,12 @@
struct QRScannerControllerView: NSViewControllerRepresentable {
let controller: QRScannerController
func makeNSViewController(context: Context) -> NSViewController {
func makeNSViewController(context _: Context) -> NSViewController {
let viewController = QRScannerViewController(controller: controller)
return viewController
}
func updateNSViewController(_ nsViewController: NSViewController, context: Context) {}
func updateNSViewController(_: NSViewController, context _: Context) {}
}
private class QRScannerViewController: NSViewController {
@@ -200,7 +263,7 @@
}
@available(*, unavailable)
required init?(coder: NSCoder) {
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@@ -27,36 +27,42 @@
#if os(iOS)
private var iOSBody: some View {
NavigationStackCompat {
QRScannerControllerView(controller: controller)
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Menu {
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)
}
ZStack {
QRScannerControllerView(controller: controller)
.ignoresSafeArea()
if controller.qrsMode {
qrsProgressOverlay
}
}
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Menu {
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")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.alert($alert)
.onAppear {
@@ -68,8 +74,14 @@
#if os(macOS)
private var macOSBody: some View {
VStack(spacing: 0) {
QRScannerControllerView(controller: controller)
.frame(minWidth: 400, minHeight: 300)
ZStack {
QRScannerControllerView(controller: controller)
if controller.qrsMode {
qrsProgressOverlay
}
}
.frame(minWidth: 400, minHeight: 300)
Divider()
@@ -102,11 +114,29 @@
}
#endif
private var qrsProgressOverlay: some View {
ZStack {
Color.black.opacity(0.5)
.ignoresSafeArea()
let k = controller.decoder?.k ?? 0
let framesScanned = controller.framesScanned
let scanProgress = k > 0 ? min(1.0, Double(framesScanned) / Double(k) / 1.2) : 0
CircularProgressView(
progress: scanProgress,
total: k
)
}
}
private func handleScanResult(_ result: Result<QRScanResult, QRScanError>) {
switch result {
case let .success(scanResult):
dismiss()
onScan(scanResult)
Task { @MainActor in
onScan(scanResult)
}
case let .failure(error):
switch error {
case .permissionDenied:
@@ -124,4 +154,38 @@
}
}
private struct CircularProgressView: View {
let progress: Double
let total: Int
private let size: CGFloat = 96
private let lineWidth: CGFloat = 8
var body: some View {
ZStack {
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: lineWidth)
.frame(width: size, height: size)
Circle()
.trim(from: 0, to: progress)
.stroke(Color.white, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.frame(width: size, height: size)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.2), value: progress)
if total > 0 {
Text("\(min(99, Int(progress * 100)))%")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
}
Text("QRS")
.font(.system(size: 32, weight: .bold))
.foregroundStyle(.white)
.offset(y: -size / 2 - 40)
}
}
}
#endif
@@ -76,24 +76,24 @@ public struct OnDemandRulesView: View {
.environment(\.editMode, $editMode)
#endif
#if !os(tvOS)
.platformSheet(isPresented: $isAddingRule) {
OnDemandRuleEditView(rule: OnDemandRule(), isNew: true) { newRule in
rules.append(newRule)
.platformSheet(isPresented: $isAddingRule) {
OnDemandRuleEditView(rule: OnDemandRule(), isNew: true) { newRule in
rules.append(newRule)
Task {
await saveRules()
}
}
}
.platformSheet(item: $editingRule) { rule in
OnDemandRuleEditView(rule: rule, isNew: false) { updatedRule in
if let index = rules.firstIndex(where: { $0.id == updatedRule.id }) {
rules[index] = updatedRule
Task {
await saveRules()
}
}
}
.platformSheet(item: $editingRule) { rule in
OnDemandRuleEditView(rule: rule, isNew: false) { updatedRule in
if let index = rules.firstIndex(where: { $0.id == updatedRule.id }) {
rules[index] = updatedRule
Task {
await saveRules()
}
}
}
}
}
#endif
}
@@ -160,7 +160,7 @@ public struct OnDemandRulesView: View {
Image(systemName: "plus.circle.fill")
}
#if os(macOS)
.buttonStyle(.plain)
.buttonStyle(.plain)
#endif
#endif
}
@@ -210,9 +210,9 @@ public struct OnDemandRulesView: View {
.contentShape(Rectangle())
}
#if os(macOS)
.buttonStyle(.plain)
.buttonStyle(.plain)
#elseif os(iOS)
.foregroundStyle(.primary)
.foregroundStyle(.primary)
#endif
#endif
}
@@ -362,36 +362,36 @@ private struct OnDemandRuleEditView: View {
.navigationBarTitleDisplayMode(.inline)
#endif
#if !os(tvOS)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(isNew ? "Create" : "Save") {
onSave(rule)
dismiss()
}
.disabled(!isProbeURLValid)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(isNew ? "Create" : "Save") {
onSave(rule)
dismiss()
}
.disabled(!isProbeURLValid)
}
}
#endif
#if os(macOS)
.formStyle(.grouped)
.formStyle(.grouped)
#endif
.platformSheet(isPresented: $isAddingConnectionRule, size: .small) {
EvaluateConnectionRuleEditView(rule: EvaluateConnectionRule()) { newRule in
rule.connectionRules.append(newRule)
}
.platformSheet(isPresented: $isAddingConnectionRule, size: .small) {
EvaluateConnectionRuleEditView(rule: EvaluateConnectionRule()) { newRule in
rule.connectionRules.append(newRule)
}
.platformSheet(item: $editingConnectionRule, size: .small) { connRule in
EvaluateConnectionRuleEditView(rule: connRule) { updatedRule in
if let index = rule.connectionRules.firstIndex(where: { $0.id == updatedRule.id }) {
rule.connectionRules[index] = updatedRule
}
}
.platformSheet(item: $editingConnectionRule, size: .small) { connRule in
EvaluateConnectionRuleEditView(rule: connRule) { updatedRule in
if let index = rule.connectionRules.firstIndex(where: { $0.id == updatedRule.id }) {
rule.connectionRules[index] = updatedRule
}
}
}
}
private var actionSection: some View {