Refactor QR scan and share and add QRS support
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user