Refactor QR scan and share and add QRS support
This commit is contained in:
@@ -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
|
import Foundation
|
||||||
|
|
||||||
struct EncodedBlock {
|
struct EncodedBlock {
|
||||||
|
static let qrsURLPrefix = "https://qrss.netlify.app/#"
|
||||||
var indices: [Int]
|
var indices: [Int]
|
||||||
var data: Data
|
var data: Data
|
||||||
let k: Int
|
let k: Int
|
||||||
@@ -80,8 +81,38 @@ struct EncodedBlock {
|
|||||||
toBinary().base64EncodedString()
|
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? {
|
static func fromBase64(_ string: String) -> EncodedBlock? {
|
||||||
guard let data = Data(base64Encoded: string) else { return nil }
|
guard let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) else {
|
||||||
return fromBinary(data)
|
#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 Foundation
|
||||||
|
import zlib
|
||||||
|
|
||||||
final class LubyTransformDecoder {
|
final class LubyTransformDecoder {
|
||||||
private(set) var decodedData: [Data?] = []
|
private(set) var decodedData: [Data?] = []
|
||||||
@@ -66,16 +66,29 @@ final class LubyTransformDecoder {
|
|||||||
indices.map(String.init).joined(separator: ",")
|
indices.map(String.init).joined(separator: ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func xorData(_ a: Data, _ b: Data) -> Data {
|
private func xorDataInPlace(_ dest: inout Data, _ src: Data) {
|
||||||
var result = a
|
let count = min(dest.count, src.count)
|
||||||
let count = min(a.count, b.count)
|
dest.withUnsafeMutableBytes { destPtr in
|
||||||
for i in 0 ..< count {
|
src.withUnsafeBytes { srcPtr in
|
||||||
result[i] ^= b[i]
|
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) {
|
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 block = wrapper.block
|
||||||
var indices = block.indices
|
var indices = block.indices
|
||||||
var indicesSet = Set(indices)
|
var indicesSet = Set(indices)
|
||||||
@@ -88,7 +101,7 @@ final class LubyTransformDecoder {
|
|||||||
if indices.count > 1 {
|
if indices.count > 1 {
|
||||||
for index in indices {
|
for index in indices {
|
||||||
if let decoded = decodedData[index] {
|
if let decoded = decodedData[index] {
|
||||||
block.data = xorData(block.data, decoded)
|
xorDataInPlace(&block.data, decoded)
|
||||||
indicesSet.remove(index)
|
indicesSet.remove(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +118,7 @@ final class LubyTransformDecoder {
|
|||||||
let subIndices = indices.filter { $0 != index }
|
let subIndices = indices.filter { $0 != index }
|
||||||
let subkey = indicesToKey(subIndices)
|
let subkey = indicesToKey(subIndices)
|
||||||
if let subWrapper = encodedBlockKeyMap[subkey] {
|
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 {
|
for i in subWrapper.block.indices {
|
||||||
indicesSet.remove(i)
|
indicesSet.remove(i)
|
||||||
}
|
}
|
||||||
@@ -121,8 +134,8 @@ final class LubyTransformDecoder {
|
|||||||
// Store subkeys for future matching if still high degree
|
// Store subkeys for future matching if still high degree
|
||||||
if indicesSet.count > 1 {
|
if indicesSet.count > 1 {
|
||||||
for (index, subkey) in subkeys {
|
for (index, subkey) in subkeys {
|
||||||
let dispose = { [weak self] in
|
let dispose: () -> Void = { [weak self] in
|
||||||
self?.encodedBlockSubkeyMap[subkey]?.remove(wrapper)
|
_ = self?.encodedBlockSubkeyMap[subkey]?.remove(wrapper)
|
||||||
}
|
}
|
||||||
if encodedBlockSubkeyMap[subkey] == nil {
|
if encodedBlockSubkeyMap[subkey] == nil {
|
||||||
encodedBlockSubkeyMap[subkey] = []
|
encodedBlockSubkeyMap[subkey] = []
|
||||||
@@ -156,14 +169,14 @@ final class LubyTransformDecoder {
|
|||||||
encodedBlockSubkeyMap.removeValue(forKey: newKey)
|
encodedBlockSubkeyMap.removeValue(forKey: newKey)
|
||||||
for superWrapper in superset {
|
for superWrapper in superset {
|
||||||
var superBlock = superWrapper.block
|
var superBlock = superWrapper.block
|
||||||
superBlock.data = xorData(superBlock.data, block.data)
|
xorDataInPlace(&superBlock.data, block.data)
|
||||||
var superIndicesSet = Set(superBlock.indices)
|
var superIndicesSet = Set(superBlock.indices)
|
||||||
for i in indices {
|
for i in indices {
|
||||||
superIndicesSet.remove(i)
|
superIndicesSet.remove(i)
|
||||||
}
|
}
|
||||||
superBlock.indices = Array(superIndicesSet).sorted()
|
superBlock.indices = Array(superIndicesSet).sorted()
|
||||||
superWrapper.block = superBlock
|
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 {
|
for waiting in waitingBlocks {
|
||||||
let waitingKey = indicesToKey(waiting.block.indices)
|
let waitingKey = indicesToKey(waiting.block.indices)
|
||||||
encodedBlockKeyMap.removeValue(forKey: waitingKey)
|
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? {
|
private static func inflate(_ data: Data) -> Data? {
|
||||||
let sourceSize = data.count
|
var stream = z_stream()
|
||||||
let destinationSize = sourceSize * 10
|
|
||||||
|
|
||||||
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: destinationSize)
|
// Use 15 for zlib format (with header/trailer) to match pako's default
|
||||||
defer { destinationBuffer.deallocate() }
|
guard inflateInit2_(
|
||||||
|
&stream,
|
||||||
let decompressedSize = data.withUnsafeBytes { sourcePtr -> Int in
|
15,
|
||||||
guard let baseAddress = sourcePtr.baseAddress else { return 0 }
|
ZLIB_VERSION,
|
||||||
return compression_decode_buffer(
|
Int32(MemoryLayout<z_stream>.size)
|
||||||
destinationBuffer,
|
) == Z_OK else {
|
||||||
destinationSize,
|
return nil
|
||||||
baseAddress.assumingMemoryBound(to: UInt8.self),
|
|
||||||
sourceSize,
|
|
||||||
nil,
|
|
||||||
COMPRESSION_ZLIB
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
defer { inflateEnd(&stream) }
|
||||||
|
|
||||||
guard decompressedSize > 0 else { return nil }
|
var destCapacity = data.count * 4
|
||||||
return Data(bytes: destinationBuffer, count: decompressedSize)
|
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 Foundation
|
||||||
|
import zlib
|
||||||
|
|
||||||
final class LubyTransformEncoder {
|
final class LubyTransformEncoder {
|
||||||
let k: Int
|
let k: Int
|
||||||
@@ -8,7 +8,7 @@ final class LubyTransformEncoder {
|
|||||||
let bytes: Int
|
let bytes: Int
|
||||||
private let sourceBlocks: [Data]
|
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
|
self.sliceSize = sliceSize
|
||||||
|
|
||||||
let compressed: Data
|
let compressed: Data
|
||||||
@@ -25,26 +25,39 @@ final class LubyTransformEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func deflateCompress(_ data: Data) -> Data? {
|
private static func deflateCompress(_ data: Data) -> Data? {
|
||||||
let sourceSize = data.count
|
var stream = z_stream()
|
||||||
let destinationSize = sourceSize + 1024
|
|
||||||
|
|
||||||
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: destinationSize)
|
// Use 15 for zlib format (with header/trailer) to match pako's default
|
||||||
defer { destinationBuffer.deallocate() }
|
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
|
let destSize = Int(deflateBound(&stream, UInt(data.count)))
|
||||||
guard let baseAddress = sourcePtr.baseAddress else { return 0 }
|
var dest = Data(count: destSize)
|
||||||
return compression_encode_buffer(
|
|
||||||
destinationBuffer,
|
let result = data.withUnsafeBytes { srcPtr -> Int32 in
|
||||||
destinationSize,
|
dest.withUnsafeMutableBytes { destPtr -> Int32 in
|
||||||
baseAddress.assumingMemoryBound(to: UInt8.self),
|
stream.next_in = UnsafeMutablePointer(mutating: srcPtr.bindMemory(to: Bytef.self).baseAddress)
|
||||||
sourceSize,
|
stream.avail_in = uInt(data.count)
|
||||||
nil,
|
stream.next_out = destPtr.bindMemory(to: Bytef.self).baseAddress
|
||||||
COMPRESSION_ZLIB
|
stream.avail_out = uInt(destSize)
|
||||||
)
|
return deflate(&stream, Z_FINISH)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard compressedSize > 0 else { return nil }
|
guard result == Z_STREAM_END else { return nil }
|
||||||
return Data(bytes: destinationBuffer, count: compressedSize)
|
dest.count = Int(stream.total_out)
|
||||||
|
return dest
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sliceData(_ data: Data, sliceSize: Int) -> [Data] {
|
private static func sliceData(_ data: Data, sliceSize: Int) -> [Data] {
|
||||||
@@ -94,10 +107,8 @@ final class LubyTransformEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let random = Double.random(in: 0 ... 1)
|
let random = Double.random(in: 0 ... 1)
|
||||||
for i in 0 ..< k {
|
if let i = cumulative.firstIndex(where: { random < $0 }) {
|
||||||
if random < cumulative[i] {
|
return i + 1
|
||||||
return i + 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return k
|
return k
|
||||||
}
|
}
|
||||||
@@ -117,4 +128,78 @@ final class LubyTransformEncoder {
|
|||||||
return self.createBlock(indices: indices)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,11 @@ public struct ProfileCard: View {
|
|||||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
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
|
#else
|
||||||
.sheet(isPresented: $viewModel.showNewProfile, onDismiss: {
|
.sheet(isPresented: $viewModel.showNewProfile, onDismiss: {
|
||||||
environments.profileUpdate.send()
|
environments.profileUpdate.send()
|
||||||
@@ -98,6 +103,11 @@ public struct ProfileCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
.sheet(isPresented: $viewModel.showQRSShare) {
|
||||||
|
if let profile = selectedProfile, let data = try? profile.origin.toContent().encode() {
|
||||||
|
QRSSheet(profileName: profile.name, profileData: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
.fileExporter(
|
.fileExporter(
|
||||||
isPresented: $viewModel.showProfileExporter,
|
isPresented: $viewModel.showProfileExporter,
|
||||||
document: viewModel.profileExportDocument,
|
document: viewModel.profileExportDocument,
|
||||||
@@ -250,20 +260,26 @@ public struct ProfileCard: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func shareMenu(for profile: ProfilePreview) -> some View {
|
private func shareMenu(for profile: ProfilePreview) -> some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
if profile.type == .remote {
|
Menu {
|
||||||
Menu {
|
if profile.type == .remote {
|
||||||
Button {
|
Button {
|
||||||
viewModel.showQRCode = true
|
viewModel.showQRCode = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
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
|
#else
|
||||||
Menu {
|
Menu {
|
||||||
Button {
|
Button {
|
||||||
@@ -297,9 +313,17 @@ public struct ProfileCard: View {
|
|||||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.showQRSShare = true
|
||||||
|
} label: {
|
||||||
|
Label("Share as QRS Code", systemImage: "barcode")
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
|
.frame(width: 44, height: 32)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.menuIndicator(.hidden)
|
.menuIndicator(.hidden)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
@@ -351,10 +375,16 @@ public struct ProfileCard: View {
|
|||||||
switch type {
|
switch type {
|
||||||
case .file:
|
case .file:
|
||||||
viewModel.profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
viewModel.profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
||||||
viewModel.showProfileExporter = true
|
|
||||||
case .json:
|
case .json:
|
||||||
viewModel.profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
|
viewModel.profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
|
||||||
viewModel.showJSONExporter = true
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch type {
|
||||||
|
case .file:
|
||||||
|
viewModel.showProfileExporter = true
|
||||||
|
case .json:
|
||||||
|
viewModel.showJSONExporter = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
viewModel.alert = AlertState(error: error)
|
viewModel.alert = AlertState(error: error)
|
||||||
@@ -462,6 +492,7 @@ extension ProfileCard {
|
|||||||
@Published var showNewProfile = false
|
@Published var showNewProfile = false
|
||||||
@Published var showProfilePicker = false
|
@Published var showProfilePicker = false
|
||||||
@Published var showQRCode = false
|
@Published var showQRCode = false
|
||||||
|
@Published var showQRSShare = false
|
||||||
@Published var isUpdating = false
|
@Published var isUpdating = false
|
||||||
@Published var alert: AlertState?
|
@Published var alert: AlertState?
|
||||||
@Published var profileToEdit: Profile?
|
@Published var profileToEdit: Profile?
|
||||||
|
|||||||
@@ -531,6 +531,7 @@ private struct ProfilePickerRow: View {
|
|||||||
|
|
||||||
@State private var isUpdating = false
|
@State private var isUpdating = false
|
||||||
@State private var showQRCode = false
|
@State private var showQRCode = false
|
||||||
|
@State private var showQRSShare = false
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@State private var shareItemType: ShareItemType?
|
@State private var shareItemType: ShareItemType?
|
||||||
@State private var exportItemType: ExportItemType?
|
@State private var exportItemType: ExportItemType?
|
||||||
@@ -569,6 +570,11 @@ private struct ProfilePickerRow: View {
|
|||||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
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 {
|
private var tvOSNormalBody: some View {
|
||||||
@@ -615,16 +621,24 @@ private struct ProfilePickerRow: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Update", systemImage: "arrow.clockwise")
|
Label("Update", systemImage: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
|
if profile.type == .remote {
|
||||||
Button {
|
Button {
|
||||||
showQRCode = true
|
showQRCode = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
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: {
|
} label: {
|
||||||
Image(systemName: "ellipsis")
|
Image(systemName: "ellipsis")
|
||||||
@@ -708,6 +722,11 @@ private struct ProfilePickerRow: View {
|
|||||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showQRSShare) {
|
||||||
|
if let data = try? profile.origin.toContent().encode() {
|
||||||
|
QRSSheet(profileName: profile.name, profileData: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
.fileExporter(
|
.fileExporter(
|
||||||
isPresented: $showProfileExporter,
|
isPresented: $showProfileExporter,
|
||||||
document: profileExportDocument,
|
document: profileExportDocument,
|
||||||
@@ -753,6 +772,11 @@ private struct ProfilePickerRow: View {
|
|||||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showQRSShare) {
|
||||||
|
if let data = try? profile.origin.toContent().encode() {
|
||||||
|
QRSSheet(profileName: profile.name, profileData: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
.fileExporter(
|
.fileExporter(
|
||||||
isPresented: $showProfileExporter,
|
isPresented: $showProfileExporter,
|
||||||
document: profileExportDocument,
|
document: profileExportDocument,
|
||||||
@@ -952,6 +976,12 @@ private struct ProfilePickerRow: View {
|
|||||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showQRSShare = true
|
||||||
|
} label: {
|
||||||
|
Label("Share as QRS Code", systemImage: "barcode")
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Share", systemImage: "square.and.arrow.up")
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
@@ -962,10 +992,16 @@ private struct ProfilePickerRow: View {
|
|||||||
switch type {
|
switch type {
|
||||||
case .file:
|
case .file:
|
||||||
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
||||||
showProfileExporter = true
|
|
||||||
case .json:
|
case .json:
|
||||||
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
|
profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
|
||||||
showJSONExporter = true
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch type {
|
||||||
|
case .file:
|
||||||
|
showProfileExporter = true
|
||||||
|
case .json:
|
||||||
|
showJSONExporter = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert = AlertState(error: error)
|
alert = AlertState(error: error)
|
||||||
@@ -1025,7 +1061,7 @@ private struct ProfilePickerRow: View {
|
|||||||
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
||||||
showProfileExporter = true
|
showProfileExporter = true
|
||||||
case .json:
|
case .json:
|
||||||
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
|
profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
|
||||||
showJSONExporter = true
|
showJSONExporter = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1136,6 +1172,7 @@ private struct ProfilePickerRow: View {
|
|||||||
|
|
||||||
@State private var isUpdating = false
|
@State private var isUpdating = false
|
||||||
@State private var showQRCode = false
|
@State private var showQRCode = false
|
||||||
|
@State private var showQRSShare = false
|
||||||
@State private var profileExportDocument: ProfileExportDocument?
|
@State private var profileExportDocument: ProfileExportDocument?
|
||||||
@State private var showProfileExporter = false
|
@State private var showProfileExporter = false
|
||||||
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
|
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
|
||||||
@@ -1197,6 +1234,11 @@ private struct ProfilePickerRow: View {
|
|||||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showQRSShare) {
|
||||||
|
if let data = try? profile.origin.toContent().encode() {
|
||||||
|
QRSSheet(profileName: profile.name, profileData: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
.fileExporter(
|
.fileExporter(
|
||||||
isPresented: $showProfileExporter,
|
isPresented: $showProfileExporter,
|
||||||
document: profileExportDocument,
|
document: profileExportDocument,
|
||||||
@@ -1293,6 +1335,12 @@ private struct ProfilePickerRow: View {
|
|||||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showQRSShare = true
|
||||||
|
} label: {
|
||||||
|
Label("Share as QRS Code", systemImage: "barcode")
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Share", systemImage: "square.and.arrow.up")
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
@@ -1303,10 +1351,16 @@ private struct ProfilePickerRow: View {
|
|||||||
switch type {
|
switch type {
|
||||||
case .file:
|
case .file:
|
||||||
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
||||||
showProfileExporter = true
|
|
||||||
case .json:
|
case .json:
|
||||||
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
|
profileJSONExportDocument = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
|
||||||
showJSONExporter = true
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch type {
|
||||||
|
case .file:
|
||||||
|
showProfileExporter = true
|
||||||
|
case .json:
|
||||||
|
showJSONExporter = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert = AlertState(error: error)
|
alert = AlertState(error: error)
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
try content.config.write(to: profileConfig, atomically: true, encoding: .utf8)
|
try content.config.write(to: profileConfig, atomically: true, encoding: .utf8)
|
||||||
var lastUpdated: Date?
|
var lastUpdated: Date?
|
||||||
if content.lastUpdated > 0 {
|
if content.lastUpdated > 0 {
|
||||||
lastUpdated = Date(timeIntervalSince1970: Double(content.lastUpdated))
|
lastUpdated = dateFromTimestamp(content.lastUpdated)
|
||||||
}
|
}
|
||||||
let uniqueProfileName = try await ProfileManager.uniqueName(content.name)
|
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)
|
let profile = Profile(name: uniqueProfileName, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated)
|
||||||
|
|||||||
@@ -231,8 +231,17 @@ public struct NewProfileMenuView: View {
|
|||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
private func handleQRScanResult(_ result: QRScanResult) {
|
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?
|
var error: NSError?
|
||||||
let remoteProfile = LibboxParseRemoteProfileImportLink(result.string, &error)
|
let remoteProfile = LibboxParseRemoteProfileImportLink(string, &error)
|
||||||
if let error {
|
if let error {
|
||||||
alert = AlertState(
|
alert = AlertState(
|
||||||
title: String(localized: "Invalid QR Code"),
|
title: String(localized: "Invalid QR Code"),
|
||||||
@@ -249,5 +258,30 @@ public struct NewProfileMenuView: View {
|
|||||||
}
|
}
|
||||||
importRequest = NewProfileView.ImportRequest(name: remoteProfile.name, url: remoteProfile.url)
|
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
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Libbox
|
|
||||||
import Library
|
|
||||||
import Network
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public struct ProfileView: View {
|
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
|
||||||
@Environment(\.importProfile) private var importProfile
|
|
||||||
@Environment(\.importRemoteProfile) private var importRemoteProfile
|
|
||||||
@StateObject private var viewModel = ProfileViewModel()
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@Environment(\.devicePickerSupports) private var devicePickerSupports
|
|
||||||
#endif
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
public var body: some View {
|
|
||||||
VStack {
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView().onAppear {
|
|
||||||
viewModel.setEnvironments(environments)
|
|
||||||
Task {
|
|
||||||
await viewModel.doReload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ZStack {
|
|
||||||
if let importRemoteProfileRequest = viewModel.importRemoteProfileRequest {
|
|
||||||
NavigationDestinationCompat(isPresented: $viewModel.importRemoteProfilePresented) {
|
|
||||||
NewProfileView(importRemoteProfileRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FormView {
|
|
||||||
#if os(iOS)
|
|
||||||
FormNavigationLink {
|
|
||||||
NewProfileView()
|
|
||||||
} label: {
|
|
||||||
Text("New Profile").foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
.disabled(viewModel.editMode.isEditing)
|
|
||||||
#elseif os(macOS)
|
|
||||||
FormNavigationLink {
|
|
||||||
NewProfileView()
|
|
||||||
} label: {
|
|
||||||
Text("New Profile")
|
|
||||||
}
|
|
||||||
#elseif os(tvOS)
|
|
||||||
Section {
|
|
||||||
FormNavigationLink {
|
|
||||||
NewProfileView()
|
|
||||||
} label: {
|
|
||||||
Text("New Profile").foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
if ApplicationLibrary.inPreview || devicePickerSupports(.applicationService(name: "sing-box"), parameters: { .applicationService }) {
|
|
||||||
FormNavigationLink {
|
|
||||||
ImportProfileView()
|
|
||||||
} label: {
|
|
||||||
Text("Import Profile").foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
if viewModel.profileList.isEmpty {
|
|
||||||
Text("Empty profiles")
|
|
||||||
} else {
|
|
||||||
List {
|
|
||||||
ForEach(viewModel.profileList, id: \.id) { profile in
|
|
||||||
Group {
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
if viewModel.editMode.isEditing == true {
|
|
||||||
Text(profile.name)
|
|
||||||
} else {
|
|
||||||
ProfileItem(viewModel, profile)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
ProfileItem(viewModel, profile)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onMove(perform: moveProfile)
|
|
||||||
.onDelete(perform: deleteProfile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isUpdating)
|
|
||||||
.alert($viewModel.alert, isLoading: $viewModel.isLoading)
|
|
||||||
.onAppear {
|
|
||||||
if let profile = importProfile.wrappedValue {
|
|
||||||
importProfile.wrappedValue = nil
|
|
||||||
viewModel.createImportProfileDialog(profile)
|
|
||||||
}
|
|
||||||
if let remoteProfile = importRemoteProfile.wrappedValue {
|
|
||||||
importRemoteProfile.wrappedValue = nil
|
|
||||||
viewModel.createImportRemoteProfileDialog(remoteProfile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChangeCompat(of: importProfile.wrappedValue) { newValue in
|
|
||||||
if let newValue {
|
|
||||||
importProfile.wrappedValue = nil
|
|
||||||
viewModel.createImportProfileDialog(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChangeCompat(of: importRemoteProfile.wrappedValue) { newValue in
|
|
||||||
if let newValue {
|
|
||||||
importRemoteProfile.wrappedValue = nil
|
|
||||||
viewModel.createImportRemoteProfileDialog(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onReceive(environments.profileUpdate) { _ in
|
|
||||||
Task {
|
|
||||||
await viewModel.doReload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
EditButton().disabled(viewModel.profileList.isEmpty && !viewModel.editMode.isEditing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#elseif os(tvOS)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
if viewModel.editMode == .inactive {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.editMode = .active
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "square.and.pencil")
|
|
||||||
})
|
|
||||||
.tint(.accentColor)
|
|
||||||
.disabled(viewModel.profileList.isEmpty)
|
|
||||||
} else {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.editMode = .inactive
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "checkmark.square.fill")
|
|
||||||
})
|
|
||||||
.tint(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
.environment(\.editMode, $viewModel.editMode)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func moveProfile(from source: IndexSet, to destination: Int) {
|
|
||||||
viewModel.moveProfile(from: source, to: destination)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteProfile(where profileIndex: IndexSet) {
|
|
||||||
viewModel.deleteProfile(where: profileIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public struct ProfileItem: View {
|
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
|
||||||
@ObservedObject private var viewModel: ProfileViewModel
|
|
||||||
@State private var profile: ProfilePreview
|
|
||||||
@State private var shareLinkPresented = false
|
|
||||||
|
|
||||||
public init(_ viewModel: ProfileViewModel, _ profile: ProfilePreview) {
|
|
||||||
self.viewModel = viewModel
|
|
||||||
_profile = State(initialValue: profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
if #available(iOS 16.0, macOS 13.0, *) {
|
|
||||||
draggableBody.draggable(profile.origin)
|
|
||||||
} else {
|
|
||||||
draggableBody
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
draggableBody
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var draggableBody: some View {
|
|
||||||
Group {
|
|
||||||
#if !os(macOS)
|
|
||||||
FormNavigationLink {
|
|
||||||
EditProfileView().environmentObject(profile.origin)
|
|
||||||
} label: {
|
|
||||||
Text(profile.name)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $shareLinkPresented) {
|
|
||||||
QRCodeSheet(profileName: profile.name, remoteURL: profile.remoteURL!)
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
ProfileShareButton($viewModel.alert, profile.origin) {
|
|
||||||
Label("Share", systemImage: "square.and.arrow.up.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
if profile.type == .remote {
|
|
||||||
Button {
|
|
||||||
shareLinkPresented = true
|
|
||||||
} label: {
|
|
||||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
viewModel.isUpdating = true
|
|
||||||
Task {
|
|
||||||
await viewModel.updateProfile(profile.origin)
|
|
||||||
profile = ProfilePreview(profile.origin)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Update", systemImage: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteProfile(profile.origin)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash.fill")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
FormNavigationLink {
|
|
||||||
EditProfileView().environmentObject(profile.origin)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(profile.name)
|
|
||||||
if profile.type == .remote {
|
|
||||||
Spacer(minLength: 4)
|
|
||||||
Text("Last Updated: \(profile.origin.lastUpdated!.relativeFormat)").font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
if profile.type == .remote {
|
|
||||||
Button {
|
|
||||||
viewModel.isUpdating = true
|
|
||||||
Task {
|
|
||||||
await viewModel.updateProfile(profile.origin)
|
|
||||||
profile = ProfilePreview(profile.origin)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
.padding(.leading, 4)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
shareLinkPresented = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "qrcode")
|
|
||||||
}
|
|
||||||
.padding(.leading, 4)
|
|
||||||
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
|
|
||||||
QRCodeContentView(profileName: profile.name, remoteURL: profile.remoteURL!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProfileShareButton($viewModel.alert, profile.origin) {
|
|
||||||
Image(systemName: "square.and.arrow.up.fill")
|
|
||||||
}
|
|
||||||
.padding(.leading, 4)
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteProfile(profile.origin)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "trash.fill")
|
|
||||||
}
|
|
||||||
.padding([.leading, .trailing], 4)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Libbox
|
|
||||||
import Library
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public class ProfileViewModel: BaseViewModel {
|
|
||||||
@Published public var importRemoteProfileRequest: NewProfileView.ImportRequest?
|
|
||||||
@Published public var importRemoteProfilePresented = false
|
|
||||||
@Published public var isUpdating = false
|
|
||||||
@Published public var profileList: [ProfilePreview] = []
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
@Published public var editMode = EditMode.inactive
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private weak var environments: ExtensionEnvironments?
|
|
||||||
|
|
||||||
override public init() {
|
|
||||||
super.init()
|
|
||||||
isLoading = true
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setEnvironments(_ environments: ExtensionEnvironments) {
|
|
||||||
self.environments = environments
|
|
||||||
}
|
|
||||||
|
|
||||||
public func createImportProfileDialog(_ profile: LibboxProfileContent) {
|
|
||||||
alert = AlertState(
|
|
||||||
title: String(localized: "Import Profile"),
|
|
||||||
message: String(localized: "Are you sure to import profile \(profile.name)?"),
|
|
||||||
primaryButton: .default(String(localized: "Import")) {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await profile.importProfile()
|
|
||||||
} catch {
|
|
||||||
self.alert = AlertState(error: error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await self.doReload()
|
|
||||||
self.environments?.emptyProfiles = self.profileList.isEmpty
|
|
||||||
}
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func createImportRemoteProfileDialog(_ newValue: LibboxImportRemoteProfile) {
|
|
||||||
importRemoteProfileRequest = .init(name: newValue.name, url: newValue.url)
|
|
||||||
alert = AlertState(
|
|
||||||
title: String(localized: "Import Remote Profile"),
|
|
||||||
message: String(localized: "Are you sure to import remote profile \(newValue.name)? You will connect to \(newValue.host) to download the configuration."),
|
|
||||||
primaryButton: .default(String(localized: "Import")) {
|
|
||||||
self.importRemoteProfilePresented = true
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func doReload() async {
|
|
||||||
defer {
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
if ApplicationLibrary.inPreview {
|
|
||||||
profileList = [
|
|
||||||
ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")),
|
|
||||||
ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))),
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
do {
|
|
||||||
profileList = try await ProfileManager.list().map { ProfilePreview($0) }
|
|
||||||
} catch {
|
|
||||||
alert = AlertState(error: error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
environments?.emptyProfiles = profileList.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
public func updateProfile(_ profile: Profile) async {
|
|
||||||
await updateProfileBackground(profile)
|
|
||||||
isUpdating = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated func updateProfileBackground(_ profile: Profile) async {
|
|
||||||
do {
|
|
||||||
_ = try await profile.updateRemoteProfile()
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
alert = AlertState(error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func deleteProfile(_ profile: Profile) async {
|
|
||||||
do {
|
|
||||||
_ = try await ProfileManager.delete(profile)
|
|
||||||
environments?.profileUpdate.send()
|
|
||||||
environments?.emptyProfiles = profileList.isEmpty
|
|
||||||
} catch {
|
|
||||||
alert = AlertState(error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func moveProfile(from source: IndexSet, to destination: Int) {
|
|
||||||
profileList.move(fromOffsets: source, toOffset: destination)
|
|
||||||
for (index, profile) in profileList.enumerated() {
|
|
||||||
profileList[index].order = UInt32(index)
|
|
||||||
profile.origin.order = UInt32(index)
|
|
||||||
}
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await ProfileManager.update(profileList.map(\.origin))
|
|
||||||
environments?.profileUpdate.send()
|
|
||||||
} catch {
|
|
||||||
alert = AlertState(error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func deleteProfile(where profileIndex: IndexSet) {
|
|
||||||
let profileToDelete = profileIndex.map { index in
|
|
||||||
profileList[index].origin
|
|
||||||
}
|
|
||||||
profileList.remove(atOffsets: profileIndex)
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
_ = try await ProfileManager.delete(profileToDelete)
|
|
||||||
environments?.emptyProfiles = profileList.isEmpty
|
|
||||||
environments?.profileUpdate.send()
|
|
||||||
} catch {
|
|
||||||
alert = AlertState(error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,8 @@ public struct QRCodeContentView: View {
|
|||||||
content: LibboxGenerateRemoteProfileImportLink(profileName, remoteURL),
|
content: LibboxGenerateRemoteProfileImportLink(profileName, remoteURL),
|
||||||
errorCorrection: .low,
|
errorCorrection: .low,
|
||||||
foregroundColor: .labelColor,
|
foregroundColor: .labelColor,
|
||||||
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
|
backgroundColor: CGColor(gray: 1.0, alpha: 0.0),
|
||||||
|
additionalQuietZonePixels: 4
|
||||||
)
|
)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 300, minHeight: 300)
|
.frame(minWidth: 300, minHeight: 300)
|
||||||
@@ -73,3 +74,64 @@ public struct QRCodeSheet: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct URLQRCodeContentView: View {
|
||||||
|
private let url: String
|
||||||
|
|
||||||
|
public init(url: String) {
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
QRCodeViewUI(
|
||||||
|
content: url,
|
||||||
|
errorCorrection: .low,
|
||||||
|
foregroundColor: .labelColor,
|
||||||
|
backgroundColor: CGColor(gray: 1.0, alpha: 0.0),
|
||||||
|
additionalQuietZonePixels: 4
|
||||||
|
)
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(minWidth: 300, minHeight: 300)
|
||||||
|
#endif
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct URLQRCodeSheet: View {
|
||||||
|
private let url: String
|
||||||
|
private let title: String
|
||||||
|
|
||||||
|
public init(url: String, title: String) {
|
||||||
|
self.url = url
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
NavigationSheet(title: title) {
|
||||||
|
URLQRCodeContentView(url: url)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 400, minHeight: 400)
|
||||||
|
#elseif os(iOS) || os(tvOS)
|
||||||
|
if #available(iOS 16.0, tvOS 17.0, *) {
|
||||||
|
NavigationStackCompat {
|
||||||
|
URLQRCodeContentView(url: url)
|
||||||
|
.navigationTitle(title)
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
} else {
|
||||||
|
NavigationStackCompat {
|
||||||
|
URLQRCodeContentView(url: url)
|
||||||
|
.navigationTitle(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,162 +2,167 @@ import Foundation
|
|||||||
import QRCode
|
import QRCode
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
private extension CGColor {
|
|
||||||
static var labelColor: CGColor {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
UIColor.label.cgColor
|
|
||||||
#elseif canImport(AppKit)
|
|
||||||
NSColor.labelColor.cgColor
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct QRSDisplayView: View {
|
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 data: Data
|
||||||
|
private let filename: String?
|
||||||
|
|
||||||
@State private var encoder: LubyTransformEncoder?
|
@State private var generator: QRSImageGenerator?
|
||||||
@State private var currentBlock: EncodedBlock?
|
|
||||||
@State private var frameCount = 0
|
|
||||||
@State private var isPlaying = true
|
|
||||||
@State private var fps: Double = 10
|
@State private var fps: Double = 10
|
||||||
@State private var sliceSize: Int = 500
|
@State private var sliceSize: Double = 500
|
||||||
@State private var timer: Timer?
|
@State private var generationTask: Task<Void, Never>?
|
||||||
|
#if os(tvOS)
|
||||||
|
@State private var showQRSInfoQRCode = false
|
||||||
|
#endif
|
||||||
|
|
||||||
public init(data: Data) {
|
public init(data: Data, filename: String? = nil) {
|
||||||
self.data = data
|
self.data = data
|
||||||
|
self.filename = filename
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
if let block = currentBlock {
|
TimelineView(.periodic(from: .now, by: 1.0 / fps)) { context in
|
||||||
QRCodeViewUI(
|
Group {
|
||||||
content: block.toBase64(),
|
if let image = generator?.currentImage {
|
||||||
errorCorrection: .low,
|
Image(decorative: image, scale: 1.0)
|
||||||
foregroundColor: .labelColor,
|
.resizable()
|
||||||
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
|
.interpolation(.none)
|
||||||
)
|
.aspectRatio(1, contentMode: .fit)
|
||||||
.aspectRatio(1, contentMode: .fit)
|
} else {
|
||||||
#if os(macOS)
|
ProgressView()
|
||||||
.frame(minWidth: 280, minHeight: 280)
|
.frame(width: 280, height: 280)
|
||||||
#else
|
|
||||||
.frame(maxWidth: 300, maxHeight: 300)
|
|
||||||
#endif
|
|
||||||
} else {
|
|
||||||
ProgressView()
|
|
||||||
.frame(width: 280, height: 280)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Text("Frame: \(frameCount)")
|
|
||||||
.font(.caption)
|
|
||||||
if let encoder {
|
|
||||||
Text("Source blocks: \(encoder.k)")
|
|
||||||
.font(.caption)
|
|
||||||
Text("Data size: \(encoder.bytes) bytes")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
HStack {
|
|
||||||
Button {
|
|
||||||
isPlaying.toggle()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text(String(localized: "Ideal FPS"))
|
|
||||||
.font(.caption)
|
|
||||||
|
|
||||||
Slider(value: $fps, in: 1 ... 30, step: 1)
|
|
||||||
.frame(maxWidth: 120)
|
|
||||||
|
|
||||||
Text("\(Int(fps))")
|
|
||||||
.font(.caption)
|
|
||||||
.frame(width: 24, alignment: .trailing)
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
HStack {
|
||||||
Text(String(localized: "Slice Size"))
|
Text(String(localized: "Slice Size"))
|
||||||
.font(.caption)
|
Spacer()
|
||||||
|
Text("\(Int(sliceSize))")
|
||||||
Picker("", selection: $sliceSize) {
|
.foregroundStyle(.secondary)
|
||||||
Text("200").tag(200)
|
|
||||||
Text("500").tag(500)
|
|
||||||
Text("1000").tag(1000)
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.frame(maxWidth: 200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(.horizontal)
|
||||||
}
|
}
|
||||||
.padding()
|
#if os(macOS)
|
||||||
|
.padding()
|
||||||
|
#else
|
||||||
|
.padding([.horizontal, .bottom])
|
||||||
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
setupEncoder()
|
setupGenerator()
|
||||||
startAnimation()
|
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
stopAnimation()
|
generator?.cancel()
|
||||||
}
|
generationTask?.cancel()
|
||||||
.onChange(of: fps) { _ in
|
|
||||||
if isPlaying {
|
|
||||||
restartTimer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: isPlaying) { playing in
|
|
||||||
if playing {
|
|
||||||
startAnimation()
|
|
||||||
} else {
|
|
||||||
stopAnimation()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: sliceSize) { _ in
|
.onChange(of: sliceSize) { _ in
|
||||||
setupEncoder()
|
setupGenerator()
|
||||||
frameCount = 0
|
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.sheet(isPresented: $showQRSInfoQRCode) {
|
||||||
|
URLQRCodeSheet(url: "https://github.com/qifi-dev/qrs", title: String(localized: "What is QRS"))
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupEncoder() {
|
private func setupGenerator() {
|
||||||
encoder = LubyTransformEncoder(data: data, sliceSize: sliceSize, compress: true)
|
generator?.cancel()
|
||||||
}
|
generationTask?.cancel()
|
||||||
|
|
||||||
private func startAnimation() {
|
let newGenerator = QRSImageGenerator(
|
||||||
nextFrame()
|
foregroundColor: CGColor(gray: 0.0, alpha: 1.0),
|
||||||
restartTimer()
|
bufferSize: 30
|
||||||
}
|
)
|
||||||
|
generator = newGenerator
|
||||||
|
|
||||||
private func restartTimer() {
|
generationTask = Task {
|
||||||
timer?.invalidate()
|
let (encoder, requiredFrames) = await createEncoder()
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / fps, repeats: true) { _ in
|
if Task.isCancelled { return }
|
||||||
Task { @MainActor in
|
|
||||||
nextFrame()
|
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() {
|
private nonisolated func createEncoder() async -> (LubyTransformEncoder, Int) {
|
||||||
timer?.invalidate()
|
await Task.detached(priority: .userInitiated) { [data, filename, sliceSize] in
|
||||||
timer = nil
|
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() {
|
private nonisolated static func calculateRequiredFrames(dataSize: Int, sliceSize: Int) -> Int {
|
||||||
guard let encoder else { return }
|
let k = (dataSize + sliceSize - 1) / sliceSize
|
||||||
currentBlock = encoder.fountain().next()
|
if k == 0 { return 1 }
|
||||||
frameCount += 1
|
return max(Int(Double(k) * recoveryFactor), k + 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct QRSSheet: View {
|
public struct QRSSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
private let profileName: String
|
private let profileName: String
|
||||||
private let profileData: Data
|
private let profileData: Data
|
||||||
|
|
||||||
@@ -168,26 +173,25 @@ public struct QRSSheet: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
NavigationSheet(title: String(localized: "Share as QRS")) {
|
QRSDisplayView(data: profileData, filename: "\(profileName).bpf")
|
||||||
VStack {
|
.frame(minWidth: 400, minHeight: 520)
|
||||||
QRSDisplayView(data: profileData)
|
|
||||||
Text("Ask the receiver to scan continuously until complete.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(.bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minWidth: 400, minHeight: 520)
|
|
||||||
#elseif os(iOS) || os(tvOS)
|
#elseif os(iOS) || os(tvOS)
|
||||||
NavigationSheet(title: String(localized: "Share as QRS"), size: .large) {
|
QRSDisplayView(data: profileData, filename: "\(profileName).bpf")
|
||||||
VStack {
|
.modifier(LargeSheetModifier())
|
||||||
QRSDisplayView(data: profileData)
|
|
||||||
Text("Ask the receiver to scan continuously until complete.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(.bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
#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
|
||||||
|
|||||||
@@ -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<Data>()
|
|
||||||
|
|
||||||
let previewView = UIView()
|
|
||||||
var onComplete: ((Data) -> Void)?
|
|
||||||
|
|
||||||
override init() {
|
|
||||||
super.init()
|
|
||||||
previewView.backgroundColor = .black
|
|
||||||
refreshCameraList()
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshCameraList() {
|
|
||||||
var deviceTypes: [AVCaptureDevice.DeviceType] = [
|
|
||||||
.builtInWideAngleCamera,
|
|
||||||
.builtInUltraWideCamera,
|
|
||||||
.builtInTelephotoCamera,
|
|
||||||
]
|
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
deviceTypes.append(.external)
|
|
||||||
}
|
|
||||||
let discoverySession = AVCaptureDevice.DiscoverySession(
|
|
||||||
deviceTypes: deviceTypes,
|
|
||||||
mediaType: .video,
|
|
||||||
position: .unspecified
|
|
||||||
)
|
|
||||||
availableCameras = discoverySession.devices
|
|
||||||
if selectedCamera == nil {
|
|
||||||
selectedCamera = availableCameras.first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectCamera(_ camera: AVCaptureDevice) {
|
|
||||||
guard camera.uniqueID != selectedCamera?.uniqueID else { return }
|
|
||||||
selectedCamera = camera
|
|
||||||
|
|
||||||
if captureSession != nil {
|
|
||||||
stopScanning()
|
|
||||||
captureSession = nil
|
|
||||||
previewLayer?.removeFromSuperlayer()
|
|
||||||
previewLayer = nil
|
|
||||||
startScanning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
decoder = LubyTransformDecoder()
|
|
||||||
seenBlockHashes.removeAll()
|
|
||||||
lastError = nil
|
|
||||||
isComplete = false
|
|
||||||
progress = 0
|
|
||||||
framesScanned = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func startScanning() {
|
|
||||||
guard captureSession == nil else {
|
|
||||||
if captureSession?.isRunning == false {
|
|
||||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
|
||||||
self?.captureSession?.startRunning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
||||||
case .authorized:
|
|
||||||
setupCaptureSession()
|
|
||||||
case .notDetermined:
|
|
||||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if granted {
|
|
||||||
self?.setupCaptureSession()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopScanning() {
|
|
||||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
|
||||||
self?.captureSession?.stopRunning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupCaptureSession() {
|
|
||||||
let session = AVCaptureSession()
|
|
||||||
|
|
||||||
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let videoInput: AVCaptureDeviceInput
|
|
||||||
do {
|
|
||||||
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard session.canAddInput(videoInput) else { return }
|
|
||||||
session.addInput(videoInput)
|
|
||||||
|
|
||||||
let metadataOutput = AVCaptureMetadataOutput()
|
|
||||||
guard session.canAddOutput(metadataOutput) else { return }
|
|
||||||
session.addOutput(metadataOutput)
|
|
||||||
metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
|
|
||||||
metadataOutput.metadataObjectTypes = [.qr]
|
|
||||||
|
|
||||||
self.metadataOutput = metadataOutput
|
|
||||||
captureSession = session
|
|
||||||
|
|
||||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
|
||||||
previewLayer.videoGravity = .resizeAspectFill
|
|
||||||
previewLayer.frame = previewView.bounds
|
|
||||||
previewView.layer.addSublayer(previewLayer)
|
|
||||||
self.previewLayer = previewLayer
|
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
|
||||||
self?.captureSession?.startRunning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updatePreviewFrame(_ frame: CGRect) {
|
|
||||||
previewLayer?.frame = frame
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processQRContent(_ content: String) {
|
|
||||||
guard !isComplete else { return }
|
|
||||||
|
|
||||||
guard let block = EncodedBlock.fromBase64(content) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let hash = block.toBinary()
|
|
||||||
guard !seenBlockHashes.contains(hash) else { return }
|
|
||||||
seenBlockHashes.insert(hash)
|
|
||||||
|
|
||||||
framesScanned += 1
|
|
||||||
|
|
||||||
do {
|
|
||||||
let complete = try decoder.addBlock(block)
|
|
||||||
progress = decoder.progress
|
|
||||||
|
|
||||||
if complete {
|
|
||||||
isComplete = true
|
|
||||||
stopScanning()
|
|
||||||
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
|
||||||
|
|
||||||
if let data = try? decoder.getDecoded() {
|
|
||||||
onComplete?(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
lastError = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension QRSScannerController: AVCaptureMetadataOutputObjectsDelegate {
|
|
||||||
nonisolated func metadataOutput(
|
|
||||||
_ output: AVCaptureMetadataOutput,
|
|
||||||
didOutput metadataObjects: [AVMetadataObject],
|
|
||||||
from connection: AVCaptureConnection
|
|
||||||
) {
|
|
||||||
Task { @MainActor in
|
|
||||||
for metadataObject in metadataObjects {
|
|
||||||
guard let readable = metadataObject as? AVMetadataMachineReadableCodeObject,
|
|
||||||
let content = readable.stringValue
|
|
||||||
else { continue }
|
|
||||||
processQRContent(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct QRSScannerControllerView: UIViewControllerRepresentable {
|
|
||||||
let controller: QRSScannerController
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIViewController {
|
|
||||||
let viewController = QRSScannerViewController(controller: controller)
|
|
||||||
return viewController
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class QRSScannerViewController: UIViewController {
|
|
||||||
let controller: QRSScannerController
|
|
||||||
|
|
||||||
init(controller: QRSScannerController) {
|
|
||||||
self.controller = controller
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
view.backgroundColor = .black
|
|
||||||
view.addSubview(controller.previewView)
|
|
||||||
controller.previewView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
controller.previewView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
controller.previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
controller.previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
controller.previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
controller.updatePreviewFrame(view.bounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
controller.startScanning()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
|
||||||
super.viewWillDisappear(animated)
|
|
||||||
controller.stopScanning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -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<Data>()
|
|
||||||
|
|
||||||
let previewView = NSView()
|
|
||||||
var onComplete: ((Data) -> Void)?
|
|
||||||
|
|
||||||
override init() {
|
|
||||||
super.init()
|
|
||||||
previewView.wantsLayer = true
|
|
||||||
previewView.layer?.backgroundColor = NSColor.black.cgColor
|
|
||||||
refreshCameraList()
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshCameraList() {
|
|
||||||
let discoverySession = AVCaptureDevice.DiscoverySession(
|
|
||||||
deviceTypes: [.builtInWideAngleCamera, .externalUnknown],
|
|
||||||
mediaType: .video,
|
|
||||||
position: .unspecified
|
|
||||||
)
|
|
||||||
availableCameras = discoverySession.devices
|
|
||||||
if selectedCamera == nil {
|
|
||||||
selectedCamera = availableCameras.first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectCamera(_ camera: AVCaptureDevice) {
|
|
||||||
guard camera.uniqueID != selectedCamera?.uniqueID else { return }
|
|
||||||
selectedCamera = camera
|
|
||||||
|
|
||||||
if captureSession != nil {
|
|
||||||
stopScanning()
|
|
||||||
captureSession = nil
|
|
||||||
previewLayer?.removeFromSuperlayer()
|
|
||||||
previewLayer = nil
|
|
||||||
startScanning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
decoder = LubyTransformDecoder()
|
|
||||||
seenBlockHashes.removeAll()
|
|
||||||
lastError = nil
|
|
||||||
isComplete = false
|
|
||||||
progress = 0
|
|
||||||
framesScanned = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func startScanning() {
|
|
||||||
guard captureSession == nil else {
|
|
||||||
if captureSession?.isRunning == false {
|
|
||||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
|
||||||
self?.captureSession?.startRunning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
||||||
case .authorized:
|
|
||||||
setupCaptureSession()
|
|
||||||
case .notDetermined:
|
|
||||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if granted {
|
|
||||||
self?.setupCaptureSession()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopScanning() {
|
|
||||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
|
||||||
self?.captureSession?.stopRunning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupCaptureSession() {
|
|
||||||
let session = AVCaptureSession()
|
|
||||||
|
|
||||||
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let videoInput: AVCaptureDeviceInput
|
|
||||||
do {
|
|
||||||
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard session.canAddInput(videoInput) else { return }
|
|
||||||
session.addInput(videoInput)
|
|
||||||
|
|
||||||
let videoOutput = AVCaptureVideoDataOutput()
|
|
||||||
videoOutput.videoSettings = [
|
|
||||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
|
||||||
]
|
|
||||||
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "QRSScannerQueue"))
|
|
||||||
|
|
||||||
guard session.canAddOutput(videoOutput) else { return }
|
|
||||||
session.addOutput(videoOutput)
|
|
||||||
|
|
||||||
self.videoOutput = videoOutput
|
|
||||||
captureSession = session
|
|
||||||
|
|
||||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
|
||||||
previewLayer.videoGravity = .resizeAspectFill
|
|
||||||
previewLayer.frame = previewView.bounds
|
|
||||||
previewView.layer?.addSublayer(previewLayer)
|
|
||||||
self.previewLayer = previewLayer
|
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
|
||||||
self?.captureSession?.startRunning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updatePreviewFrame(_ frame: CGRect) {
|
|
||||||
previewLayer?.frame = frame
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processQRContent(_ content: String) {
|
|
||||||
guard !isComplete else { return }
|
|
||||||
|
|
||||||
guard let block = EncodedBlock.fromBase64(content) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let hash = block.toBinary()
|
|
||||||
guard !seenBlockHashes.contains(hash) else { return }
|
|
||||||
seenBlockHashes.insert(hash)
|
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
framesScanned += 1
|
|
||||||
|
|
||||||
do {
|
|
||||||
let complete = try decoder.addBlock(block)
|
|
||||||
progress = decoder.progress
|
|
||||||
|
|
||||||
if complete {
|
|
||||||
isComplete = true
|
|
||||||
stopScanning()
|
|
||||||
NSSound.beep()
|
|
||||||
|
|
||||||
if let data = try? decoder.getDecoded() {
|
|
||||||
onComplete?(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
lastError = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension QRSScannerController: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
||||||
nonisolated func captureOutput(
|
|
||||||
_ output: AVCaptureOutput,
|
|
||||||
didOutput sampleBuffer: CMSampleBuffer,
|
|
||||||
from connection: AVCaptureConnection
|
|
||||||
) {
|
|
||||||
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
|
||||||
|
|
||||||
let request = VNDetectBarcodesRequest { [weak self] request, _ in
|
|
||||||
guard let results = request.results as? [VNBarcodeObservation] else { return }
|
|
||||||
|
|
||||||
for result in results {
|
|
||||||
if result.symbology == .qr, let payload = result.payloadStringValue {
|
|
||||||
self?.processQRContent(payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
request.symbologies = [.qr]
|
|
||||||
|
|
||||||
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
|
|
||||||
try? handler.perform([request])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct QRSScannerControllerView: NSViewControllerRepresentable {
|
|
||||||
let controller: QRSScannerController
|
|
||||||
|
|
||||||
func makeNSViewController(context: Context) -> NSViewController {
|
|
||||||
let viewController = QRSScannerViewController(controller: controller)
|
|
||||||
return viewController
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNSViewController(_ nsViewController: NSViewController, context: Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class QRSScannerViewController: NSViewController {
|
|
||||||
let controller: QRSScannerController
|
|
||||||
|
|
||||||
init(controller: QRSScannerController) {
|
|
||||||
self.controller = controller
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func loadView() {
|
|
||||||
view = NSView()
|
|
||||||
view.wantsLayer = true
|
|
||||||
view.layer?.backgroundColor = NSColor.black.cgColor
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
view.addSubview(controller.previewView)
|
|
||||||
controller.previewView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
controller.previewView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
controller.previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
controller.previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
controller.previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
controller.updatePreviewFrame(view.bounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillAppear() {
|
|
||||||
super.viewWillAppear()
|
|
||||||
controller.startScanning()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillDisappear() {
|
|
||||||
super.viewWillDisappear()
|
|
||||||
controller.stopScanning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -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
|
|
||||||
@@ -6,6 +6,7 @@ public enum QRScanError: Error, LocalizedError {
|
|||||||
case permissionDenied
|
case permissionDenied
|
||||||
case scanFailed(Error)
|
case scanFailed(Error)
|
||||||
case invalidCode
|
case invalidCode
|
||||||
|
case qrsDecodeFailed
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -17,16 +18,22 @@ public enum QRScanError: Error, LocalizedError {
|
|||||||
return error.localizedDescription
|
return error.localizedDescription
|
||||||
case .invalidCode:
|
case .invalidCode:
|
||||||
return String(localized: "Invalid QR code")
|
return String(localized: "Invalid QR code")
|
||||||
|
case .qrsDecodeFailed:
|
||||||
|
return String(localized: "Failed to decode QRS data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct QRScanResult: Sendable {
|
public enum QRScanResult: Sendable {
|
||||||
public let string: String
|
case qrCode(string: String, type: AVMetadataObject.ObjectType)
|
||||||
public let type: AVMetadataObject.ObjectType
|
case qrsData(Data)
|
||||||
|
|
||||||
public init(string: String, type: AVMetadataObject.ObjectType) {
|
public var string: String? {
|
||||||
self.string = string
|
switch self {
|
||||||
self.type = type
|
case let .qrCode(string, _):
|
||||||
|
return string
|
||||||
|
case .qrsData:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
@Published var availableCameras: [AVCaptureDevice] = []
|
@Published var availableCameras: [AVCaptureDevice] = []
|
||||||
@Published var selectedCamera: 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<String>()
|
||||||
|
private let decodingQueue = DispatchQueue(label: "QRSDecoding", qos: .userInitiated)
|
||||||
|
|
||||||
let previewView = UIView()
|
let previewView = UIView()
|
||||||
var onScan: ((Result<QRScanResult, QRScanError>) -> Void)?
|
var onScan: ((Result<QRScanResult, QRScanError>) -> Void)?
|
||||||
|
|
||||||
@@ -95,6 +102,11 @@
|
|||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
didFinishScanning = false
|
didFinishScanning = false
|
||||||
|
qrsMode = false
|
||||||
|
decoder = nil
|
||||||
|
progress = 0
|
||||||
|
framesScanned = 0
|
||||||
|
seenBlockIds.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupCaptureSession() {
|
private func setupCaptureSession() {
|
||||||
@@ -129,7 +141,7 @@
|
|||||||
metadataOutput.metadataObjectTypes = [.qr]
|
metadataOutput.metadataObjectTypes = [.qr]
|
||||||
|
|
||||||
self.metadataOutput = metadataOutput
|
self.metadataOutput = metadataOutput
|
||||||
self.captureSession = session
|
captureSession = session
|
||||||
|
|
||||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
previewLayer.videoGravity = .resizeAspectFill
|
previewLayer.videoGravity = .resizeAspectFill
|
||||||
@@ -149,24 +161,79 @@
|
|||||||
|
|
||||||
extension QRScannerController: AVCaptureMetadataOutputObjectsDelegate {
|
extension QRScannerController: AVCaptureMetadataOutputObjectsDelegate {
|
||||||
nonisolated func metadataOutput(
|
nonisolated func metadataOutput(
|
||||||
_ output: AVCaptureMetadataOutput,
|
_: AVCaptureMetadataOutput,
|
||||||
didOutput metadataObjects: [AVMetadataObject],
|
didOutput metadataObjects: [AVMetadataObject],
|
||||||
from connection: AVCaptureConnection
|
from _: AVCaptureConnection
|
||||||
) {
|
) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard !didFinishScanning,
|
guard !didFinishScanning else { return }
|
||||||
let metadataObject = metadataObjects.first,
|
|
||||||
let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
|
|
||||||
let stringValue = readableObject.stringValue
|
|
||||||
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
|
didFinishScanning = true
|
||||||
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
||||||
|
onScan?(.success(.qrCode(string: content, type: type)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let result = QRScanResult(string: stringValue, type: readableObject.type)
|
private func processQRSBlock(_ block: EncodedBlock) {
|
||||||
onScan?(.success(result))
|
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 {
|
struct QRScannerControllerView: UIViewControllerRepresentable {
|
||||||
let controller: QRScannerController
|
let controller: QRScannerController
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIViewController {
|
func makeUIViewController(context _: Context) -> UIViewController {
|
||||||
let viewController = QRScannerViewController(controller: controller)
|
let viewController = QRScannerViewController(controller: controller)
|
||||||
return viewController
|
return viewController
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
func updateUIViewController(_: UIViewController, context _: Context) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class QRScannerViewController: UIViewController {
|
private class QRScannerViewController: UIViewController {
|
||||||
@@ -191,7 +258,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder _: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
@Published var availableCameras: [AVCaptureDevice] = []
|
@Published var availableCameras: [AVCaptureDevice] = []
|
||||||
@Published var selectedCamera: 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<String>()
|
||||||
|
private let decodingQueue = DispatchQueue(label: "QRSDecoding", qos: .userInitiated)
|
||||||
|
|
||||||
let previewView = NSView()
|
let previewView = NSView()
|
||||||
var onScan: ((Result<QRScanResult, QRScanError>) -> Void)?
|
var onScan: ((Result<QRScanResult, QRScanError>) -> Void)?
|
||||||
|
|
||||||
@@ -89,6 +96,11 @@
|
|||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
didFinishScanning = false
|
didFinishScanning = false
|
||||||
|
qrsMode = false
|
||||||
|
decoder = nil
|
||||||
|
progress = 0
|
||||||
|
framesScanned = 0
|
||||||
|
seenBlockIds.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupCaptureSession() {
|
private func setupCaptureSession() {
|
||||||
@@ -126,7 +138,7 @@
|
|||||||
session.addOutput(videoOutput)
|
session.addOutput(videoOutput)
|
||||||
|
|
||||||
self.videoOutput = videoOutput
|
self.videoOutput = videoOutput
|
||||||
self.captureSession = session
|
captureSession = session
|
||||||
|
|
||||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
previewLayer.videoGravity = .resizeAspectFill
|
previewLayer.videoGravity = .resizeAspectFill
|
||||||
@@ -143,23 +155,75 @@
|
|||||||
previewLayer?.frame = frame
|
previewLayer?.frame = frame
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processQRCode(_ payloadString: String) {
|
private func processScannedContent(_ content: String) {
|
||||||
guard !didFinishScanning else { return }
|
|
||||||
didFinishScanning = true
|
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
NSSound.beep()
|
guard let self, !didFinishScanning else { return }
|
||||||
let result = QRScanResult(string: payloadString, type: .qr)
|
|
||||||
self?.onScan?(.success(result))
|
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 {
|
extension QRScannerController: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
nonisolated func captureOutput(
|
nonisolated func captureOutput(
|
||||||
_ output: AVCaptureOutput,
|
_: AVCaptureOutput,
|
||||||
didOutput sampleBuffer: CMSampleBuffer,
|
didOutput sampleBuffer: CMSampleBuffer,
|
||||||
from connection: AVCaptureConnection
|
from _: AVCaptureConnection
|
||||||
) {
|
) {
|
||||||
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||||
|
|
||||||
@@ -168,8 +232,7 @@
|
|||||||
|
|
||||||
for result in results {
|
for result in results {
|
||||||
if result.symbology == .qr, let payload = result.payloadStringValue {
|
if result.symbology == .qr, let payload = result.payloadStringValue {
|
||||||
self?.processQRCode(payload)
|
self?.processScannedContent(payload)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,12 +246,12 @@
|
|||||||
struct QRScannerControllerView: NSViewControllerRepresentable {
|
struct QRScannerControllerView: NSViewControllerRepresentable {
|
||||||
let controller: QRScannerController
|
let controller: QRScannerController
|
||||||
|
|
||||||
func makeNSViewController(context: Context) -> NSViewController {
|
func makeNSViewController(context _: Context) -> NSViewController {
|
||||||
let viewController = QRScannerViewController(controller: controller)
|
let viewController = QRScannerViewController(controller: controller)
|
||||||
return viewController
|
return viewController
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSViewController(_ nsViewController: NSViewController, context: Context) {}
|
func updateNSViewController(_: NSViewController, context _: Context) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class QRScannerViewController: NSViewController {
|
private class QRScannerViewController: NSViewController {
|
||||||
@@ -200,7 +263,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder _: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,36 +27,42 @@
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private var iOSBody: some View {
|
private var iOSBody: some View {
|
||||||
NavigationStackCompat {
|
NavigationStackCompat {
|
||||||
QRScannerControllerView(controller: controller)
|
ZStack {
|
||||||
.ignoresSafeArea()
|
QRScannerControllerView(controller: controller)
|
||||||
.navigationTitle("Scan QR Code")
|
.ignoresSafeArea()
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
if controller.qrsMode {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
qrsProgressOverlay
|
||||||
Button("Cancel") { dismiss() }
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .primaryAction) {
|
.navigationTitle("Scan QR Code")
|
||||||
Menu {
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
if controller.availableCameras.count > 1 {
|
.toolbar {
|
||||||
Menu("Camera") {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
|
Button("Cancel") { dismiss() }
|
||||||
Button {
|
}
|
||||||
controller.selectCamera(camera)
|
ToolbarItem(placement: .primaryAction) {
|
||||||
} label: {
|
Menu {
|
||||||
if camera.uniqueID == controller.selectedCamera?.uniqueID {
|
if controller.availableCameras.count > 1 {
|
||||||
Label(camera.localizedName, systemImage: "checkmark")
|
Menu("Camera") {
|
||||||
} else {
|
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
|
||||||
Text(camera.localizedName)
|
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)
|
.alert($alert)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -68,8 +74,14 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private var macOSBody: some View {
|
private var macOSBody: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
QRScannerControllerView(controller: controller)
|
ZStack {
|
||||||
.frame(minWidth: 400, minHeight: 300)
|
QRScannerControllerView(controller: controller)
|
||||||
|
|
||||||
|
if controller.qrsMode {
|
||||||
|
qrsProgressOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 400, minHeight: 300)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -102,11 +114,29 @@
|
|||||||
}
|
}
|
||||||
#endif
|
#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<QRScanResult, QRScanError>) {
|
private func handleScanResult(_ result: Result<QRScanResult, QRScanError>) {
|
||||||
switch result {
|
switch result {
|
||||||
case let .success(scanResult):
|
case let .success(scanResult):
|
||||||
dismiss()
|
dismiss()
|
||||||
onScan(scanResult)
|
Task { @MainActor in
|
||||||
|
onScan(scanResult)
|
||||||
|
}
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
switch error {
|
switch error {
|
||||||
case .permissionDenied:
|
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
|
#endif
|
||||||
|
|||||||
@@ -76,24 +76,24 @@ public struct OnDemandRulesView: View {
|
|||||||
.environment(\.editMode, $editMode)
|
.environment(\.editMode, $editMode)
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.platformSheet(isPresented: $isAddingRule) {
|
.platformSheet(isPresented: $isAddingRule) {
|
||||||
OnDemandRuleEditView(rule: OnDemandRule(), isNew: true) { newRule in
|
OnDemandRuleEditView(rule: OnDemandRule(), isNew: true) { newRule in
|
||||||
rules.append(newRule)
|
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 {
|
Task {
|
||||||
await saveRules()
|
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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ public struct OnDemandRulesView: View {
|
|||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -210,9 +210,9 @@ public struct OnDemandRulesView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -362,36 +362,36 @@ private struct OnDemandRuleEditView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button(isNew ? "Create" : "Save") {
|
|
||||||
onSave(rule)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.disabled(!isProbeURLValid)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(isNew ? "Create" : "Save") {
|
||||||
|
onSave(rule)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(!isProbeURLValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
#endif
|
#endif
|
||||||
.platformSheet(isPresented: $isAddingConnectionRule, size: .small) {
|
.platformSheet(isPresented: $isAddingConnectionRule, size: .small) {
|
||||||
EvaluateConnectionRuleEditView(rule: EvaluateConnectionRule()) { newRule in
|
EvaluateConnectionRuleEditView(rule: EvaluateConnectionRule()) { newRule in
|
||||||
rule.connectionRules.append(newRule)
|
rule.connectionRules.append(newRule)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.platformSheet(item: $editingConnectionRule, size: .small) { connRule in
|
}
|
||||||
EvaluateConnectionRuleEditView(rule: connRule) { updatedRule in
|
.platformSheet(item: $editingConnectionRule, size: .small) { connRule in
|
||||||
if let index = rule.connectionRules.firstIndex(where: { $0.id == updatedRule.id }) {
|
EvaluateConnectionRuleEditView(rule: connRule) { updatedRule in
|
||||||
rule.connectionRules[index] = updatedRule
|
if let index = rule.connectionRules.firstIndex(where: { $0.id == updatedRule.id }) {
|
||||||
}
|
rule.connectionRules[index] = updatedRule
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var actionSection: some View {
|
private var actionSection: some View {
|
||||||
|
|||||||
@@ -16,13 +16,21 @@ public extension Profile {
|
|||||||
content.autoUpdate = autoUpdate
|
content.autoUpdate = autoUpdate
|
||||||
content.autoUpdateInterval = autoUpdateInterval
|
content.autoUpdateInterval = autoUpdateInterval
|
||||||
if let lastUpdated {
|
if let lastUpdated {
|
||||||
content.lastUpdated = Int64(lastUpdated.timeIntervalSince1970)
|
content.lastUpdated = Int64(lastUpdated.timeIntervalSince1970 * 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return content
|
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, *)
|
@available(iOS 16.0, macOS 13.0, *)
|
||||||
extension Profile: Transferable {
|
extension Profile: Transferable {
|
||||||
public static var transferRepresentation: some TransferRepresentation {
|
public static var transferRepresentation: some TransferRepresentation {
|
||||||
@@ -51,7 +59,7 @@ public extension LibboxProfileContent {
|
|||||||
try config.write(to: profileConfig, atomically: true, encoding: .utf8)
|
try config.write(to: profileConfig, atomically: true, encoding: .utf8)
|
||||||
var lastUpdatedAt: Date?
|
var lastUpdatedAt: Date?
|
||||||
if lastUpdated > 0 {
|
if lastUpdated > 0 {
|
||||||
lastUpdatedAt = Date(timeIntervalSince1970: Double(lastUpdated))
|
lastUpdatedAt = dateFromTimestamp(lastUpdated)
|
||||||
}
|
}
|
||||||
let uniqueProfileName = try await ProfileManager.uniqueName(name)
|
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)
|
let profile = Profile(name: uniqueProfileName, type: ProfileType(rawValue: Int(type))!, path: profileConfig.relativePath, remoteURL: remotePath, autoUpdate: autoUpdate, autoUpdateInterval: autoUpdateInterval, lastUpdated: lastUpdatedAt)
|
||||||
|
|||||||
@@ -43,6 +43,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"%lld%%" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"↑ %@" : {
|
"↑ %@" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
@@ -1049,6 +1052,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Failed to decode QRS data" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "解码 QRS 数据失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"false" : {
|
"false" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -1089,6 +1102,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"FPS" : {
|
||||||
|
"shouldTranslate" : false
|
||||||
|
},
|
||||||
"From Outbound" : {
|
"From Outbound" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -1430,6 +1446,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Last Updated: %@" : {
|
"Last Updated: %@" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1889,6 +1906,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"QRS" : {
|
||||||
|
"shouldTranslate" : false
|
||||||
|
},
|
||||||
"Quit" : {
|
"Quit" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"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" : {
|
"Scan QR Code" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -2113,6 +2153,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Share as QRS Code" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "通过 QRS 码分享"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Share Content JSON File" : {
|
"Share Content JSON File" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -2166,6 +2216,16 @@
|
|||||||
"sing-box" : {
|
"sing-box" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
|
"Slice Size" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "分片大小"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Sort By" : {
|
"Sort By" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"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." : {
|
"When action is 'Evaluate Connection', these rules determine whether to connect based on the destination host." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
|
|||||||
Reference in New Issue
Block a user