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:
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
enum CRC32 {
|
||||
private static let table: [UInt32] = {
|
||||
var table = [UInt32](repeating: 0, count: 256)
|
||||
for i in 0 ..< 256 {
|
||||
var crc = UInt32(i)
|
||||
for _ in 0 ..< 8 {
|
||||
if crc & 1 != 0 {
|
||||
crc = (crc >> 1) ^ 0xEDB8_8320
|
||||
} else {
|
||||
crc = crc >> 1
|
||||
}
|
||||
}
|
||||
table[i] = crc
|
||||
}
|
||||
return table
|
||||
}()
|
||||
|
||||
static func checksum(_ data: Data, k: Int) -> UInt32 {
|
||||
var crc: UInt32 = 0xFFFF_FFFF
|
||||
for byte in data {
|
||||
let index = Int((crc ^ UInt32(byte)) & 0xFF)
|
||||
crc = (crc >> 8) ^ table[index]
|
||||
}
|
||||
return crc ^ UInt32(k) ^ 0xFFFF_FFFF
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
struct EncodedBlock {
|
||||
var indices: [Int]
|
||||
var data: Data
|
||||
let k: Int
|
||||
let bytes: Int
|
||||
let checksum: UInt32
|
||||
|
||||
// Binary format: degree(4) + indices(4*n) + k(4) + bytes(4) + checksum(4) + data
|
||||
func toBinary() -> Data {
|
||||
var result = Data()
|
||||
|
||||
// Write degree (number of indices)
|
||||
var degree = UInt32(indices.count).littleEndian
|
||||
result.append(Data(bytes: °ree, count: 4))
|
||||
|
||||
// Write indices
|
||||
for index in indices {
|
||||
var idx = UInt32(index).littleEndian
|
||||
result.append(Data(bytes: &idx, count: 4))
|
||||
}
|
||||
|
||||
// Write k, bytes, checksum
|
||||
var kVal = UInt32(k).littleEndian
|
||||
var bytesVal = UInt32(bytes).littleEndian
|
||||
var checksumVal = checksum.littleEndian
|
||||
result.append(Data(bytes: &kVal, count: 4))
|
||||
result.append(Data(bytes: &bytesVal, count: 4))
|
||||
result.append(Data(bytes: &checksumVal, count: 4))
|
||||
|
||||
// Write data
|
||||
result.append(data)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
static func fromBinary(_ binary: Data) -> EncodedBlock? {
|
||||
guard binary.count >= 16 else { return nil }
|
||||
|
||||
var offset = 0
|
||||
|
||||
let degree = binary.withUnsafeBytes {
|
||||
$0.load(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||
}
|
||||
offset += 4
|
||||
|
||||
guard binary.count >= 4 + Int(degree) * 4 + 12 else { return nil }
|
||||
|
||||
var indices: [Int] = []
|
||||
for _ in 0 ..< degree {
|
||||
let idx = binary.withUnsafeBytes {
|
||||
$0.load(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||
}
|
||||
indices.append(Int(idx))
|
||||
offset += 4
|
||||
}
|
||||
|
||||
let k = Int(binary.withUnsafeBytes {
|
||||
$0.load(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||
})
|
||||
offset += 4
|
||||
|
||||
let bytes = Int(binary.withUnsafeBytes {
|
||||
$0.load(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||
})
|
||||
offset += 4
|
||||
|
||||
let checksum = binary.withUnsafeBytes {
|
||||
$0.load(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||
}
|
||||
offset += 4
|
||||
|
||||
let data = binary.subdata(in: offset ..< binary.count)
|
||||
|
||||
return EncodedBlock(indices: indices, data: data, k: k, bytes: bytes, checksum: checksum)
|
||||
}
|
||||
|
||||
func toBase64() -> String {
|
||||
toBinary().base64EncodedString()
|
||||
}
|
||||
|
||||
static func fromBase64(_ string: String) -> EncodedBlock? {
|
||||
guard let data = Data(base64Encoded: string) else { return nil }
|
||||
return fromBinary(data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import Compression
|
||||
import Foundation
|
||||
|
||||
final class LubyTransformDecoder {
|
||||
private(set) var decodedData: [Data?] = []
|
||||
private(set) var decodedCount = 0
|
||||
private(set) var encodedCount = 0
|
||||
private var encodedBlocks: Set<BlockWrapper> = []
|
||||
private var encodedBlockKeyMap: [String: BlockWrapper] = [:]
|
||||
private var encodedBlockSubkeyMap: [String: Set<BlockWrapper>] = [:]
|
||||
private var encodedBlockIndexMap: [Int: Set<BlockWrapper>] = [:]
|
||||
private var disposedEncodedBlocks: [Int: [() -> Void]] = [:]
|
||||
private(set) var meta: EncodedBlock?
|
||||
|
||||
var k: Int { meta?.k ?? 0 }
|
||||
var progress: Double {
|
||||
guard k > 0 else { return 0 }
|
||||
return Double(decodedCount) / Double(k)
|
||||
}
|
||||
|
||||
var isComplete: Bool { meta != nil && decodedCount == k }
|
||||
|
||||
private class BlockWrapper: Hashable {
|
||||
var block: EncodedBlock
|
||||
let id = UUID()
|
||||
|
||||
init(_ block: EncodedBlock) { self.block = block }
|
||||
|
||||
static func == (lhs: BlockWrapper, rhs: BlockWrapper) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
enum DecoderError: Error {
|
||||
case checksumMismatch
|
||||
case incomplete
|
||||
case noMeta
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addBlock(_ block: EncodedBlock) throws -> Bool {
|
||||
if meta == nil {
|
||||
meta = block
|
||||
decodedData = Array(repeating: nil, count: block.k)
|
||||
}
|
||||
|
||||
guard block.checksum == meta?.checksum else {
|
||||
throw DecoderError.checksumMismatch
|
||||
}
|
||||
|
||||
encodedCount += 1
|
||||
|
||||
var mutableBlock = block
|
||||
mutableBlock.indices.sort()
|
||||
let wrapper = BlockWrapper(mutableBlock)
|
||||
propagateDecoded(key: indicesToKey(mutableBlock.indices), wrapper: wrapper)
|
||||
|
||||
return decodedCount == k
|
||||
}
|
||||
|
||||
private func indicesToKey(_ indices: [Int]) -> String {
|
||||
indices.map(String.init).joined(separator: ",")
|
||||
}
|
||||
|
||||
private func xorData(_ a: Data, _ b: Data) -> Data {
|
||||
var result = a
|
||||
let count = min(a.count, b.count)
|
||||
for i in 0 ..< count {
|
||||
result[i] ^= b[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func propagateDecoded(key: String, wrapper: BlockWrapper) {
|
||||
var block = wrapper.block
|
||||
var indices = block.indices
|
||||
var indicesSet = Set(indices)
|
||||
|
||||
if encodedBlockKeyMap[key] != nil || indices.allSatisfy({ decodedData[$0] != nil }) {
|
||||
return
|
||||
}
|
||||
|
||||
// XOR with already decoded blocks to reduce degree
|
||||
if indices.count > 1 {
|
||||
for index in indices {
|
||||
if let decoded = decodedData[index] {
|
||||
block.data = xorData(block.data, decoded)
|
||||
indicesSet.remove(index)
|
||||
}
|
||||
}
|
||||
if indicesSet.count != indices.count {
|
||||
indices = Array(indicesSet).sorted()
|
||||
block.indices = indices
|
||||
}
|
||||
}
|
||||
|
||||
// Try subset matching for blocks with degree > 2
|
||||
if indices.count > 2 {
|
||||
var subkeys: [(index: Int, subkey: String)] = []
|
||||
for index in indices {
|
||||
let subIndices = indices.filter { $0 != index }
|
||||
let subkey = indicesToKey(subIndices)
|
||||
if let subWrapper = encodedBlockKeyMap[subkey] {
|
||||
block.data = xorData(block.data, subWrapper.block.data)
|
||||
for i in subWrapper.block.indices {
|
||||
indicesSet.remove(i)
|
||||
}
|
||||
indices = Array(indicesSet).sorted()
|
||||
block.indices = indices
|
||||
subkeys.removeAll()
|
||||
break
|
||||
} else {
|
||||
subkeys.append((index, subkey))
|
||||
}
|
||||
}
|
||||
|
||||
// Store subkeys for future matching if still high degree
|
||||
if indicesSet.count > 1 {
|
||||
for (index, subkey) in subkeys {
|
||||
let dispose = { [weak self] in
|
||||
self?.encodedBlockSubkeyMap[subkey]?.remove(wrapper)
|
||||
}
|
||||
if encodedBlockSubkeyMap[subkey] == nil {
|
||||
encodedBlockSubkeyMap[subkey] = []
|
||||
}
|
||||
encodedBlockSubkeyMap[subkey]?.insert(wrapper)
|
||||
if disposedEncodedBlocks[index] == nil {
|
||||
disposedEncodedBlocks[index] = []
|
||||
}
|
||||
disposedEncodedBlocks[index]?.append(dispose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.block = block
|
||||
|
||||
// If still degree > 1, store as pending
|
||||
if indices.count > 1 {
|
||||
encodedBlocks.insert(wrapper)
|
||||
for i in indices {
|
||||
if encodedBlockIndexMap[i] == nil {
|
||||
encodedBlockIndexMap[i] = []
|
||||
}
|
||||
encodedBlockIndexMap[i]?.insert(wrapper)
|
||||
}
|
||||
|
||||
let newKey = indicesToKey(indices)
|
||||
encodedBlockKeyMap[newKey] = wrapper
|
||||
|
||||
// Check if this can decode pending supersets
|
||||
if let superset = encodedBlockSubkeyMap[newKey] {
|
||||
encodedBlockSubkeyMap.removeValue(forKey: newKey)
|
||||
for superWrapper in superset {
|
||||
var superBlock = superWrapper.block
|
||||
superBlock.data = xorData(superBlock.data, block.data)
|
||||
var superIndicesSet = Set(superBlock.indices)
|
||||
for i in indices {
|
||||
superIndicesSet.remove(i)
|
||||
}
|
||||
superBlock.indices = Array(superIndicesSet).sorted()
|
||||
superWrapper.block = superBlock
|
||||
propagateDecoded(key: indicesToKey(superBlock.indices), wrapper: superWrapper)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Degree 1: directly decode
|
||||
else if let index = indices.first, decodedData[index] == nil {
|
||||
encodedBlocks.remove(wrapper)
|
||||
disposedEncodedBlocks[index]?.forEach { $0() }
|
||||
decodedData[index] = block.data
|
||||
decodedCount += 1
|
||||
|
||||
// Propagate to waiting blocks
|
||||
if let waitingBlocks = encodedBlockIndexMap[index] {
|
||||
encodedBlockIndexMap.removeValue(forKey: index)
|
||||
for waiting in waitingBlocks {
|
||||
let waitingKey = indicesToKey(waiting.block.indices)
|
||||
encodedBlockKeyMap.removeValue(forKey: waitingKey)
|
||||
propagateDecoded(key: waitingKey, wrapper: waiting)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getDecoded() throws -> Data {
|
||||
guard decodedCount == k else {
|
||||
throw DecoderError.incomplete
|
||||
}
|
||||
guard decodedData.allSatisfy({ $0 != nil }) else {
|
||||
throw DecoderError.incomplete
|
||||
}
|
||||
guard let meta else {
|
||||
throw DecoderError.noMeta
|
||||
}
|
||||
|
||||
let sliceSize = meta.data.count
|
||||
var result = Data(capacity: meta.bytes)
|
||||
|
||||
for (i, block) in decodedData.enumerated() {
|
||||
guard let block else { continue }
|
||||
let start = i * sliceSize
|
||||
let copyLength = min(sliceSize, meta.bytes - start)
|
||||
if copyLength > 0 {
|
||||
result.append(block.prefix(copyLength))
|
||||
}
|
||||
}
|
||||
|
||||
// Try decompression
|
||||
if let decompressed = Self.inflate(result) {
|
||||
let checksum = CRC32.checksum(decompressed, k: meta.k)
|
||||
if checksum == meta.checksum {
|
||||
return decompressed
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to uncompressed
|
||||
let checksum = CRC32.checksum(result, k: meta.k)
|
||||
if checksum == meta.checksum {
|
||||
return result
|
||||
}
|
||||
|
||||
throw DecoderError.checksumMismatch
|
||||
}
|
||||
|
||||
private static func inflate(_ data: Data) -> Data? {
|
||||
let sourceSize = data.count
|
||||
let destinationSize = sourceSize * 10
|
||||
|
||||
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: destinationSize)
|
||||
defer { destinationBuffer.deallocate() }
|
||||
|
||||
let decompressedSize = data.withUnsafeBytes { sourcePtr -> Int in
|
||||
guard let baseAddress = sourcePtr.baseAddress else { return 0 }
|
||||
return compression_decode_buffer(
|
||||
destinationBuffer,
|
||||
destinationSize,
|
||||
baseAddress.assumingMemoryBound(to: UInt8.self),
|
||||
sourceSize,
|
||||
nil,
|
||||
COMPRESSION_ZLIB
|
||||
)
|
||||
}
|
||||
|
||||
guard decompressedSize > 0 else { return nil }
|
||||
return Data(bytes: destinationBuffer, count: decompressedSize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Compression
|
||||
import Foundation
|
||||
|
||||
final class LubyTransformEncoder {
|
||||
let k: Int
|
||||
let sliceSize: Int
|
||||
let checksum: UInt32
|
||||
let bytes: Int
|
||||
private let sourceBlocks: [Data]
|
||||
|
||||
init(data: Data, sliceSize: Int = 500, compress: Bool = true) {
|
||||
self.sliceSize = sliceSize
|
||||
|
||||
let compressed: Data
|
||||
if compress {
|
||||
compressed = Self.deflateCompress(data) ?? data
|
||||
} else {
|
||||
compressed = data
|
||||
}
|
||||
|
||||
bytes = compressed.count
|
||||
sourceBlocks = Self.sliceData(compressed, sliceSize: sliceSize)
|
||||
k = sourceBlocks.count
|
||||
checksum = CRC32.checksum(data, k: k)
|
||||
}
|
||||
|
||||
private static func deflateCompress(_ data: Data) -> Data? {
|
||||
let sourceSize = data.count
|
||||
let destinationSize = sourceSize + 1024
|
||||
|
||||
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: destinationSize)
|
||||
defer { destinationBuffer.deallocate() }
|
||||
|
||||
let compressedSize = data.withUnsafeBytes { sourcePtr -> Int in
|
||||
guard let baseAddress = sourcePtr.baseAddress else { return 0 }
|
||||
return compression_encode_buffer(
|
||||
destinationBuffer,
|
||||
destinationSize,
|
||||
baseAddress.assumingMemoryBound(to: UInt8.self),
|
||||
sourceSize,
|
||||
nil,
|
||||
COMPRESSION_ZLIB
|
||||
)
|
||||
}
|
||||
|
||||
guard compressedSize > 0 else { return nil }
|
||||
return Data(bytes: destinationBuffer, count: compressedSize)
|
||||
}
|
||||
|
||||
private static func sliceData(_ data: Data, sliceSize: Int) -> [Data] {
|
||||
var blocks: [Data] = []
|
||||
var offset = 0
|
||||
while offset < data.count {
|
||||
let end = min(offset + sliceSize, data.count)
|
||||
var block = data.subdata(in: offset ..< end)
|
||||
if block.count < sliceSize {
|
||||
block.append(Data(count: sliceSize - block.count))
|
||||
}
|
||||
blocks.append(block)
|
||||
offset += sliceSize
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func createBlock(indices: [Int]) -> EncodedBlock {
|
||||
var result = Data(count: sliceSize)
|
||||
for index in indices {
|
||||
let source = sourceBlocks[index]
|
||||
for i in 0 ..< sliceSize {
|
||||
result[i] ^= source[i]
|
||||
}
|
||||
}
|
||||
return EncodedBlock(
|
||||
indices: indices,
|
||||
data: result,
|
||||
k: k,
|
||||
bytes: bytes,
|
||||
checksum: checksum
|
||||
)
|
||||
}
|
||||
|
||||
// Ideal Soliton Distribution for degree selection
|
||||
private func getRandomDegree() -> Int {
|
||||
var probabilities = [Double](repeating: 0, count: k)
|
||||
probabilities[0] = 1.0 / Double(k)
|
||||
for d in 2 ... k {
|
||||
probabilities[d - 1] = 1.0 / Double(d * (d - 1))
|
||||
}
|
||||
|
||||
var cumulative = [Double](repeating: 0, count: k)
|
||||
cumulative[0] = probabilities[0]
|
||||
for i in 1 ..< k {
|
||||
cumulative[i] = cumulative[i - 1] + probabilities[i]
|
||||
}
|
||||
|
||||
let random = Double.random(in: 0 ... 1)
|
||||
for i in 0 ..< k {
|
||||
if random < cumulative[i] {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
private func getRandomIndices(degree: Int) -> [Int] {
|
||||
var indices = Set<Int>()
|
||||
while indices.count < degree {
|
||||
indices.insert(Int.random(in: 0 ..< k))
|
||||
}
|
||||
return Array(indices)
|
||||
}
|
||||
|
||||
func fountain() -> AnyIterator<EncodedBlock> {
|
||||
AnyIterator {
|
||||
let degree = self.getRandomDegree()
|
||||
let indices = self.getRandomIndices(degree: degree)
|
||||
return self.createBlock(indices: indices)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user