Refactor QR scan and share and add QRS support

This commit is contained in:
世界
2026-01-02 16:27:26 +08:00
parent 313cb5d213
commit 52db9bbb39
23 changed files with 1115 additions and 1411 deletions
@@ -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")
}
}
@@ -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
}
}
@@ -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<UInt8>.allocate(capacity: destinationSize)
defer { destinationBuffer.deallocate() }
let decompressedSize = data.withUnsafeBytes { sourcePtr -> Int in
guard let baseAddress = sourcePtr.baseAddress else { return 0 }
return compression_decode_buffer(
destinationBuffer,
destinationSize,
baseAddress.assumingMemoryBound(to: UInt8.self),
sourceSize,
nil,
COMPRESSION_ZLIB
)
// Use 15 for zlib format (with header/trailer) to match pako's default
guard inflateInit2_(
&stream,
15,
ZLIB_VERSION,
Int32(MemoryLayout<z_stream>.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
}
}
}
}
}
@@ -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<UInt8>.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<z_stream>.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
}
@@ -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
}
}