From 313cb5d213dcf1663329bf686b0857f1f111fd3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 1 Jan 2026 20:25:28 +0800 Subject: [PATCH] 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. --- ApplicationLibrary/LubyTransform/CRC32.swift | 28 ++ .../LubyTransform/EncodedBlock.swift | 87 ++++++ .../LubyTransform/LubyTransformDecoder.swift | 251 +++++++++++++++++ .../LubyTransform/LubyTransformEncoder.swift | 120 ++++++++ .../Views/Dashboard/Cards/ProfileCard.swift | 70 ++++- .../Dashboard/Cards/ProfilePickerSheet.swift | 214 ++++++++++++-- .../Views/Profile/QRSDisplayView.swift | 193 +++++++++++++ .../Scanner/QRSScannerController+iOS.swift | 249 +++++++++++++++++ .../Scanner/QRSScannerController+macOS.swift | 261 ++++++++++++++++++ .../Views/Scanner/QRSScannerView.swift | 169 ++++++++++++ Library/Database/Profile+Transferable.swift | 58 ++++ 11 files changed, 1674 insertions(+), 26 deletions(-) create mode 100644 ApplicationLibrary/LubyTransform/CRC32.swift create mode 100644 ApplicationLibrary/LubyTransform/EncodedBlock.swift create mode 100644 ApplicationLibrary/LubyTransform/LubyTransformDecoder.swift create mode 100644 ApplicationLibrary/LubyTransform/LubyTransformEncoder.swift create mode 100644 ApplicationLibrary/Views/Profile/QRSDisplayView.swift create mode 100644 ApplicationLibrary/Views/Scanner/QRSScannerController+iOS.swift create mode 100644 ApplicationLibrary/Views/Scanner/QRSScannerController+macOS.swift create mode 100644 ApplicationLibrary/Views/Scanner/QRSScannerView.swift diff --git a/ApplicationLibrary/LubyTransform/CRC32.swift b/ApplicationLibrary/LubyTransform/CRC32.swift new file mode 100644 index 0000000..cbb6f0b --- /dev/null +++ b/ApplicationLibrary/LubyTransform/CRC32.swift @@ -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 + } +} diff --git a/ApplicationLibrary/LubyTransform/EncodedBlock.swift b/ApplicationLibrary/LubyTransform/EncodedBlock.swift new file mode 100644 index 0000000..253722b --- /dev/null +++ b/ApplicationLibrary/LubyTransform/EncodedBlock.swift @@ -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) + } +} diff --git a/ApplicationLibrary/LubyTransform/LubyTransformDecoder.swift b/ApplicationLibrary/LubyTransform/LubyTransformDecoder.swift new file mode 100644 index 0000000..f89a802 --- /dev/null +++ b/ApplicationLibrary/LubyTransform/LubyTransformDecoder.swift @@ -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 = [] + private var encodedBlockKeyMap: [String: BlockWrapper] = [:] + private var encodedBlockSubkeyMap: [String: Set] = [:] + private var encodedBlockIndexMap: [Int: Set] = [:] + 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.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) + } +} diff --git a/ApplicationLibrary/LubyTransform/LubyTransformEncoder.swift b/ApplicationLibrary/LubyTransform/LubyTransformEncoder.swift new file mode 100644 index 0000000..1d769b8 --- /dev/null +++ b/ApplicationLibrary/LubyTransform/LubyTransformEncoder.swift @@ -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.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() + while indices.count < degree { + indices.insert(Int.random(in: 0 ..< k)) + } + return Array(indices) + } + + func fountain() -> AnyIterator { + AnyIterator { + let degree = self.getRandomDegree() + let indices = self.getRandomIndices(degree: degree) + return self.createBlock(indices: indices) + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift index 0d770c8..8a6714f 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift @@ -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 diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift index 6c9a11e..aee98f0 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift @@ -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) { diff --git a/ApplicationLibrary/Views/Profile/QRSDisplayView.swift b/ApplicationLibrary/Views/Profile/QRSDisplayView.swift new file mode 100644 index 0000000..832a23b --- /dev/null +++ b/ApplicationLibrary/Views/Profile/QRSDisplayView.swift @@ -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 + } +} diff --git a/ApplicationLibrary/Views/Scanner/QRSScannerController+iOS.swift b/ApplicationLibrary/Views/Scanner/QRSScannerController+iOS.swift new file mode 100644 index 0000000..7d1c59f --- /dev/null +++ b/ApplicationLibrary/Views/Scanner/QRSScannerController+iOS.swift @@ -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() + + 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 diff --git a/ApplicationLibrary/Views/Scanner/QRSScannerController+macOS.swift b/ApplicationLibrary/Views/Scanner/QRSScannerController+macOS.swift new file mode 100644 index 0000000..c5a5819 --- /dev/null +++ b/ApplicationLibrary/Views/Scanner/QRSScannerController+macOS.swift @@ -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() + + 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 diff --git a/ApplicationLibrary/Views/Scanner/QRSScannerView.swift b/ApplicationLibrary/Views/Scanner/QRSScannerView.swift new file mode 100644 index 0000000..5e2ed14 --- /dev/null +++ b/ApplicationLibrary/Views/Scanner/QRSScannerView.swift @@ -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 diff --git a/Library/Database/Profile+Transferable.swift b/Library/Database/Profile+Transferable.swift index 6bfab3d..0a920f7 100644 --- a/Library/Database/Profile+Transferable.swift +++ b/Library/Database/Profile+Transferable.swift @@ -109,3 +109,61 @@ public struct TypedProfile: Transferable, Codable { public extension UTType { static var profile: UTType { .init(exportedAs: "io.nekohasekai.sfavt.profile") } } + +// MARK: - FileDocument for Export + +public struct ProfileExportDocument: FileDocument { + public static var readableContentTypes: [UTType] { [.profile] } + + public let data: Data + public let filename: String + + public init(content: LibboxProfileContent) throws { + guard let encoded = content.encode() else { + throw NSError(domain: "ProfileExportDocument", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode profile"]) + } + data = encoded + filename = "\(content.name).bpf" + } + + public init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents else { + throw CocoaError(.fileReadCorruptFile) + } + self.data = data + filename = "profile.bpf" + } + + public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} + +public struct ProfileJSONExportDocument: FileDocument { + public static var readableContentTypes: [UTType] { [.json] } + + public let content: String + public let filename: String + + public init(jsonContent: String, name: String) { + content = jsonContent + filename = "\(name).json" + } + + public init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents, + let content = String(data: data, encoding: .utf8) + else { + throw CocoaError(.fileReadCorruptFile) + } + self.content = content + filename = "profile.json" + } + + public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { + guard let data = content.data(using: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) + } + return FileWrapper(regularFileWithContents: data) + } +}