diff --git a/ApplicationLibrary/LubyTransform/BinaryMeta.swift b/ApplicationLibrary/LubyTransform/BinaryMeta.swift new file mode 100644 index 0000000..20e1c0b --- /dev/null +++ b/ApplicationLibrary/LubyTransform/BinaryMeta.swift @@ -0,0 +1,89 @@ +import Foundation + +enum BinaryMeta { + enum MetaError: Error { + case invalidBuffer + case invalidMeta + } + + struct FileHeaderMeta: Codable { + var filename: String? + var contentType: String? + } + + static func mergeDataArrays(_ arrays: [Data]) -> Data { + var totalLength = 0 + for arr in arrays { + totalLength += 4 + arr.count + } + + var merged = Data(capacity: totalLength) + for arr in arrays { + let length = UInt32(arr.count) + var bytes: [UInt8] = [ + UInt8((length >> 24) & 0xFF), + UInt8((length >> 16) & 0xFF), + UInt8((length >> 8) & 0xFF), + UInt8(length & 0xFF), + ] + merged.append(contentsOf: bytes) + merged.append(arr) + } + + return merged + } + + static func splitDataArrays(_ merged: Data) throws -> [Data] { + var arrays: [Data] = [] + var offset = 0 + + while offset < merged.count { + guard offset + 4 <= merged.count else { + throw MetaError.invalidBuffer + } + + let length = merged.withUnsafeBytes { ptr -> Int in + let b0 = Int(ptr[offset]) << 24 + let b1 = Int(ptr[offset + 1]) << 16 + let b2 = Int(ptr[offset + 2]) << 8 + let b3 = Int(ptr[offset + 3]) + return b0 | b1 | b2 | b3 + } + offset += 4 + + guard offset + length <= merged.count else { + throw MetaError.invalidBuffer + } + + let arr = merged.subdata(in: offset ..< offset + length) + arrays.append(arr) + offset += length + } + + return arrays + } + + static func appendFileHeaderMeta(data: Data, filename: String?, contentType: String) -> Data { + let meta = FileHeaderMeta(filename: filename, contentType: contentType) + guard let metaData = try? JSONEncoder().encode(meta) else { + return data + } + return mergeDataArrays([metaData, data]) + } + + static func readFileHeaderMeta(buffer: Data) throws -> (data: Data, filename: String?, contentType: String) { + let arrays = try splitDataArrays(buffer) + guard arrays.count == 2 else { + throw MetaError.invalidBuffer + } + + let metaData = arrays[0] + let data = arrays[1] + + guard let meta = try? JSONDecoder().decode(FileHeaderMeta.self, from: metaData) else { + throw MetaError.invalidMeta + } + + return (data, meta.filename, meta.contentType ?? "application/octet-stream") + } +} diff --git a/ApplicationLibrary/LubyTransform/EncodedBlock.swift b/ApplicationLibrary/LubyTransform/EncodedBlock.swift index 253722b..790b583 100644 --- a/ApplicationLibrary/LubyTransform/EncodedBlock.swift +++ b/ApplicationLibrary/LubyTransform/EncodedBlock.swift @@ -1,6 +1,7 @@ import Foundation struct EncodedBlock { + static let qrsURLPrefix = "https://qrss.netlify.app/#" var indices: [Int] var data: Data let k: Int @@ -80,8 +81,38 @@ struct EncodedBlock { toBinary().base64EncodedString() } + func toQRSString() -> String { + Self.qrsURLPrefix + toBase64() + } + + static func fromQRSString(_ string: String) -> EncodedBlock? { + var content = string + if content.hasPrefix("http"), let hashIndex = content.firstIndex(of: "#") { + content = String(content[content.index(after: hashIndex)...]) + } + return fromBase64(content) + } + static func fromBase64(_ string: String) -> EncodedBlock? { - guard let data = Data(base64Encoded: string) else { return nil } - return fromBinary(data) + guard let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) else { + #if DEBUG + print("[EncodedBlock] Base64 decode failed for string of length \(string.count)") + #endif + return nil + } + #if DEBUG + print("[EncodedBlock] Base64 decoded: \(data.count) bytes") + #endif + guard let block = fromBinary(data) else { + #if DEBUG + print("[EncodedBlock] fromBinary failed") + if data.count >= 4 { + let degree = data.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self).littleEndian } + print("[EncodedBlock] degree=\(degree), expected size=\(4 + Int(degree) * 4 + 12), actual=\(data.count)") + } + #endif + return nil + } + return block } } diff --git a/ApplicationLibrary/LubyTransform/LubyTransformDecoder.swift b/ApplicationLibrary/LubyTransform/LubyTransformDecoder.swift index f89a802..25286fb 100644 --- a/ApplicationLibrary/LubyTransform/LubyTransformDecoder.swift +++ b/ApplicationLibrary/LubyTransform/LubyTransformDecoder.swift @@ -1,5 +1,5 @@ -import Compression import Foundation +import zlib final class LubyTransformDecoder { private(set) var decodedData: [Data?] = [] @@ -66,16 +66,29 @@ final class LubyTransformDecoder { 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] + private func xorDataInPlace(_ dest: inout Data, _ src: Data) { + let count = min(dest.count, src.count) + dest.withUnsafeMutableBytes { destPtr in + src.withUnsafeBytes { srcPtr in + let d = destPtr.bindMemory(to: UInt8.self).baseAddress! + let s = srcPtr.bindMemory(to: UInt8.self).baseAddress! + for i in 0 ..< count { + d[i] ^= s[i] + } + } } - return result } private func propagateDecoded(key: String, wrapper: BlockWrapper) { + var queue: [(key: String, wrapper: BlockWrapper)] = [(key, wrapper)] + + while !queue.isEmpty { + let (currentKey, currentWrapper) = queue.removeFirst() + processBlock(key: currentKey, wrapper: currentWrapper, queue: &queue) + } + } + + private func processBlock(key: String, wrapper: BlockWrapper, queue: inout [(key: String, wrapper: BlockWrapper)]) { var block = wrapper.block var indices = block.indices var indicesSet = Set(indices) @@ -88,7 +101,7 @@ final class LubyTransformDecoder { if indices.count > 1 { for index in indices { if let decoded = decodedData[index] { - block.data = xorData(block.data, decoded) + xorDataInPlace(&block.data, decoded) indicesSet.remove(index) } } @@ -105,7 +118,7 @@ final class LubyTransformDecoder { let subIndices = indices.filter { $0 != index } let subkey = indicesToKey(subIndices) if let subWrapper = encodedBlockKeyMap[subkey] { - block.data = xorData(block.data, subWrapper.block.data) + xorDataInPlace(&block.data, subWrapper.block.data) for i in subWrapper.block.indices { indicesSet.remove(i) } @@ -121,8 +134,8 @@ final class LubyTransformDecoder { // 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) + let dispose: () -> Void = { [weak self] in + _ = self?.encodedBlockSubkeyMap[subkey]?.remove(wrapper) } if encodedBlockSubkeyMap[subkey] == nil { encodedBlockSubkeyMap[subkey] = [] @@ -156,14 +169,14 @@ final class LubyTransformDecoder { encodedBlockSubkeyMap.removeValue(forKey: newKey) for superWrapper in superset { var superBlock = superWrapper.block - superBlock.data = xorData(superBlock.data, block.data) + xorDataInPlace(&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) + queue.append((indicesToKey(superBlock.indices), superWrapper)) } } } @@ -180,7 +193,7 @@ final class LubyTransformDecoder { for waiting in waitingBlocks { let waitingKey = indicesToKey(waiting.block.indices) encodedBlockKeyMap.removeValue(forKey: waitingKey) - propagateDecoded(key: waitingKey, wrapper: waiting) + queue.append((waitingKey, waiting)) } } } @@ -227,25 +240,48 @@ final class LubyTransformDecoder { } private static func inflate(_ data: Data) -> Data? { - let sourceSize = data.count - let destinationSize = sourceSize * 10 + var stream = z_stream() - 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 - ) + // Use 15 for zlib format (with header/trailer) to match pako's default + guard inflateInit2_( + &stream, + 15, + ZLIB_VERSION, + Int32(MemoryLayout.size) + ) == Z_OK else { + return nil } + defer { inflateEnd(&stream) } - guard decompressedSize > 0 else { return nil } - return Data(bytes: destinationBuffer, count: decompressedSize) + var destCapacity = data.count * 4 + var dest = Data(count: destCapacity) + + return data.withUnsafeBytes { srcPtr -> Data? in + stream.next_in = UnsafeMutablePointer(mutating: srcPtr.bindMemory(to: Bytef.self).baseAddress) + stream.avail_in = uInt(data.count) + + while true { + dest.withUnsafeMutableBytes { destPtr in + stream.next_out = destPtr.bindMemory(to: Bytef.self).baseAddress?.advanced(by: Int(stream.total_out)) + stream.avail_out = uInt(destCapacity - Int(stream.total_out)) + } + + let result = zlib.inflate(&stream, Z_NO_FLUSH) + + if result == Z_STREAM_END { + dest.count = Int(stream.total_out) + return dest + } + + if result != Z_OK { + return nil + } + + if stream.avail_out == 0 { + destCapacity *= 2 + dest.count = destCapacity + } + } + } } } diff --git a/ApplicationLibrary/LubyTransform/LubyTransformEncoder.swift b/ApplicationLibrary/LubyTransform/LubyTransformEncoder.swift index 1d769b8..0eeecdb 100644 --- a/ApplicationLibrary/LubyTransform/LubyTransformEncoder.swift +++ b/ApplicationLibrary/LubyTransform/LubyTransformEncoder.swift @@ -1,5 +1,5 @@ -import Compression import Foundation +import zlib final class LubyTransformEncoder { let k: Int @@ -8,7 +8,7 @@ final class LubyTransformEncoder { let bytes: Int private let sourceBlocks: [Data] - init(data: Data, sliceSize: Int = 500, compress: Bool = true) { + init(data: Data, sliceSize: Int = 512, compress: Bool = true) { self.sliceSize = sliceSize let compressed: Data @@ -25,26 +25,39 @@ final class LubyTransformEncoder { } private static func deflateCompress(_ data: Data) -> Data? { - let sourceSize = data.count - let destinationSize = sourceSize + 1024 + var stream = z_stream() - let destinationBuffer = UnsafeMutablePointer.allocate(capacity: destinationSize) - defer { destinationBuffer.deallocate() } + // Use 15 for zlib format (with header/trailer) to match pako's default + guard deflateInit2_( + &stream, + Z_DEFAULT_COMPRESSION, + Z_DEFLATED, + 15, + 8, + Z_DEFAULT_STRATEGY, + ZLIB_VERSION, + Int32(MemoryLayout.size) + ) == Z_OK else { + return nil + } + defer { deflateEnd(&stream) } - 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 - ) + let destSize = Int(deflateBound(&stream, UInt(data.count))) + var dest = Data(count: destSize) + + let result = data.withUnsafeBytes { srcPtr -> Int32 in + dest.withUnsafeMutableBytes { destPtr -> Int32 in + stream.next_in = UnsafeMutablePointer(mutating: srcPtr.bindMemory(to: Bytef.self).baseAddress) + stream.avail_in = uInt(data.count) + stream.next_out = destPtr.bindMemory(to: Bytef.self).baseAddress + stream.avail_out = uInt(destSize) + return deflate(&stream, Z_FINISH) + } } - guard compressedSize > 0 else { return nil } - return Data(bytes: destinationBuffer, count: compressedSize) + guard result == Z_STREAM_END else { return nil } + dest.count = Int(stream.total_out) + return dest } private static func sliceData(_ data: Data, sliceSize: Int) -> [Data] { @@ -94,10 +107,8 @@ final class LubyTransformEncoder { } let random = Double.random(in: 0 ... 1) - for i in 0 ..< k { - if random < cumulative[i] { - return i + 1 - } + if let i = cumulative.firstIndex(where: { random < $0 }) { + return i + 1 } return k } @@ -117,4 +128,78 @@ final class LubyTransformEncoder { return self.createBlock(indices: indices) } } + + #if DEBUG + static func runSelfTest() -> Bool { + // Test 1: Round-trip tests with various sizes + let testCases: [(size: Int, sliceSize: Int)] = [ + (1, 100), + (100, 100), + (1000, 100), + (1031, 100), + ] + + for (size, sliceSize) in testCases { + let data = Data((0 ..< size).map { UInt8($0 % 256) }) + let encoder = LubyTransformEncoder(data: data, sliceSize: sliceSize, compress: true) + let decoder = LubyTransformDecoder() + + var blockCount = 0 + for block in encoder.fountain() { + blockCount += 1 + if blockCount > encoder.k * 3 { + print("LubyTransform self-test FAILED: too many blocks for size=\(size)") + return false + } + do { + if try decoder.addBlock(block) { break } + } catch { + print("LubyTransform self-test FAILED: \(error)") + return false + } + } + + do { + let decoded = try decoder.getDecoded() + if decoded != data { + print("LubyTransform self-test FAILED: data mismatch for size=\(size)") + return false + } + } catch { + print("LubyTransform self-test FAILED: \(error)") + return false + } + } + + // Test 2: TypeScript compatibility (decode blocks generated by TypeScript) + // Test vector: 5 bytes [0xAB, 0xCD, 0xEF, 0x12, 0x34], uncompressed, sliceSize=10 + let tsBlockBase64 = "AQAAAAAAAAABAAAABQAAALt8DL6rze8SNAAAAAAA" + let expectedData = Data([0xAB, 0xCD, 0xEF, 0x12, 0x34]) + + guard let blockData = Data(base64Encoded: tsBlockBase64), + let block = EncodedBlock.fromBinary(blockData) + else { + print("LubyTransform self-test FAILED: cannot parse TypeScript block") + return false + } + + let tsDecoder = LubyTransformDecoder() + do { + _ = try tsDecoder.addBlock(block) + let decoded = try tsDecoder.getDecoded() + if decoded != expectedData { + print("LubyTransform self-test FAILED: TypeScript compatibility mismatch") + print("Expected: \(expectedData.map { String(format: "%02X", $0) }.joined())") + print("Got: \(decoded.map { String(format: "%02X", $0) }.joined())") + return false + } + } catch { + print("LubyTransform self-test FAILED: TypeScript decode error: \(error)") + return false + } + + print("LubyTransform self-test PASSED (including TypeScript compatibility)") + return true + } + #endif } diff --git a/ApplicationLibrary/LubyTransform/QRSImageGenerator.swift b/ApplicationLibrary/LubyTransform/QRSImageGenerator.swift new file mode 100644 index 0000000..989fc93 --- /dev/null +++ b/ApplicationLibrary/LubyTransform/QRSImageGenerator.swift @@ -0,0 +1,96 @@ +import CoreGraphics +import Foundation +import QRCode + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +@MainActor +final class QRSImageGenerator: ObservableObject { + @Published private(set) var currentImage: CGImage? + + private let bufferSize: Int + private let foregroundColor: CGColor + private let backgroundColor: CGColor + private let imageDimension: Int + + private var frames: [EncodedBlock] = [] + private var imageBuffer: [CGImage?] + private var generatedUpTo: Int = -1 + private var currentFrameIndex: Int = 0 + private var expectedTotalFrames: Int = 0 + + init( + foregroundColor: CGColor, + backgroundColor: CGColor = CGColor(gray: 1.0, alpha: 1.0), + bufferSize: Int = 30, + imageDimension: Int = 512 + ) { + self.foregroundColor = foregroundColor + self.backgroundColor = backgroundColor + self.bufferSize = bufferSize + self.imageDimension = imageDimension + imageBuffer = Array(repeating: nil, count: bufferSize) + } + + func setExpectedFrames(_ count: Int) { + expectedTotalFrames = count + } + + func addFrame(_ block: EncodedBlock) async { + let image = await generateImage(for: block) + + let index = frames.count + frames.append(block) + + let bufferIndex = index % bufferSize + imageBuffer[bufferIndex] = image + generatedUpTo = index + + if index == 0 { + currentImage = image + } + } + + func advanceFrame() { + guard generatedUpTo >= 0 else { return } + + let totalFrames = expectedTotalFrames > 0 ? expectedTotalFrames : frames.count + guard totalFrames > 0 else { return } + + let nextIndex = (currentFrameIndex + 1) % totalFrames + if nextIndex <= generatedUpTo || generatedUpTo == totalFrames - 1 { + currentFrameIndex = nextIndex + } + + let bufferIndex = currentFrameIndex % bufferSize + currentImage = imageBuffer[bufferIndex] + } + + func cancel() {} + + private nonisolated func generateImage(for block: EncodedBlock) async -> CGImage? { + let content = block.toQRSString() + let foregroundColor = foregroundColor + let backgroundColor = backgroundColor + let dimension = imageDimension + + return await Task.detached(priority: .userInitiated) { + do { + let document = try QRCode.Document( + utf8String: content, + errorCorrection: .low + ) + document.design.foregroundColor(foregroundColor) + document.design.backgroundColor(backgroundColor) + document.design.additionalQuietZonePixels = 4 + return try document.cgImage(dimension: dimension) + } catch { + return nil + } + }.value + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift index 8a6714f..7bb6c21 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift @@ -78,6 +78,11 @@ public struct ProfileCard: View { QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) } } + .sheet(isPresented: $viewModel.showQRSShare) { + if let profile = selectedProfile, let data = try? profile.origin.toContent().encode() { + QRSSheet(profileName: profile.name, profileData: data) + } + } #else .sheet(isPresented: $viewModel.showNewProfile, onDismiss: { environments.profileUpdate.send() @@ -98,6 +103,11 @@ public struct ProfileCard: View { } } #endif + .sheet(isPresented: $viewModel.showQRSShare) { + if let profile = selectedProfile, let data = try? profile.origin.toContent().encode() { + QRSSheet(profileName: profile.name, profileData: data) + } + } .fileExporter( isPresented: $viewModel.showProfileExporter, document: viewModel.profileExportDocument, @@ -250,20 +260,26 @@ public struct ProfileCard: View { @ViewBuilder private func shareMenu(for profile: ProfilePreview) -> some View { #if os(tvOS) - if profile.type == .remote { - Menu { + Menu { + if profile.type == .remote { Button { viewModel.showQRCode = true } label: { Label("Share URL as QR Code", systemImage: "qrcode") } - } label: { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 16)) } - .buttonStyle(.plain) - .actionButtonStyle() + + Button { + viewModel.showQRSShare = true + } label: { + Label("Share as QRS Code", systemImage: "barcode") + } + } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 16)) } + .buttonStyle(.plain) + .actionButtonStyle() #else Menu { Button { @@ -297,9 +313,17 @@ public struct ProfileCard: View { Label("Share URL as QR Code", systemImage: "qrcode") } } + + Button { + viewModel.showQRSShare = true + } label: { + Label("Share as QRS Code", systemImage: "barcode") + } } label: { Image(systemName: "square.and.arrow.up") .font(.system(size: 16)) + .frame(width: 44, height: 32) + .contentShape(Rectangle()) } .menuIndicator(.hidden) .foregroundStyle(.primary) @@ -351,10 +375,16 @@ public struct ProfileCard: View { switch type { case .file: viewModel.profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent()) - viewModel.showProfileExporter = true case .json: - viewModel.profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name) - viewModel.showJSONExporter = true + viewModel.profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) + } + DispatchQueue.main.async { + switch type { + case .file: + viewModel.showProfileExporter = true + case .json: + viewModel.showJSONExporter = true + } } } catch { viewModel.alert = AlertState(error: error) @@ -462,6 +492,7 @@ extension ProfileCard { @Published var showNewProfile = false @Published var showProfilePicker = false @Published var showQRCode = false + @Published var showQRSShare = false @Published var isUpdating = false @Published var alert: AlertState? @Published var profileToEdit: Profile? diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift index aee98f0..34d6eae 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift @@ -531,6 +531,7 @@ private struct ProfilePickerRow: View { @State private var isUpdating = false @State private var showQRCode = false + @State private var showQRSShare = false #if os(macOS) @State private var shareItemType: ShareItemType? @State private var exportItemType: ExportItemType? @@ -569,6 +570,11 @@ private struct ProfilePickerRow: View { QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) } } + .sheet(isPresented: $showQRSShare) { + if let data = try? profile.origin.toContent().encode() { + QRSSheet(profileName: profile.name, profileData: data) + } + } } private var tvOSNormalBody: some View { @@ -615,16 +621,24 @@ private struct ProfilePickerRow: View { } label: { Label("Update", systemImage: "arrow.clockwise") } + } - Menu { + Menu { + if profile.type == .remote { Button { showQRCode = true } label: { Label("Share URL as QR Code", systemImage: "qrcode") } - } label: { - Label("Share", systemImage: "square.and.arrow.up") } + + Button { + showQRSShare = true + } label: { + Label("Share as QRS Code", systemImage: "barcode") + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") } } label: { Image(systemName: "ellipsis") @@ -708,6 +722,11 @@ private struct ProfilePickerRow: View { QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) } } + .sheet(isPresented: $showQRSShare) { + if let data = try? profile.origin.toContent().encode() { + QRSSheet(profileName: profile.name, profileData: data) + } + } .fileExporter( isPresented: $showProfileExporter, document: profileExportDocument, @@ -753,6 +772,11 @@ private struct ProfilePickerRow: View { QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) } } + .sheet(isPresented: $showQRSShare) { + if let data = try? profile.origin.toContent().encode() { + QRSSheet(profileName: profile.name, profileData: data) + } + } .fileExporter( isPresented: $showProfileExporter, document: profileExportDocument, @@ -952,6 +976,12 @@ private struct ProfilePickerRow: View { Label("Share URL as QR Code", systemImage: "qrcode") } } + + Button { + showQRSShare = true + } label: { + Label("Share as QRS Code", systemImage: "barcode") + } } label: { Label("Share", systemImage: "square.and.arrow.up") } @@ -962,10 +992,16 @@ private struct ProfilePickerRow: View { switch type { case .file: profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent()) - showProfileExporter = true case .json: - profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name) - showJSONExporter = true + profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) + } + DispatchQueue.main.async { + switch type { + case .file: + showProfileExporter = true + case .json: + showJSONExporter = true + } } } catch { alert = AlertState(error: error) @@ -1025,7 +1061,7 @@ private struct ProfilePickerRow: View { profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent()) showProfileExporter = true case .json: - profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name) + profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) showJSONExporter = true } } catch { @@ -1136,6 +1172,7 @@ private struct ProfilePickerRow: View { @State private var isUpdating = false @State private var showQRCode = false + @State private var showQRSShare = false @State private var profileExportDocument: ProfileExportDocument? @State private var showProfileExporter = false @State private var profileJSONExportDocument: ProfileJSONExportDocument? @@ -1197,6 +1234,11 @@ private struct ProfilePickerRow: View { QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) } } + .sheet(isPresented: $showQRSShare) { + if let data = try? profile.origin.toContent().encode() { + QRSSheet(profileName: profile.name, profileData: data) + } + } .fileExporter( isPresented: $showProfileExporter, document: profileExportDocument, @@ -1293,6 +1335,12 @@ private struct ProfilePickerRow: View { Label("Share URL as QR Code", systemImage: "qrcode") } } + + Button { + showQRSShare = true + } label: { + Label("Share as QRS Code", systemImage: "barcode") + } } label: { Label("Share", systemImage: "square.and.arrow.up") } @@ -1303,10 +1351,16 @@ private struct ProfilePickerRow: View { switch type { case .file: profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent()) - showProfileExporter = true case .json: - profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name) - showJSONExporter = true + profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) + } + DispatchQueue.main.async { + switch type { + case .file: + showProfileExporter = true + case .json: + showJSONExporter = true + } } } catch { alert = AlertState(error: error) diff --git a/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift b/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift index 1bf8d76..7c7d5d3 100644 --- a/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift +++ b/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift @@ -140,7 +140,7 @@ try content.config.write(to: profileConfig, atomically: true, encoding: .utf8) var lastUpdated: Date? if content.lastUpdated > 0 { - lastUpdated = Date(timeIntervalSince1970: Double(content.lastUpdated)) + lastUpdated = dateFromTimestamp(content.lastUpdated) } let uniqueProfileName = try await ProfileManager.uniqueName(content.name) let profile = Profile(name: uniqueProfileName, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated) diff --git a/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift b/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift index 42f63ac..a662825 100644 --- a/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift +++ b/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift @@ -231,8 +231,17 @@ public struct NewProfileMenuView: View { #if !os(tvOS) private func handleQRScanResult(_ result: QRScanResult) { + switch result { + case let .qrCode(string, _): + handleQRCodeString(string) + case let .qrsData(data): + handleQRSData(data) + } + } + + private func handleQRCodeString(_ string: String) { var error: NSError? - let remoteProfile = LibboxParseRemoteProfileImportLink(result.string, &error) + let remoteProfile = LibboxParseRemoteProfileImportLink(string, &error) if let error { alert = AlertState( title: String(localized: "Invalid QR Code"), @@ -249,5 +258,30 @@ public struct NewProfileMenuView: View { } importRequest = NewProfileView.ImportRequest(name: remoteProfile.name, url: remoteProfile.url) } + + private func handleQRSData(_ data: Data) { + do { + let (actualData, _, _) = try BinaryMeta.readFileHeaderMeta(buffer: data) + let content = try LibboxProfileContent.from(actualData) + alert = AlertState( + title: String(localized: "Import Profile"), + message: String(localized: "Are you sure to import profile \(content.name)?"), + primaryButton: .default(String(localized: "Import")) { + Task { + do { + try await content.importProfile() + environments.profileUpdate.send() + dismiss() + } catch { + alert = AlertState(error: error) + } + } + }, + secondaryButton: .cancel() + ) + } catch { + alert = AlertState(error: error) + } + } #endif } diff --git a/ApplicationLibrary/Views/Profile/ProfileView.swift b/ApplicationLibrary/Views/Profile/ProfileView.swift deleted file mode 100644 index 1ffa015..0000000 --- a/ApplicationLibrary/Views/Profile/ProfileView.swift +++ /dev/null @@ -1,282 +0,0 @@ -import Foundation -import Libbox -import Library -import Network -import SwiftUI - -@MainActor -public struct ProfileView: View { - @EnvironmentObject private var environments: ExtensionEnvironments - @Environment(\.importProfile) private var importProfile - @Environment(\.importRemoteProfile) private var importRemoteProfile - @StateObject private var viewModel = ProfileViewModel() - - #if os(tvOS) - @Environment(\.devicePickerSupports) private var devicePickerSupports - #endif - - public init() {} - public var body: some View { - VStack { - if viewModel.isLoading { - ProgressView().onAppear { - viewModel.setEnvironments(environments) - Task { - await viewModel.doReload() - } - } - } else { - ZStack { - if let importRemoteProfileRequest = viewModel.importRemoteProfileRequest { - NavigationDestinationCompat(isPresented: $viewModel.importRemoteProfilePresented) { - NewProfileView(importRemoteProfileRequest) - } - } - FormView { - #if os(iOS) - FormNavigationLink { - NewProfileView() - } label: { - Text("New Profile").foregroundColor(.accentColor) - } - .disabled(viewModel.editMode.isEditing) - #elseif os(macOS) - FormNavigationLink { - NewProfileView() - } label: { - Text("New Profile") - } - #elseif os(tvOS) - Section { - FormNavigationLink { - NewProfileView() - } label: { - Text("New Profile").foregroundColor(.accentColor) - } - if ApplicationLibrary.inPreview || devicePickerSupports(.applicationService(name: "sing-box"), parameters: { .applicationService }) { - FormNavigationLink { - ImportProfileView() - } label: { - Text("Import Profile").foregroundColor(.accentColor) - } - } - } - #endif - if viewModel.profileList.isEmpty { - Text("Empty profiles") - } else { - List { - ForEach(viewModel.profileList, id: \.id) { profile in - Group { - #if os(iOS) || os(tvOS) - if viewModel.editMode.isEditing == true { - Text(profile.name) - } else { - ProfileItem(viewModel, profile) - } - #else - ProfileItem(viewModel, profile) - #endif - } - } - .onMove(perform: moveProfile) - .onDelete(perform: deleteProfile) - } - } - } - } - } - } - .disabled(viewModel.isUpdating) - .alert($viewModel.alert, isLoading: $viewModel.isLoading) - .onAppear { - if let profile = importProfile.wrappedValue { - importProfile.wrappedValue = nil - viewModel.createImportProfileDialog(profile) - } - if let remoteProfile = importRemoteProfile.wrappedValue { - importRemoteProfile.wrappedValue = nil - viewModel.createImportRemoteProfileDialog(remoteProfile) - } - } - .onChangeCompat(of: importProfile.wrappedValue) { newValue in - if let newValue { - importProfile.wrappedValue = nil - viewModel.createImportProfileDialog(newValue) - } - } - .onChangeCompat(of: importRemoteProfile.wrappedValue) { newValue in - if let newValue { - importRemoteProfile.wrappedValue = nil - viewModel.createImportRemoteProfileDialog(newValue) - } - } - .onReceive(environments.profileUpdate) { _ in - Task { - await viewModel.doReload() - } - } - #if os(iOS) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - EditButton().disabled(viewModel.profileList.isEmpty && !viewModel.editMode.isEditing) - } - } - #elseif os(tvOS) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - if viewModel.editMode == .inactive { - Button(action: { - viewModel.editMode = .active - }, label: { - Image(systemName: "square.and.pencil") - }) - .tint(.accentColor) - .disabled(viewModel.profileList.isEmpty) - } else { - Button(action: { - viewModel.editMode = .inactive - }, label: { - Image(systemName: "checkmark.square.fill") - }) - .tint(.accentColor) - } - } - } - #endif - #if os(iOS) || os(tvOS) - .environment(\.editMode, $viewModel.editMode) - #endif - } - - private func moveProfile(from source: IndexSet, to destination: Int) { - viewModel.moveProfile(from: source, to: destination) - } - - private func deleteProfile(where profileIndex: IndexSet) { - viewModel.deleteProfile(where: profileIndex) - } - - @MainActor - public struct ProfileItem: View { - @EnvironmentObject private var environments: ExtensionEnvironments - @ObservedObject private var viewModel: ProfileViewModel - @State private var profile: ProfilePreview - @State private var shareLinkPresented = false - - public init(_ viewModel: ProfileViewModel, _ profile: ProfilePreview) { - self.viewModel = viewModel - _profile = State(initialValue: profile) - } - - public var body: some View { - #if os(iOS) || os(macOS) - if #available(iOS 16.0, macOS 13.0, *) { - draggableBody.draggable(profile.origin) - } else { - draggableBody - } - #else - draggableBody - #endif - } - - private var draggableBody: some View { - Group { - #if !os(macOS) - FormNavigationLink { - EditProfileView().environmentObject(profile.origin) - } label: { - Text(profile.name) - } - .sheet(isPresented: $shareLinkPresented) { - QRCodeSheet(profileName: profile.name, remoteURL: profile.remoteURL!) - } - .contextMenu { - ProfileShareButton($viewModel.alert, profile.origin) { - Label("Share", systemImage: "square.and.arrow.up.fill") - } - - if profile.type == .remote { - Button { - shareLinkPresented = true - } label: { - Label("Share URL as QR Code", systemImage: "qrcode") - } - Button { - viewModel.isUpdating = true - Task { - await viewModel.updateProfile(profile.origin) - profile = ProfilePreview(profile.origin) - } - } label: { - Label("Update", systemImage: "arrow.clockwise") - } - } - Button(role: .destructive) { - Task { - await viewModel.deleteProfile(profile.origin) - } - } label: { - Label("Delete", systemImage: "trash.fill") - } - } - #else - FormNavigationLink { - EditProfileView().environmentObject(profile.origin) - } label: { - HStack { - VStack(alignment: .leading) { - Text(profile.name) - if profile.type == .remote { - Spacer(minLength: 4) - Text("Last Updated: \(profile.origin.lastUpdated!.relativeFormat)").font(.caption) - } - } - HStack { - if profile.type == .remote { - Button { - viewModel.isUpdating = true - Task { - await viewModel.updateProfile(profile.origin) - profile = ProfilePreview(profile.origin) - } - } label: { - Image(systemName: "arrow.clockwise") - } - .padding(.leading, 4) - - Button { - shareLinkPresented = true - } label: { - Image(systemName: "qrcode") - } - .padding(.leading, 4) - .popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) { - QRCodeContentView(profileName: profile.name, remoteURL: profile.remoteURL!) - } - } - ProfileShareButton($viewModel.alert, profile.origin) { - Image(systemName: "square.and.arrow.up.fill") - } - .padding(.leading, 4) - Button { - Task { - await viewModel.deleteProfile(profile.origin) - } - } label: { - Image(systemName: "trash.fill") - } - .padding([.leading, .trailing], 4) - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - } - #endif - } - } - } -} diff --git a/ApplicationLibrary/Views/Profile/ProfileViewModel.swift b/ApplicationLibrary/Views/Profile/ProfileViewModel.swift deleted file mode 100644 index a029857..0000000 --- a/ApplicationLibrary/Views/Profile/ProfileViewModel.swift +++ /dev/null @@ -1,136 +0,0 @@ -import Foundation -import Libbox -import Library -import SwiftUI - -@MainActor -public class ProfileViewModel: BaseViewModel { - @Published public var importRemoteProfileRequest: NewProfileView.ImportRequest? - @Published public var importRemoteProfilePresented = false - @Published public var isUpdating = false - @Published public var profileList: [ProfilePreview] = [] - - #if os(iOS) || os(tvOS) - @Published public var editMode = EditMode.inactive - #endif - - private weak var environments: ExtensionEnvironments? - - override public init() { - super.init() - isLoading = true - } - - public func setEnvironments(_ environments: ExtensionEnvironments) { - self.environments = environments - } - - public func createImportProfileDialog(_ profile: LibboxProfileContent) { - alert = AlertState( - title: String(localized: "Import Profile"), - message: String(localized: "Are you sure to import profile \(profile.name)?"), - primaryButton: .default(String(localized: "Import")) { - Task { - do { - try await profile.importProfile() - } catch { - self.alert = AlertState(error: error) - return - } - await self.doReload() - self.environments?.emptyProfiles = self.profileList.isEmpty - } - }, - secondaryButton: .cancel() - ) - } - - public func createImportRemoteProfileDialog(_ newValue: LibboxImportRemoteProfile) { - importRemoteProfileRequest = .init(name: newValue.name, url: newValue.url) - alert = AlertState( - title: String(localized: "Import Remote Profile"), - message: String(localized: "Are you sure to import remote profile \(newValue.name)? You will connect to \(newValue.host) to download the configuration."), - primaryButton: .default(String(localized: "Import")) { - self.importRemoteProfilePresented = true - }, - secondaryButton: .cancel() - ) - } - - public func doReload() async { - defer { - isLoading = false - } - if ApplicationLibrary.inPreview { - profileList = [ - ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")), - ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))), - ] - } else { - do { - profileList = try await ProfileManager.list().map { ProfilePreview($0) } - } catch { - alert = AlertState(error: error) - return - } - } - environments?.emptyProfiles = profileList.isEmpty - } - - public func updateProfile(_ profile: Profile) async { - await updateProfileBackground(profile) - isUpdating = false - } - - private nonisolated func updateProfileBackground(_ profile: Profile) async { - do { - _ = try await profile.updateRemoteProfile() - } catch { - await MainActor.run { - alert = AlertState(error: error) - } - } - } - - public func deleteProfile(_ profile: Profile) async { - do { - _ = try await ProfileManager.delete(profile) - environments?.profileUpdate.send() - environments?.emptyProfiles = profileList.isEmpty - } catch { - alert = AlertState(error: error) - } - } - - public func moveProfile(from source: IndexSet, to destination: Int) { - profileList.move(fromOffsets: source, toOffset: destination) - for (index, profile) in profileList.enumerated() { - profileList[index].order = UInt32(index) - profile.origin.order = UInt32(index) - } - Task { - do { - try await ProfileManager.update(profileList.map(\.origin)) - environments?.profileUpdate.send() - } catch { - alert = AlertState(error: error) - } - } - } - - public func deleteProfile(where profileIndex: IndexSet) { - let profileToDelete = profileIndex.map { index in - profileList[index].origin - } - profileList.remove(atOffsets: profileIndex) - Task { - do { - _ = try await ProfileManager.delete(profileToDelete) - environments?.emptyProfiles = profileList.isEmpty - environments?.profileUpdate.send() - } catch { - alert = AlertState(error: error) - } - } - } -} diff --git a/ApplicationLibrary/Views/Profile/QRCodeSheet.swift b/ApplicationLibrary/Views/Profile/QRCodeSheet.swift index e33ebb7..c328edd 100644 --- a/ApplicationLibrary/Views/Profile/QRCodeSheet.swift +++ b/ApplicationLibrary/Views/Profile/QRCodeSheet.swift @@ -31,7 +31,8 @@ public struct QRCodeContentView: View { content: LibboxGenerateRemoteProfileImportLink(profileName, remoteURL), errorCorrection: .low, foregroundColor: .labelColor, - backgroundColor: CGColor(gray: 1.0, alpha: 0.0) + backgroundColor: CGColor(gray: 1.0, alpha: 0.0), + additionalQuietZonePixels: 4 ) #if os(macOS) .frame(minWidth: 300, minHeight: 300) @@ -73,3 +74,64 @@ public struct QRCodeSheet: View { #endif } } + +@MainActor +public struct URLQRCodeContentView: View { + private let url: String + + public init(url: String) { + self.url = url + } + + public var body: some View { + VStack { + Spacer() + QRCodeViewUI( + content: url, + errorCorrection: .low, + foregroundColor: .labelColor, + backgroundColor: CGColor(gray: 1.0, alpha: 0.0), + additionalQuietZonePixels: 4 + ) + #if os(macOS) + .frame(minWidth: 300, minHeight: 300) + #endif + Spacer() + } + .padding() + } +} + +@MainActor +public struct URLQRCodeSheet: View { + private let url: String + private let title: String + + public init(url: String, title: String) { + self.url = url + self.title = title + } + + public var body: some View { + #if os(macOS) + NavigationSheet(title: title) { + URLQRCodeContentView(url: url) + } + .frame(minWidth: 400, minHeight: 400) + #elseif os(iOS) || os(tvOS) + if #available(iOS 16.0, tvOS 17.0, *) { + NavigationStackCompat { + URLQRCodeContentView(url: url) + .navigationTitle(title) + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } else { + NavigationStackCompat { + URLQRCodeContentView(url: url) + .navigationTitle(title) + } + } + #endif + } +} diff --git a/ApplicationLibrary/Views/Profile/QRSDisplayView.swift b/ApplicationLibrary/Views/Profile/QRSDisplayView.swift index 832a23b..f25615f 100644 --- a/ApplicationLibrary/Views/Profile/QRSDisplayView.swift +++ b/ApplicationLibrary/Views/Profile/QRSDisplayView.swift @@ -2,162 +2,167 @@ import Foundation import QRCode import SwiftUI -private extension CGColor { - static var labelColor: CGColor { - #if canImport(UIKit) - UIColor.label.cgColor - #elseif canImport(AppKit) - NSColor.labelColor.cgColor - #endif - } -} - @MainActor public struct QRSDisplayView: View { + private static let recoveryFactor = 1.3 + + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL private let data: Data + private let filename: String? - @State private var encoder: LubyTransformEncoder? - @State private var currentBlock: EncodedBlock? - @State private var frameCount = 0 - @State private var isPlaying = true + @State private var generator: QRSImageGenerator? @State private var fps: Double = 10 - @State private var sliceSize: Int = 500 - @State private var timer: Timer? + @State private var sliceSize: Double = 500 + @State private var generationTask: Task? + #if os(tvOS) + @State private var showQRSInfoQRCode = false + #endif - public init(data: Data) { + public init(data: Data, filename: String? = nil) { self.data = data + self.filename = filename } public var body: some View { VStack(spacing: 16) { - if let block = currentBlock { - QRCodeViewUI( - content: block.toBase64(), - errorCorrection: .low, - foregroundColor: .labelColor, - backgroundColor: CGColor(gray: 1.0, alpha: 0.0) - ) - .aspectRatio(1, contentMode: .fit) - #if os(macOS) - .frame(minWidth: 280, minHeight: 280) - #else - .frame(maxWidth: 300, maxHeight: 300) - #endif - } else { - ProgressView() - .frame(width: 280, height: 280) - } - - VStack(spacing: 4) { - Text("Frame: \(frameCount)") - .font(.caption) - if let encoder { - Text("Source blocks: \(encoder.k)") - .font(.caption) - Text("Data size: \(encoder.bytes) bytes") - .font(.caption) - } - } - .foregroundStyle(.secondary) - - VStack(spacing: 12) { - HStack { - Button { - isPlaying.toggle() - } label: { - Image(systemName: isPlaying ? "pause.fill" : "play.fill") + TimelineView(.periodic(from: .now, by: 1.0 / fps)) { context in + Group { + if let image = generator?.currentImage { + Image(decorative: image, scale: 1.0) + .resizable() + .interpolation(.none) + .aspectRatio(1, contentMode: .fit) + } else { + ProgressView() + .frame(width: 280, height: 280) } - #if os(macOS) - .buttonStyle(.bordered) - #endif - - Spacer() - - Text(String(localized: "Ideal FPS")) - .font(.caption) - - Slider(value: $fps, in: 1 ... 30, step: 1) - .frame(maxWidth: 120) - - Text("\(Int(fps))") - .font(.caption) - .frame(width: 24, alignment: .trailing) } + .onChange(of: context.date) { _ in + generator?.advanceFrame() + } + } + + VStack(spacing: 8) { + HStack { + Text(String(localized: "FPS")) + Spacer() + Text(verbatim: "\(Int(fps))") + .foregroundStyle(.secondary) + } + + Slider(value: $fps, in: 1 ... 60, step: 1) HStack { Text(String(localized: "Slice Size")) - .font(.caption) - - Picker("", selection: $sliceSize) { - Text("200").tag(200) - Text("500").tag(500) - Text("1000").tag(1000) - } - .pickerStyle(.segmented) - .frame(maxWidth: 200) + Spacer() + Text("\(Int(sliceSize))") + .foregroundStyle(.secondary) } + + Slider(value: $sliceSize, in: 100 ... 1500, step: 100) + } + .padding(.horizontal) + + HStack(spacing: 12) { + Button { + #if os(tvOS) + showQRSInfoQRCode = true + #else + openURL(URL(string: "https://github.com/qifi-dev/qrs")!) + #endif + } label: { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text(String(localized: "What is QRS")) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "xmark") + Text(String(localized: "Close")) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) } .padding(.horizontal) } - .padding() + #if os(macOS) + .padding() + #else + .padding([.horizontal, .bottom]) + #endif .onAppear { - setupEncoder() - startAnimation() + setupGenerator() } .onDisappear { - stopAnimation() - } - .onChange(of: fps) { _ in - if isPlaying { - restartTimer() - } - } - .onChange(of: isPlaying) { playing in - if playing { - startAnimation() - } else { - stopAnimation() - } + generator?.cancel() + generationTask?.cancel() } .onChange(of: sliceSize) { _ in - setupEncoder() - frameCount = 0 + setupGenerator() } + #if os(tvOS) + .sheet(isPresented: $showQRSInfoQRCode) { + URLQRCodeSheet(url: "https://github.com/qifi-dev/qrs", title: String(localized: "What is QRS")) + } + #endif } - private func setupEncoder() { - encoder = LubyTransformEncoder(data: data, sliceSize: sliceSize, compress: true) - } + private func setupGenerator() { + generator?.cancel() + generationTask?.cancel() - private func startAnimation() { - nextFrame() - restartTimer() - } + let newGenerator = QRSImageGenerator( + foregroundColor: CGColor(gray: 0.0, alpha: 1.0), + bufferSize: 30 + ) + generator = newGenerator - private func restartTimer() { - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: 1.0 / fps, repeats: true) { _ in - Task { @MainActor in - nextFrame() + generationTask = Task { + let (encoder, requiredFrames) = await createEncoder() + if Task.isCancelled { return } + + newGenerator.setExpectedFrames(requiredFrames) + + let fountain = encoder.fountain() + for _ in 0 ..< requiredFrames { + if Task.isCancelled { return } + guard let block = fountain.next() else { continue } + await newGenerator.addFrame(block) } } } - private func stopAnimation() { - timer?.invalidate() - timer = nil + private nonisolated func createEncoder() async -> (LubyTransformEncoder, Int) { + await Task.detached(priority: .userInitiated) { [data, filename, sliceSize] in + let wrappedData = BinaryMeta.appendFileHeaderMeta( + data: data, + filename: filename, + contentType: "application/octet-stream" + ) + let sliceSizeInt = Int(sliceSize) + let encoder = LubyTransformEncoder(data: wrappedData, sliceSize: sliceSizeInt, compress: true) + let requiredFrames = Self.calculateRequiredFrames(dataSize: wrappedData.count, sliceSize: sliceSizeInt) + return (encoder, requiredFrames) + }.value } - private func nextFrame() { - guard let encoder else { return } - currentBlock = encoder.fountain().next() - frameCount += 1 + private nonisolated static func calculateRequiredFrames(dataSize: Int, sliceSize: Int) -> Int { + let k = (dataSize + sliceSize - 1) / sliceSize + if k == 0 { return 1 } + return max(Int(Double(k) * recoveryFactor), k + 5) } } @MainActor public struct QRSSheet: View { - @Environment(\.dismiss) private var dismiss private let profileName: String private let profileData: Data @@ -168,26 +173,25 @@ public struct QRSSheet: View { public var body: some View { #if os(macOS) - NavigationSheet(title: String(localized: "Share as QRS")) { - VStack { - QRSDisplayView(data: profileData) - Text("Ask the receiver to scan continuously until complete.") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.bottom) - } - } - .frame(minWidth: 400, minHeight: 520) + QRSDisplayView(data: profileData, filename: "\(profileName).bpf") + .frame(minWidth: 400, minHeight: 520) #elseif os(iOS) || os(tvOS) - NavigationSheet(title: String(localized: "Share as QRS"), size: .large) { - VStack { - QRSDisplayView(data: profileData) - Text("Ask the receiver to scan continuously until complete.") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.bottom) - } - } + QRSDisplayView(data: profileData, filename: "\(profileName).bpf") + .modifier(LargeSheetModifier()) #endif } } + +#if os(iOS) || os(tvOS) + private struct LargeSheetModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.0, tvOS 17.0, *) { + content + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } else { + content + } + } + } +#endif diff --git a/ApplicationLibrary/Views/Scanner/QRSScannerController+iOS.swift b/ApplicationLibrary/Views/Scanner/QRSScannerController+iOS.swift deleted file mode 100644 index 7d1c59f..0000000 --- a/ApplicationLibrary/Views/Scanner/QRSScannerController+iOS.swift +++ /dev/null @@ -1,249 +0,0 @@ -#if os(iOS) - - import AVFoundation - import SwiftUI - import UIKit - - @MainActor - final class QRSScannerController: NSObject, ObservableObject { - private var captureSession: AVCaptureSession? - private var previewLayer: AVCaptureVideoPreviewLayer? - private var metadataOutput: AVCaptureMetadataOutput? - - @Published var decoder = LubyTransformDecoder() - @Published var lastError: String? - @Published var isComplete = false - @Published var progress: Double = 0 - @Published var framesScanned = 0 - @Published var availableCameras: [AVCaptureDevice] = [] - @Published var selectedCamera: AVCaptureDevice? - - private var seenBlockHashes = Set() - - 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 deleted file mode 100644 index c5a5819..0000000 --- a/ApplicationLibrary/Views/Scanner/QRSScannerController+macOS.swift +++ /dev/null @@ -1,261 +0,0 @@ -#if os(macOS) - - import AppKit - import AVFoundation - import SwiftUI - import Vision - - @MainActor - final class QRSScannerController: NSObject, ObservableObject { - private var captureSession: AVCaptureSession? - private var previewLayer: AVCaptureVideoPreviewLayer? - private var videoOutput: AVCaptureVideoDataOutput? - - @Published var decoder = LubyTransformDecoder() - @Published var lastError: String? - @Published var isComplete = false - @Published var progress: Double = 0 - @Published var framesScanned = 0 - @Published var availableCameras: [AVCaptureDevice] = [] - @Published var selectedCamera: AVCaptureDevice? - - private var seenBlockHashes = Set() - - 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 deleted file mode 100644 index 5e2ed14..0000000 --- a/ApplicationLibrary/Views/Scanner/QRSScannerView.swift +++ /dev/null @@ -1,169 +0,0 @@ -#if !os(tvOS) - - import AVFoundation - import Library - import SwiftUI - - @MainActor - public struct QRSScannerView: View { - @Environment(\.dismiss) private var dismiss - @State private var alert: AlertState? - @StateObject private var controller = QRSScannerController() - - private let onComplete: (Data) -> Void - - public init(onComplete: @escaping (Data) -> Void) { - self.onComplete = onComplete - } - - public var body: some View { - #if os(iOS) - iOSBody - #elseif os(macOS) - macOSBody - #endif - } - - #if os(iOS) - private var iOSBody: some View { - NavigationStackCompat { - ZStack { - QRSScannerControllerView(controller: controller) - .ignoresSafeArea() - - VStack { - Spacer() - progressOverlay - .padding() - } - } - .navigationTitle("Scan QRS") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .primaryAction) { - Menu { - Button { - controller.reset() - controller.startScanning() - } label: { - Label("Reset", systemImage: "arrow.counterclockwise") - } - - if controller.availableCameras.count > 1 { - Menu("Camera") { - ForEach(controller.availableCameras, id: \.uniqueID) { camera in - Button { - controller.selectCamera(camera) - } label: { - if camera.uniqueID == controller.selectedCamera?.uniqueID { - Label(camera.localizedName, systemImage: "checkmark") - } else { - Text(camera.localizedName) - } - } - } - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - } - .alert($alert) - .onAppear { - controller.onComplete = { data in - dismiss() - onComplete(data) - } - } - } - #endif - - #if os(macOS) - private var macOSBody: some View { - VStack(spacing: 0) { - ZStack { - QRSScannerControllerView(controller: controller) - .frame(minWidth: 400, minHeight: 300) - - VStack { - Spacer() - progressOverlay - .padding() - } - } - - Divider() - - HStack { - if controller.availableCameras.count > 1 { - Picker("Camera", selection: Binding( - get: { controller.selectedCamera }, - set: { camera in - if let camera { - controller.selectCamera(camera) - } - } - )) { - ForEach(controller.availableCameras, id: \.uniqueID) { camera in - Text(camera.localizedName).tag(camera as AVCaptureDevice?) - } - } - .frame(maxWidth: 200) - } - - Button("Reset") { - controller.reset() - controller.startScanning() - } - - Spacer() - - Button("Cancel") { dismiss() } - .keyboardShortcut(.cancelAction) - } - .padding() - } - .alert($alert) - .onAppear { - controller.onComplete = { data in - dismiss() - onComplete(data) - } - } - } - #endif - - private var progressOverlay: some View { - VStack(spacing: 8) { - ProgressView(value: controller.progress) - .progressViewStyle(.linear) - .frame(maxWidth: 200) - - Text("Decoded: \(Int(controller.progress * 100))%") - .font(.headline) - - if controller.decoder.k > 0 { - Text("\(controller.decoder.decodedCount)/\(controller.decoder.k) blocks") - .font(.caption) - } - - Text("Frames scanned: \(controller.framesScanned)") - .font(.caption) - - if let error = controller.lastError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - } - .padding() - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) - } - } - -#endif diff --git a/ApplicationLibrary/Views/Scanner/QRScanner.swift b/ApplicationLibrary/Views/Scanner/QRScanner.swift index a22f917..0a9edd4 100644 --- a/ApplicationLibrary/Views/Scanner/QRScanner.swift +++ b/ApplicationLibrary/Views/Scanner/QRScanner.swift @@ -6,6 +6,7 @@ public enum QRScanError: Error, LocalizedError { case permissionDenied case scanFailed(Error) case invalidCode + case qrsDecodeFailed public var errorDescription: String? { switch self { @@ -17,16 +18,22 @@ public enum QRScanError: Error, LocalizedError { return error.localizedDescription case .invalidCode: return String(localized: "Invalid QR code") + case .qrsDecodeFailed: + return String(localized: "Failed to decode QRS data") } } } -public struct QRScanResult: Sendable { - public let string: String - public let type: AVMetadataObject.ObjectType +public enum QRScanResult: Sendable { + case qrCode(string: String, type: AVMetadataObject.ObjectType) + case qrsData(Data) - public init(string: String, type: AVMetadataObject.ObjectType) { - self.string = string - self.type = type + public var string: String? { + switch self { + case let .qrCode(string, _): + return string + case .qrsData: + return nil + } } } diff --git a/ApplicationLibrary/Views/Scanner/QRScannerController+iOS.swift b/ApplicationLibrary/Views/Scanner/QRScannerController+iOS.swift index c65b7a4..c6bd87a 100644 --- a/ApplicationLibrary/Views/Scanner/QRScannerController+iOS.swift +++ b/ApplicationLibrary/Views/Scanner/QRScannerController+iOS.swift @@ -14,6 +14,13 @@ @Published var availableCameras: [AVCaptureDevice] = [] @Published var selectedCamera: AVCaptureDevice? + @Published var qrsMode = false + @Published var decoder: LubyTransformDecoder? + @Published var progress: Double = 0 + @Published var framesScanned = 0 + private var seenBlockIds = Set() + private let decodingQueue = DispatchQueue(label: "QRSDecoding", qos: .userInitiated) + let previewView = UIView() var onScan: ((Result) -> Void)? @@ -95,6 +102,11 @@ func reset() { didFinishScanning = false + qrsMode = false + decoder = nil + progress = 0 + framesScanned = 0 + seenBlockIds.removeAll() } private func setupCaptureSession() { @@ -129,7 +141,7 @@ metadataOutput.metadataObjectTypes = [.qr] self.metadataOutput = metadataOutput - self.captureSession = session + captureSession = session let previewLayer = AVCaptureVideoPreviewLayer(session: session) previewLayer.videoGravity = .resizeAspectFill @@ -149,24 +161,79 @@ extension QRScannerController: AVCaptureMetadataOutputObjectsDelegate { nonisolated func metadataOutput( - _ output: AVCaptureMetadataOutput, + _: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection + from _: AVCaptureConnection ) { Task { @MainActor in - guard !didFinishScanning, - let metadataObject = metadataObjects.first, - let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject, - let stringValue = readableObject.stringValue - else { - return - } + guard !didFinishScanning else { return } + for metadataObject in metadataObjects { + guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject, + let stringValue = readableObject.stringValue + else { continue } + + processScannedContent(stringValue, type: readableObject.type) + } + } + } + + private func processScannedContent(_ content: String, type: AVMetadataObject.ObjectType) { + if let block = EncodedBlock.fromQRSString(content) { + processQRSBlock(block) + } else if !qrsMode { didFinishScanning = true AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + onScan?(.success(.qrCode(string: content, type: type))) + } + } - let result = QRScanResult(string: stringValue, type: readableObject.type) - onScan?(.success(result)) + private func processQRSBlock(_ block: EncodedBlock) { + if let currentChecksum = decoder?.meta?.checksum, + block.checksum != currentChecksum + { + decoder = LubyTransformDecoder() + seenBlockIds.removeAll() + progress = 0 + framesScanned = 0 + } + + if !qrsMode { + qrsMode = true + decoder = LubyTransformDecoder() + } + + let blockId = "\(block.checksum):\(block.indices.sorted().map(String.init).joined(separator: ","))" + guard !seenBlockIds.contains(blockId) else { return } + seenBlockIds.insert(blockId) + + framesScanned += 1 + + guard let decoder else { return } + decodingQueue.async { [weak self] in + do { + let complete = try decoder.addBlock(block) + let currentProgress = decoder.progress + + DispatchQueue.main.async { + guard let self, !self.didFinishScanning else { return } + self.progress = currentProgress + + if complete { + self.didFinishScanning = true + self.stopScanning() + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + + if let data = try? decoder.getDecoded() { + self.onScan?(.success(.qrsData(data))) + } else { + self.onScan?(.failure(.qrsDecodeFailed)) + } + } + } + } catch { + // Checksum mismatch is handled above, ignore other errors + } } } } @@ -174,12 +241,12 @@ struct QRScannerControllerView: UIViewControllerRepresentable { let controller: QRScannerController - func makeUIViewController(context: Context) -> UIViewController { + func makeUIViewController(context _: Context) -> UIViewController { let viewController = QRScannerViewController(controller: controller) return viewController } - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + func updateUIViewController(_: UIViewController, context _: Context) {} } private class QRScannerViewController: UIViewController { @@ -191,7 +258,7 @@ } @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/ApplicationLibrary/Views/Scanner/QRScannerController+macOS.swift b/ApplicationLibrary/Views/Scanner/QRScannerController+macOS.swift index efe7f0a..7b7e22b 100644 --- a/ApplicationLibrary/Views/Scanner/QRScannerController+macOS.swift +++ b/ApplicationLibrary/Views/Scanner/QRScannerController+macOS.swift @@ -15,6 +15,13 @@ @Published var availableCameras: [AVCaptureDevice] = [] @Published var selectedCamera: AVCaptureDevice? + @Published var qrsMode = false + @Published var decoder: LubyTransformDecoder? + @Published var progress: Double = 0 + @Published var framesScanned = 0 + private var seenBlockIds = Set() + private let decodingQueue = DispatchQueue(label: "QRSDecoding", qos: .userInitiated) + let previewView = NSView() var onScan: ((Result) -> Void)? @@ -89,6 +96,11 @@ func reset() { didFinishScanning = false + qrsMode = false + decoder = nil + progress = 0 + framesScanned = 0 + seenBlockIds.removeAll() } private func setupCaptureSession() { @@ -126,7 +138,7 @@ session.addOutput(videoOutput) self.videoOutput = videoOutput - self.captureSession = session + captureSession = session let previewLayer = AVCaptureVideoPreviewLayer(session: session) previewLayer.videoGravity = .resizeAspectFill @@ -143,23 +155,75 @@ previewLayer?.frame = frame } - private func processQRCode(_ payloadString: String) { - guard !didFinishScanning else { return } - didFinishScanning = true - + private func processScannedContent(_ content: String) { DispatchQueue.main.async { [weak self] in - NSSound.beep() - let result = QRScanResult(string: payloadString, type: .qr) - self?.onScan?(.success(result)) + guard let self, !didFinishScanning else { return } + + if let block = EncodedBlock.fromQRSString(content) { + processQRSBlock(block) + } else if !qrsMode { + didFinishScanning = true + NSSound.beep() + onScan?(.success(.qrCode(string: content, type: .qr))) + } + } + } + + private func processQRSBlock(_ block: EncodedBlock) { + if let currentChecksum = decoder?.meta?.checksum, + block.checksum != currentChecksum + { + decoder = LubyTransformDecoder() + seenBlockIds.removeAll() + progress = 0 + framesScanned = 0 + } + + if !qrsMode { + qrsMode = true + decoder = LubyTransformDecoder() + } + + let blockId = "\(block.checksum):\(block.indices.sorted().map(String.init).joined(separator: ","))" + guard !seenBlockIds.contains(blockId) else { return } + seenBlockIds.insert(blockId) + + framesScanned += 1 + + guard let decoder else { return } + decodingQueue.async { [weak self] in + do { + let complete = try decoder.addBlock(block) + let currentProgress = decoder.progress + + DispatchQueue.main.async { + guard let self, !self.didFinishScanning else { return } + self.progress = currentProgress + + if complete { + self.didFinishScanning = true + self.stopScanning() + NSSound.beep() + + if let data = try? decoder.getDecoded() { + self.onScan?(.success(.qrsData(data))) + } else { + self.onScan?(.failure(.qrsDecodeFailed)) + } + } + } + } catch { + // Checksum mismatch is handled above, ignore other errors + } } } } extension QRScannerController: AVCaptureVideoDataOutputSampleBufferDelegate { nonisolated func captureOutput( - _ output: AVCaptureOutput, + _: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, - from connection: AVCaptureConnection + from _: AVCaptureConnection ) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } @@ -168,8 +232,7 @@ for result in results { if result.symbology == .qr, let payload = result.payloadStringValue { - self?.processQRCode(payload) - return + self?.processScannedContent(payload) } } } @@ -183,12 +246,12 @@ struct QRScannerControllerView: NSViewControllerRepresentable { let controller: QRScannerController - func makeNSViewController(context: Context) -> NSViewController { + func makeNSViewController(context _: Context) -> NSViewController { let viewController = QRScannerViewController(controller: controller) return viewController } - func updateNSViewController(_ nsViewController: NSViewController, context: Context) {} + func updateNSViewController(_: NSViewController, context _: Context) {} } private class QRScannerViewController: NSViewController { @@ -200,7 +263,7 @@ } @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/ApplicationLibrary/Views/Scanner/QRScannerView.swift b/ApplicationLibrary/Views/Scanner/QRScannerView.swift index dcef120..a1439cd 100644 --- a/ApplicationLibrary/Views/Scanner/QRScannerView.swift +++ b/ApplicationLibrary/Views/Scanner/QRScannerView.swift @@ -27,36 +27,42 @@ #if os(iOS) private var iOSBody: some View { NavigationStackCompat { - QRScannerControllerView(controller: controller) - .ignoresSafeArea() - .navigationTitle("Scan QR Code") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .primaryAction) { - Menu { - if controller.availableCameras.count > 1 { - Menu("Camera") { - ForEach(controller.availableCameras, id: \.uniqueID) { camera in - Button { - controller.selectCamera(camera) - } label: { - if camera.uniqueID == controller.selectedCamera?.uniqueID { - Label(camera.localizedName, systemImage: "checkmark") - } else { - Text(camera.localizedName) - } + ZStack { + QRScannerControllerView(controller: controller) + .ignoresSafeArea() + + if controller.qrsMode { + qrsProgressOverlay + } + } + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Menu { + if controller.availableCameras.count > 1 { + Menu("Camera") { + ForEach(controller.availableCameras, id: \.uniqueID) { camera in + Button { + controller.selectCamera(camera) + } label: { + if camera.uniqueID == controller.selectedCamera?.uniqueID { + Label(camera.localizedName, systemImage: "checkmark") + } else { + Text(camera.localizedName) } } } } - } label: { - Image(systemName: "ellipsis.circle") } + } label: { + Image(systemName: "ellipsis.circle") } } + } } .alert($alert) .onAppear { @@ -68,8 +74,14 @@ #if os(macOS) private var macOSBody: some View { VStack(spacing: 0) { - QRScannerControllerView(controller: controller) - .frame(minWidth: 400, minHeight: 300) + ZStack { + QRScannerControllerView(controller: controller) + + if controller.qrsMode { + qrsProgressOverlay + } + } + .frame(minWidth: 400, minHeight: 300) Divider() @@ -102,11 +114,29 @@ } #endif + private var qrsProgressOverlay: some View { + ZStack { + Color.black.opacity(0.5) + .ignoresSafeArea() + + let k = controller.decoder?.k ?? 0 + let framesScanned = controller.framesScanned + let scanProgress = k > 0 ? min(1.0, Double(framesScanned) / Double(k) / 1.2) : 0 + + CircularProgressView( + progress: scanProgress, + total: k + ) + } + } + private func handleScanResult(_ result: Result) { switch result { case let .success(scanResult): dismiss() - onScan(scanResult) + Task { @MainActor in + onScan(scanResult) + } case let .failure(error): switch error { case .permissionDenied: @@ -124,4 +154,38 @@ } } + private struct CircularProgressView: View { + let progress: Double + let total: Int + + private let size: CGFloat = 96 + private let lineWidth: CGFloat = 8 + + var body: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: lineWidth) + .frame(width: size, height: size) + + Circle() + .trim(from: 0, to: progress) + .stroke(Color.white, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .frame(width: size, height: size) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress) + + if total > 0 { + Text("\(min(99, Int(progress * 100)))%") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + } + + Text("QRS") + .font(.system(size: 32, weight: .bold)) + .foregroundStyle(.white) + .offset(y: -size / 2 - 40) + } + } + } + #endif diff --git a/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift b/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift index 917e384..978bd1d 100644 --- a/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift +++ b/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift @@ -76,24 +76,24 @@ public struct OnDemandRulesView: View { .environment(\.editMode, $editMode) #endif #if !os(tvOS) - .platformSheet(isPresented: $isAddingRule) { - OnDemandRuleEditView(rule: OnDemandRule(), isNew: true) { newRule in - rules.append(newRule) + .platformSheet(isPresented: $isAddingRule) { + OnDemandRuleEditView(rule: OnDemandRule(), isNew: true) { newRule in + rules.append(newRule) + Task { + await saveRules() + } + } + } + .platformSheet(item: $editingRule) { rule in + OnDemandRuleEditView(rule: rule, isNew: false) { updatedRule in + if let index = rules.firstIndex(where: { $0.id == updatedRule.id }) { + rules[index] = updatedRule Task { await saveRules() } } } - .platformSheet(item: $editingRule) { rule in - OnDemandRuleEditView(rule: rule, isNew: false) { updatedRule in - if let index = rules.firstIndex(where: { $0.id == updatedRule.id }) { - rules[index] = updatedRule - Task { - await saveRules() - } - } - } - } + } #endif } @@ -160,7 +160,7 @@ public struct OnDemandRulesView: View { Image(systemName: "plus.circle.fill") } #if os(macOS) - .buttonStyle(.plain) + .buttonStyle(.plain) #endif #endif } @@ -210,9 +210,9 @@ public struct OnDemandRulesView: View { .contentShape(Rectangle()) } #if os(macOS) - .buttonStyle(.plain) + .buttonStyle(.plain) #elseif os(iOS) - .foregroundStyle(.primary) + .foregroundStyle(.primary) #endif #endif } @@ -362,36 +362,36 @@ private struct OnDemandRuleEditView: View { .navigationBarTitleDisplayMode(.inline) #endif #if !os(tvOS) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - ToolbarItem(placement: .confirmationAction) { - Button(isNew ? "Create" : "Save") { - onSave(rule) - dismiss() - } - .disabled(!isProbeURLValid) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button(isNew ? "Create" : "Save") { + onSave(rule) + dismiss() + } + .disabled(!isProbeURLValid) + } + } #endif #if os(macOS) - .formStyle(.grouped) + .formStyle(.grouped) #endif - .platformSheet(isPresented: $isAddingConnectionRule, size: .small) { - EvaluateConnectionRuleEditView(rule: EvaluateConnectionRule()) { newRule in - rule.connectionRules.append(newRule) - } + .platformSheet(isPresented: $isAddingConnectionRule, size: .small) { + EvaluateConnectionRuleEditView(rule: EvaluateConnectionRule()) { newRule in + rule.connectionRules.append(newRule) } - .platformSheet(item: $editingConnectionRule, size: .small) { connRule in - EvaluateConnectionRuleEditView(rule: connRule) { updatedRule in - if let index = rule.connectionRules.firstIndex(where: { $0.id == updatedRule.id }) { - rule.connectionRules[index] = updatedRule - } + } + .platformSheet(item: $editingConnectionRule, size: .small) { connRule in + EvaluateConnectionRuleEditView(rule: connRule) { updatedRule in + if let index = rule.connectionRules.firstIndex(where: { $0.id == updatedRule.id }) { + rule.connectionRules[index] = updatedRule } } + } } private var actionSection: some View { diff --git a/Library/Database/Profile+Transferable.swift b/Library/Database/Profile+Transferable.swift index 0a920f7..367b9fe 100644 --- a/Library/Database/Profile+Transferable.swift +++ b/Library/Database/Profile+Transferable.swift @@ -16,13 +16,21 @@ public extension Profile { content.autoUpdate = autoUpdate content.autoUpdateInterval = autoUpdateInterval if let lastUpdated { - content.lastUpdated = Int64(lastUpdated.timeIntervalSince1970) + content.lastUpdated = Int64(lastUpdated.timeIntervalSince1970 * 1000) } } return content } } +public func dateFromTimestamp(_ timestamp: Int64) -> Date { + if timestamp > 100_000_000_000 { + return Date(timeIntervalSince1970: Double(timestamp) / 1000) + } else { + return Date(timeIntervalSince1970: Double(timestamp)) + } +} + @available(iOS 16.0, macOS 13.0, *) extension Profile: Transferable { public static var transferRepresentation: some TransferRepresentation { @@ -51,7 +59,7 @@ public extension LibboxProfileContent { try config.write(to: profileConfig, atomically: true, encoding: .utf8) var lastUpdatedAt: Date? if lastUpdated > 0 { - lastUpdatedAt = Date(timeIntervalSince1970: Double(lastUpdated)) + lastUpdatedAt = dateFromTimestamp(lastUpdated) } let uniqueProfileName = try await ProfileManager.uniqueName(name) let profile = Profile(name: uniqueProfileName, type: ProfileType(rawValue: Int(type))!, path: profileConfig.relativePath, remoteURL: remotePath, autoUpdate: autoUpdate, autoUpdateInterval: autoUpdateInterval, lastUpdated: lastUpdatedAt) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index c76c680..5bef9b0 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -43,6 +43,9 @@ } } } + }, + "%lld%%" : { + }, "↑ %@" : { "shouldTranslate" : false @@ -1049,6 +1052,16 @@ } } }, + "Failed to decode QRS data" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解码 QRS 数据失败" + } + } + } + }, "false" : { "localizations" : { "zh-Hans" : { @@ -1089,6 +1102,9 @@ } } }, + "FPS" : { + "shouldTranslate" : false + }, "From Outbound" : { "localizations" : { "zh-Hans" : { @@ -1430,6 +1446,7 @@ } }, "Last Updated: %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1889,6 +1906,9 @@ } } }, + "QRS" : { + "shouldTranslate" : false + }, "Quit" : { "localizations" : { "zh-Hans" : { @@ -2003,6 +2023,26 @@ } } }, + "Save Content JSON" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存内容 JSON" + } + } + } + }, + "Save File" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存文件" + } + } + } + }, "Scan QR Code" : { "localizations" : { "zh-Hans" : { @@ -2113,6 +2153,16 @@ } } }, + "Share as QRS Code" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过 QRS 码分享" + } + } + } + }, "Share Content JSON File" : { "localizations" : { "zh-Hans" : { @@ -2166,6 +2216,16 @@ "sing-box" : { "shouldTranslate" : false }, + "Slice Size" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分片大小" + } + } + } + }, "Sort By" : { "localizations" : { "zh-Hans" : { @@ -2542,6 +2602,16 @@ } } }, + "What is QRS" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "什么是 QRS" + } + } + } + }, "When action is 'Evaluate Connection', these rules determine whether to connect based on the destination host." : { "localizations" : { "zh-Hans" : {