Add save file option for profile sharing
Add "Save File" and "Save Content JSON" options to profile share menu, allowing users to save profiles directly to a chosen location using fileExporter instead of the system share sheet.
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CRC32 {
|
||||||
|
private static let table: [UInt32] = {
|
||||||
|
var table = [UInt32](repeating: 0, count: 256)
|
||||||
|
for i in 0 ..< 256 {
|
||||||
|
var crc = UInt32(i)
|
||||||
|
for _ in 0 ..< 8 {
|
||||||
|
if crc & 1 != 0 {
|
||||||
|
crc = (crc >> 1) ^ 0xEDB8_8320
|
||||||
|
} else {
|
||||||
|
crc = crc >> 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table[i] = crc
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func checksum(_ data: Data, k: Int) -> UInt32 {
|
||||||
|
var crc: UInt32 = 0xFFFF_FFFF
|
||||||
|
for byte in data {
|
||||||
|
let index = Int((crc ^ UInt32(byte)) & 0xFF)
|
||||||
|
crc = (crc >> 8) ^ table[index]
|
||||||
|
}
|
||||||
|
return crc ^ UInt32(k) ^ 0xFFFF_FFFF
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct EncodedBlock {
|
||||||
|
var indices: [Int]
|
||||||
|
var data: Data
|
||||||
|
let k: Int
|
||||||
|
let bytes: Int
|
||||||
|
let checksum: UInt32
|
||||||
|
|
||||||
|
// Binary format: degree(4) + indices(4*n) + k(4) + bytes(4) + checksum(4) + data
|
||||||
|
func toBinary() -> Data {
|
||||||
|
var result = Data()
|
||||||
|
|
||||||
|
// Write degree (number of indices)
|
||||||
|
var degree = UInt32(indices.count).littleEndian
|
||||||
|
result.append(Data(bytes: °ree, count: 4))
|
||||||
|
|
||||||
|
// Write indices
|
||||||
|
for index in indices {
|
||||||
|
var idx = UInt32(index).littleEndian
|
||||||
|
result.append(Data(bytes: &idx, count: 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write k, bytes, checksum
|
||||||
|
var kVal = UInt32(k).littleEndian
|
||||||
|
var bytesVal = UInt32(bytes).littleEndian
|
||||||
|
var checksumVal = checksum.littleEndian
|
||||||
|
result.append(Data(bytes: &kVal, count: 4))
|
||||||
|
result.append(Data(bytes: &bytesVal, count: 4))
|
||||||
|
result.append(Data(bytes: &checksumVal, count: 4))
|
||||||
|
|
||||||
|
// Write data
|
||||||
|
result.append(data)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromBinary(_ binary: Data) -> EncodedBlock? {
|
||||||
|
guard binary.count >= 16 else { return nil }
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
let degree = binary.withUnsafeBytes {
|
||||||
|
$0.load(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||||
|
}
|
||||||
|
offset += 4
|
||||||
|
|
||||||
|
guard binary.count >= 4 + Int(degree) * 4 + 12 else { return nil }
|
||||||
|
|
||||||
|
var indices: [Int] = []
|
||||||
|
for _ in 0 ..< degree {
|
||||||
|
let idx = binary.withUnsafeBytes {
|
||||||
|
$0.load(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||||
|
}
|
||||||
|
indices.append(Int(idx))
|
||||||
|
offset += 4
|
||||||
|
}
|
||||||
|
|
||||||
|
let k = Int(binary.withUnsafeBytes {
|
||||||
|
$0.load(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||||
|
})
|
||||||
|
offset += 4
|
||||||
|
|
||||||
|
let bytes = Int(binary.withUnsafeBytes {
|
||||||
|
$0.load(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||||
|
})
|
||||||
|
offset += 4
|
||||||
|
|
||||||
|
let checksum = binary.withUnsafeBytes {
|
||||||
|
$0.load(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||||
|
}
|
||||||
|
offset += 4
|
||||||
|
|
||||||
|
let data = binary.subdata(in: offset ..< binary.count)
|
||||||
|
|
||||||
|
return EncodedBlock(indices: indices, data: data, k: k, bytes: bytes, checksum: checksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBase64() -> String {
|
||||||
|
toBinary().base64EncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromBase64(_ string: String) -> EncodedBlock? {
|
||||||
|
guard let data = Data(base64Encoded: string) else { return nil }
|
||||||
|
return fromBinary(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import Compression
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class LubyTransformDecoder {
|
||||||
|
private(set) var decodedData: [Data?] = []
|
||||||
|
private(set) var decodedCount = 0
|
||||||
|
private(set) var encodedCount = 0
|
||||||
|
private var encodedBlocks: Set<BlockWrapper> = []
|
||||||
|
private var encodedBlockKeyMap: [String: BlockWrapper] = [:]
|
||||||
|
private var encodedBlockSubkeyMap: [String: Set<BlockWrapper>] = [:]
|
||||||
|
private var encodedBlockIndexMap: [Int: Set<BlockWrapper>] = [:]
|
||||||
|
private var disposedEncodedBlocks: [Int: [() -> Void]] = [:]
|
||||||
|
private(set) var meta: EncodedBlock?
|
||||||
|
|
||||||
|
var k: Int { meta?.k ?? 0 }
|
||||||
|
var progress: Double {
|
||||||
|
guard k > 0 else { return 0 }
|
||||||
|
return Double(decodedCount) / Double(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isComplete: Bool { meta != nil && decodedCount == k }
|
||||||
|
|
||||||
|
private class BlockWrapper: Hashable {
|
||||||
|
var block: EncodedBlock
|
||||||
|
let id = UUID()
|
||||||
|
|
||||||
|
init(_ block: EncodedBlock) { self.block = block }
|
||||||
|
|
||||||
|
static func == (lhs: BlockWrapper, rhs: BlockWrapper) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DecoderError: Error {
|
||||||
|
case checksumMismatch
|
||||||
|
case incomplete
|
||||||
|
case noMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func addBlock(_ block: EncodedBlock) throws -> Bool {
|
||||||
|
if meta == nil {
|
||||||
|
meta = block
|
||||||
|
decodedData = Array(repeating: nil, count: block.k)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard block.checksum == meta?.checksum else {
|
||||||
|
throw DecoderError.checksumMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedCount += 1
|
||||||
|
|
||||||
|
var mutableBlock = block
|
||||||
|
mutableBlock.indices.sort()
|
||||||
|
let wrapper = BlockWrapper(mutableBlock)
|
||||||
|
propagateDecoded(key: indicesToKey(mutableBlock.indices), wrapper: wrapper)
|
||||||
|
|
||||||
|
return decodedCount == k
|
||||||
|
}
|
||||||
|
|
||||||
|
private func indicesToKey(_ indices: [Int]) -> String {
|
||||||
|
indices.map(String.init).joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xorData(_ a: Data, _ b: Data) -> Data {
|
||||||
|
var result = a
|
||||||
|
let count = min(a.count, b.count)
|
||||||
|
for i in 0 ..< count {
|
||||||
|
result[i] ^= b[i]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func propagateDecoded(key: String, wrapper: BlockWrapper) {
|
||||||
|
var block = wrapper.block
|
||||||
|
var indices = block.indices
|
||||||
|
var indicesSet = Set(indices)
|
||||||
|
|
||||||
|
if encodedBlockKeyMap[key] != nil || indices.allSatisfy({ decodedData[$0] != nil }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// XOR with already decoded blocks to reduce degree
|
||||||
|
if indices.count > 1 {
|
||||||
|
for index in indices {
|
||||||
|
if let decoded = decodedData[index] {
|
||||||
|
block.data = xorData(block.data, decoded)
|
||||||
|
indicesSet.remove(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if indicesSet.count != indices.count {
|
||||||
|
indices = Array(indicesSet).sorted()
|
||||||
|
block.indices = indices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try subset matching for blocks with degree > 2
|
||||||
|
if indices.count > 2 {
|
||||||
|
var subkeys: [(index: Int, subkey: String)] = []
|
||||||
|
for index in indices {
|
||||||
|
let subIndices = indices.filter { $0 != index }
|
||||||
|
let subkey = indicesToKey(subIndices)
|
||||||
|
if let subWrapper = encodedBlockKeyMap[subkey] {
|
||||||
|
block.data = xorData(block.data, subWrapper.block.data)
|
||||||
|
for i in subWrapper.block.indices {
|
||||||
|
indicesSet.remove(i)
|
||||||
|
}
|
||||||
|
indices = Array(indicesSet).sorted()
|
||||||
|
block.indices = indices
|
||||||
|
subkeys.removeAll()
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
subkeys.append((index, subkey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store subkeys for future matching if still high degree
|
||||||
|
if indicesSet.count > 1 {
|
||||||
|
for (index, subkey) in subkeys {
|
||||||
|
let dispose = { [weak self] in
|
||||||
|
self?.encodedBlockSubkeyMap[subkey]?.remove(wrapper)
|
||||||
|
}
|
||||||
|
if encodedBlockSubkeyMap[subkey] == nil {
|
||||||
|
encodedBlockSubkeyMap[subkey] = []
|
||||||
|
}
|
||||||
|
encodedBlockSubkeyMap[subkey]?.insert(wrapper)
|
||||||
|
if disposedEncodedBlocks[index] == nil {
|
||||||
|
disposedEncodedBlocks[index] = []
|
||||||
|
}
|
||||||
|
disposedEncodedBlocks[index]?.append(dispose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.block = block
|
||||||
|
|
||||||
|
// If still degree > 1, store as pending
|
||||||
|
if indices.count > 1 {
|
||||||
|
encodedBlocks.insert(wrapper)
|
||||||
|
for i in indices {
|
||||||
|
if encodedBlockIndexMap[i] == nil {
|
||||||
|
encodedBlockIndexMap[i] = []
|
||||||
|
}
|
||||||
|
encodedBlockIndexMap[i]?.insert(wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
let newKey = indicesToKey(indices)
|
||||||
|
encodedBlockKeyMap[newKey] = wrapper
|
||||||
|
|
||||||
|
// Check if this can decode pending supersets
|
||||||
|
if let superset = encodedBlockSubkeyMap[newKey] {
|
||||||
|
encodedBlockSubkeyMap.removeValue(forKey: newKey)
|
||||||
|
for superWrapper in superset {
|
||||||
|
var superBlock = superWrapper.block
|
||||||
|
superBlock.data = xorData(superBlock.data, block.data)
|
||||||
|
var superIndicesSet = Set(superBlock.indices)
|
||||||
|
for i in indices {
|
||||||
|
superIndicesSet.remove(i)
|
||||||
|
}
|
||||||
|
superBlock.indices = Array(superIndicesSet).sorted()
|
||||||
|
superWrapper.block = superBlock
|
||||||
|
propagateDecoded(key: indicesToKey(superBlock.indices), wrapper: superWrapper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Degree 1: directly decode
|
||||||
|
else if let index = indices.first, decodedData[index] == nil {
|
||||||
|
encodedBlocks.remove(wrapper)
|
||||||
|
disposedEncodedBlocks[index]?.forEach { $0() }
|
||||||
|
decodedData[index] = block.data
|
||||||
|
decodedCount += 1
|
||||||
|
|
||||||
|
// Propagate to waiting blocks
|
||||||
|
if let waitingBlocks = encodedBlockIndexMap[index] {
|
||||||
|
encodedBlockIndexMap.removeValue(forKey: index)
|
||||||
|
for waiting in waitingBlocks {
|
||||||
|
let waitingKey = indicesToKey(waiting.block.indices)
|
||||||
|
encodedBlockKeyMap.removeValue(forKey: waitingKey)
|
||||||
|
propagateDecoded(key: waitingKey, wrapper: waiting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDecoded() throws -> Data {
|
||||||
|
guard decodedCount == k else {
|
||||||
|
throw DecoderError.incomplete
|
||||||
|
}
|
||||||
|
guard decodedData.allSatisfy({ $0 != nil }) else {
|
||||||
|
throw DecoderError.incomplete
|
||||||
|
}
|
||||||
|
guard let meta else {
|
||||||
|
throw DecoderError.noMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
let sliceSize = meta.data.count
|
||||||
|
var result = Data(capacity: meta.bytes)
|
||||||
|
|
||||||
|
for (i, block) in decodedData.enumerated() {
|
||||||
|
guard let block else { continue }
|
||||||
|
let start = i * sliceSize
|
||||||
|
let copyLength = min(sliceSize, meta.bytes - start)
|
||||||
|
if copyLength > 0 {
|
||||||
|
result.append(block.prefix(copyLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try decompression
|
||||||
|
if let decompressed = Self.inflate(result) {
|
||||||
|
let checksum = CRC32.checksum(decompressed, k: meta.k)
|
||||||
|
if checksum == meta.checksum {
|
||||||
|
return decompressed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to uncompressed
|
||||||
|
let checksum = CRC32.checksum(result, k: meta.k)
|
||||||
|
if checksum == meta.checksum {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
throw DecoderError.checksumMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inflate(_ data: Data) -> Data? {
|
||||||
|
let sourceSize = data.count
|
||||||
|
let destinationSize = sourceSize * 10
|
||||||
|
|
||||||
|
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: destinationSize)
|
||||||
|
defer { destinationBuffer.deallocate() }
|
||||||
|
|
||||||
|
let decompressedSize = data.withUnsafeBytes { sourcePtr -> Int in
|
||||||
|
guard let baseAddress = sourcePtr.baseAddress else { return 0 }
|
||||||
|
return compression_decode_buffer(
|
||||||
|
destinationBuffer,
|
||||||
|
destinationSize,
|
||||||
|
baseAddress.assumingMemoryBound(to: UInt8.self),
|
||||||
|
sourceSize,
|
||||||
|
nil,
|
||||||
|
COMPRESSION_ZLIB
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard decompressedSize > 0 else { return nil }
|
||||||
|
return Data(bytes: destinationBuffer, count: decompressedSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import Compression
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class LubyTransformEncoder {
|
||||||
|
let k: Int
|
||||||
|
let sliceSize: Int
|
||||||
|
let checksum: UInt32
|
||||||
|
let bytes: Int
|
||||||
|
private let sourceBlocks: [Data]
|
||||||
|
|
||||||
|
init(data: Data, sliceSize: Int = 500, compress: Bool = true) {
|
||||||
|
self.sliceSize = sliceSize
|
||||||
|
|
||||||
|
let compressed: Data
|
||||||
|
if compress {
|
||||||
|
compressed = Self.deflateCompress(data) ?? data
|
||||||
|
} else {
|
||||||
|
compressed = data
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes = compressed.count
|
||||||
|
sourceBlocks = Self.sliceData(compressed, sliceSize: sliceSize)
|
||||||
|
k = sourceBlocks.count
|
||||||
|
checksum = CRC32.checksum(data, k: k)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deflateCompress(_ data: Data) -> Data? {
|
||||||
|
let sourceSize = data.count
|
||||||
|
let destinationSize = sourceSize + 1024
|
||||||
|
|
||||||
|
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: destinationSize)
|
||||||
|
defer { destinationBuffer.deallocate() }
|
||||||
|
|
||||||
|
let compressedSize = data.withUnsafeBytes { sourcePtr -> Int in
|
||||||
|
guard let baseAddress = sourcePtr.baseAddress else { return 0 }
|
||||||
|
return compression_encode_buffer(
|
||||||
|
destinationBuffer,
|
||||||
|
destinationSize,
|
||||||
|
baseAddress.assumingMemoryBound(to: UInt8.self),
|
||||||
|
sourceSize,
|
||||||
|
nil,
|
||||||
|
COMPRESSION_ZLIB
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard compressedSize > 0 else { return nil }
|
||||||
|
return Data(bytes: destinationBuffer, count: compressedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sliceData(_ data: Data, sliceSize: Int) -> [Data] {
|
||||||
|
var blocks: [Data] = []
|
||||||
|
var offset = 0
|
||||||
|
while offset < data.count {
|
||||||
|
let end = min(offset + sliceSize, data.count)
|
||||||
|
var block = data.subdata(in: offset ..< end)
|
||||||
|
if block.count < sliceSize {
|
||||||
|
block.append(Data(count: sliceSize - block.count))
|
||||||
|
}
|
||||||
|
blocks.append(block)
|
||||||
|
offset += sliceSize
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBlock(indices: [Int]) -> EncodedBlock {
|
||||||
|
var result = Data(count: sliceSize)
|
||||||
|
for index in indices {
|
||||||
|
let source = sourceBlocks[index]
|
||||||
|
for i in 0 ..< sliceSize {
|
||||||
|
result[i] ^= source[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EncodedBlock(
|
||||||
|
indices: indices,
|
||||||
|
data: result,
|
||||||
|
k: k,
|
||||||
|
bytes: bytes,
|
||||||
|
checksum: checksum
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ideal Soliton Distribution for degree selection
|
||||||
|
private func getRandomDegree() -> Int {
|
||||||
|
var probabilities = [Double](repeating: 0, count: k)
|
||||||
|
probabilities[0] = 1.0 / Double(k)
|
||||||
|
for d in 2 ... k {
|
||||||
|
probabilities[d - 1] = 1.0 / Double(d * (d - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
var cumulative = [Double](repeating: 0, count: k)
|
||||||
|
cumulative[0] = probabilities[0]
|
||||||
|
for i in 1 ..< k {
|
||||||
|
cumulative[i] = cumulative[i - 1] + probabilities[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
let random = Double.random(in: 0 ... 1)
|
||||||
|
for i in 0 ..< k {
|
||||||
|
if random < cumulative[i] {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getRandomIndices(degree: Int) -> [Int] {
|
||||||
|
var indices = Set<Int>()
|
||||||
|
while indices.count < degree {
|
||||||
|
indices.insert(Int.random(in: 0 ..< k))
|
||||||
|
}
|
||||||
|
return Array(indices)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fountain() -> AnyIterator<EncodedBlock> {
|
||||||
|
AnyIterator {
|
||||||
|
let degree = self.getRandomDegree()
|
||||||
|
let indices = self.getRandomIndices(degree: degree)
|
||||||
|
return self.createBlock(indices: indices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,28 @@ public struct ProfileCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
.fileExporter(
|
||||||
|
isPresented: $viewModel.showProfileExporter,
|
||||||
|
document: viewModel.profileExportDocument,
|
||||||
|
contentType: .profile,
|
||||||
|
defaultFilename: viewModel.profileExportDocument?.filename
|
||||||
|
) { result in
|
||||||
|
viewModel.profileExportDocument = nil
|
||||||
|
if case let .failure(error) = result {
|
||||||
|
viewModel.alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fileExporter(
|
||||||
|
isPresented: $viewModel.showJSONExporter,
|
||||||
|
document: viewModel.profileJSONExportDocument,
|
||||||
|
contentType: .json,
|
||||||
|
defaultFilename: viewModel.profileJSONExportDocument?.filename
|
||||||
|
) { result in
|
||||||
|
viewModel.profileJSONExportDocument = nil
|
||||||
|
if case let .failure(error) = result {
|
||||||
|
viewModel.alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
.alert($viewModel.alert)
|
.alert($viewModel.alert)
|
||||||
}
|
}
|
||||||
@@ -244,12 +266,30 @@ public struct ProfileCard: View {
|
|||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
Menu {
|
Menu {
|
||||||
|
Button {
|
||||||
|
exportProfile(profile, type: .file)
|
||||||
|
} label: {
|
||||||
|
Label("Save File", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.shareItemType = .file
|
viewModel.shareItemType = .file
|
||||||
} label: {
|
} label: {
|
||||||
Label("Share File", systemImage: "doc")
|
Label("Share File", systemImage: "doc")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
exportProfile(profile, type: .json)
|
||||||
|
} label: {
|
||||||
|
Label("Save Content JSON", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.shareItemType = .json
|
||||||
|
} label: {
|
||||||
|
Label("Share Content JSON File", systemImage: "curlybraces")
|
||||||
|
}
|
||||||
|
|
||||||
if profile.type == .remote {
|
if profile.type == .remote {
|
||||||
Button {
|
Button {
|
||||||
viewModel.showQRCode = true
|
viewModel.showQRCode = true
|
||||||
@@ -257,12 +297,6 @@ public struct ProfileCard: View {
|
|||||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.shareItemType = .json
|
|
||||||
} label: {
|
|
||||||
Label("Share Content JSON File", systemImage: "curlybraces")
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
@@ -312,6 +346,21 @@ public struct ProfileCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func exportProfile(_ profile: ProfilePreview, type: ExportItemType) {
|
||||||
|
do {
|
||||||
|
switch type {
|
||||||
|
case .file:
|
||||||
|
viewModel.profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
||||||
|
viewModel.showProfileExporter = true
|
||||||
|
case .json:
|
||||||
|
viewModel.profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
|
||||||
|
viewModel.showJSONExporter = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
viewModel.alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private func presentShareController(_ item: URL) {
|
private func presentShareController(_ item: URL) {
|
||||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
@@ -403,6 +452,11 @@ extension ProfileCard {
|
|||||||
case json
|
case json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ExportItemType {
|
||||||
|
case file
|
||||||
|
case json
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var showNewProfile = false
|
@Published var showNewProfile = false
|
||||||
@@ -412,6 +466,10 @@ extension ProfileCard {
|
|||||||
@Published var alert: AlertState?
|
@Published var alert: AlertState?
|
||||||
@Published var profileToEdit: Profile?
|
@Published var profileToEdit: Profile?
|
||||||
@Published var shareItemType: ShareItemType?
|
@Published var shareItemType: ShareItemType?
|
||||||
|
@Published var profileExportDocument: ProfileExportDocument?
|
||||||
|
@Published var showProfileExporter = false
|
||||||
|
@Published var profileJSONExportDocument: ProfileJSONExportDocument?
|
||||||
|
@Published var showJSONExporter = false
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
var shareButtonView: NSView?
|
var shareButtonView: NSView?
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -533,8 +533,19 @@ private struct ProfilePickerRow: View {
|
|||||||
@State private var showQRCode = false
|
@State private var showQRCode = 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 profileExportDocument: ProfileExportDocument?
|
||||||
|
@State private var showProfileExporter = false
|
||||||
|
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
|
||||||
|
@State private var showJSONExporter = false
|
||||||
@State private var menuAnchorView: NSView?
|
@State private var menuAnchorView: NSView?
|
||||||
#endif
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
@State private var profileExportDocument: ProfileExportDocument?
|
||||||
|
@State private var showProfileExporter = false
|
||||||
|
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
|
||||||
|
@State private var showJSONExporter = false
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@@ -697,6 +708,28 @@ private struct ProfilePickerRow: View {
|
|||||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fileExporter(
|
||||||
|
isPresented: $showProfileExporter,
|
||||||
|
document: profileExportDocument,
|
||||||
|
contentType: .profile,
|
||||||
|
defaultFilename: profileExportDocument?.filename
|
||||||
|
) { result in
|
||||||
|
profileExportDocument = nil
|
||||||
|
if case let .failure(error) = result {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fileExporter(
|
||||||
|
isPresented: $showJSONExporter,
|
||||||
|
document: profileJSONExportDocument,
|
||||||
|
contentType: .json,
|
||||||
|
defaultFilename: profileJSONExportDocument?.filename
|
||||||
|
) { result in
|
||||||
|
profileJSONExportDocument = nil
|
||||||
|
if case let .failure(error) = result {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var macOSEditingBody: some View {
|
private var macOSEditingBody: some View {
|
||||||
@@ -720,6 +753,28 @@ private struct ProfilePickerRow: View {
|
|||||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fileExporter(
|
||||||
|
isPresented: $showProfileExporter,
|
||||||
|
document: profileExportDocument,
|
||||||
|
contentType: .profile,
|
||||||
|
defaultFilename: profileExportDocument?.filename
|
||||||
|
) { result in
|
||||||
|
profileExportDocument = nil
|
||||||
|
if case let .failure(error) = result {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fileExporter(
|
||||||
|
isPresented: $showJSONExporter,
|
||||||
|
document: profileJSONExportDocument,
|
||||||
|
contentType: .json,
|
||||||
|
defaultFilename: profileJSONExportDocument?.filename
|
||||||
|
) { result in
|
||||||
|
profileJSONExportDocument = nil
|
||||||
|
if case let .failure(error) = result {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -828,6 +883,11 @@ private struct ProfilePickerRow: View {
|
|||||||
self.shareItemType = nil
|
self.shareItemType = nil
|
||||||
shareProfile(type: shareItemType)
|
shareProfile(type: shareItemType)
|
||||||
}
|
}
|
||||||
|
.onChange(of: exportItemType) { exportItemType in
|
||||||
|
guard let exportItemType else { return }
|
||||||
|
self.exportItemType = nil
|
||||||
|
exportProfileMacOS(type: exportItemType)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,17 +896,53 @@ private struct ProfilePickerRow: View {
|
|||||||
private var shareMenu: some View {
|
private var shareMenu: some View {
|
||||||
Menu {
|
Menu {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
Button {
|
||||||
|
exportItemType = .file
|
||||||
|
} label: {
|
||||||
|
Label("Save File", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
shareItemType = .file
|
shareItemType = .file
|
||||||
} label: {
|
} label: {
|
||||||
Label("Share File", systemImage: "doc")
|
Label("Share File", systemImage: "doc")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
exportItemType = .json
|
||||||
|
} label: {
|
||||||
|
Label("Save Content JSON", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
shareItemType = .json
|
||||||
|
} label: {
|
||||||
|
Label("Share Content JSON File", systemImage: "curlybraces")
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
|
Button {
|
||||||
|
exportProfile(type: .file)
|
||||||
|
} label: {
|
||||||
|
Label("Save File", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
|
||||||
ShareButtonCompat($alert) {
|
ShareButtonCompat($alert) {
|
||||||
Label("Share File", systemImage: "doc")
|
Label("Share File", systemImage: "doc")
|
||||||
} itemURL: {
|
} itemURL: {
|
||||||
try profile.origin.toContent().generateShareFile()
|
try profile.origin.toContent().generateShareFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
exportProfile(type: .json)
|
||||||
|
} label: {
|
||||||
|
Label("Save Content JSON", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareButtonCompat($alert) {
|
||||||
|
Label("Share Content JSON File", systemImage: "curlybraces")
|
||||||
|
} itemURL: {
|
||||||
|
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if profile.type == .remote {
|
if profile.type == .remote {
|
||||||
@@ -856,24 +952,25 @@ private struct ProfilePickerRow: View {
|
|||||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
Button {
|
|
||||||
shareItemType = .json
|
|
||||||
} label: {
|
|
||||||
Label("Share Content JSON File", systemImage: "curlybraces")
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
ShareButtonCompat($alert) {
|
|
||||||
Label("Share Content JSON File", systemImage: "curlybraces")
|
|
||||||
} itemURL: {
|
|
||||||
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("Share", systemImage: "square.and.arrow.up")
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func exportProfile(type: ExportItemType) {
|
||||||
|
do {
|
||||||
|
switch type {
|
||||||
|
case .file:
|
||||||
|
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
||||||
|
showProfileExporter = true
|
||||||
|
case .json:
|
||||||
|
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
|
||||||
|
showJSONExporter = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private var profileInfo: some View {
|
private var profileInfo: some View {
|
||||||
@@ -921,6 +1018,21 @@ private struct ProfilePickerRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func exportProfileMacOS(type: ExportItemType) {
|
||||||
|
do {
|
||||||
|
switch type {
|
||||||
|
case .file:
|
||||||
|
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
||||||
|
showProfileExporter = true
|
||||||
|
case .json:
|
||||||
|
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
|
||||||
|
showJSONExporter = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func previewContent(profile: ProfilePreview, width: CGFloat) -> some View {
|
static func previewContent(profile: ProfilePreview, width: CGFloat) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "line.3.horizontal")
|
Image(systemName: "line.3.horizontal")
|
||||||
@@ -965,6 +1077,15 @@ private struct ProfilePickerRow: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Export Helpers
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
private enum ExportItemType {
|
||||||
|
case file
|
||||||
|
case json
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - macOS Helpers
|
// MARK: - macOS Helpers
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -1015,6 +1136,10 @@ 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 profileExportDocument: ProfileExportDocument?
|
||||||
|
@State private var showProfileExporter = false
|
||||||
|
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
|
||||||
|
@State private var showJSONExporter = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -1072,6 +1197,28 @@ private struct ProfilePickerRow: View {
|
|||||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fileExporter(
|
||||||
|
isPresented: $showProfileExporter,
|
||||||
|
document: profileExportDocument,
|
||||||
|
contentType: .profile,
|
||||||
|
defaultFilename: profileExportDocument?.filename
|
||||||
|
) { result in
|
||||||
|
profileExportDocument = nil
|
||||||
|
if case let .failure(error) = result {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fileExporter(
|
||||||
|
isPresented: $showJSONExporter,
|
||||||
|
document: profileJSONExportDocument,
|
||||||
|
contentType: .json,
|
||||||
|
defaultFilename: profileJSONExportDocument?.filename
|
||||||
|
) { result in
|
||||||
|
profileJSONExportDocument = nil
|
||||||
|
if case let .failure(error) = result {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var rowMenu: some View {
|
private var rowMenu: some View {
|
||||||
@@ -1115,12 +1262,30 @@ private struct ProfilePickerRow: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var shareMenu: some View {
|
private var shareMenu: some View {
|
||||||
Menu {
|
Menu {
|
||||||
|
Button {
|
||||||
|
exportProfile(type: .file)
|
||||||
|
} label: {
|
||||||
|
Label("Save File", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
|
||||||
ShareButtonCompat($alert) {
|
ShareButtonCompat($alert) {
|
||||||
Label("Share File", systemImage: "doc")
|
Label("Share File", systemImage: "doc")
|
||||||
} itemURL: {
|
} itemURL: {
|
||||||
try profile.origin.toContent().generateShareFile()
|
try profile.origin.toContent().generateShareFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
exportProfile(type: .json)
|
||||||
|
} label: {
|
||||||
|
Label("Save Content JSON", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareButtonCompat($alert) {
|
||||||
|
Label("Share Content JSON File", systemImage: "curlybraces")
|
||||||
|
} itemURL: {
|
||||||
|
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
||||||
|
}
|
||||||
|
|
||||||
if profile.type == .remote {
|
if profile.type == .remote {
|
||||||
Button {
|
Button {
|
||||||
showQRCode = true
|
showQRCode = true
|
||||||
@@ -1128,17 +1293,26 @@ private struct ProfilePickerRow: View {
|
|||||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ShareButtonCompat($alert) {
|
|
||||||
Label("Share Content JSON File", systemImage: "curlybraces")
|
|
||||||
} itemURL: {
|
|
||||||
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("Share", systemImage: "square.and.arrow.up")
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func exportProfile(type: ExportItemType) {
|
||||||
|
do {
|
||||||
|
switch type {
|
||||||
|
case .file:
|
||||||
|
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
|
||||||
|
showProfileExporter = true
|
||||||
|
case .json:
|
||||||
|
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
|
||||||
|
showJSONExporter = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var profileInfo: some View {
|
private var profileInfo: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import Foundation
|
||||||
|
import QRCode
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private extension CGColor {
|
||||||
|
static var labelColor: CGColor {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
UIColor.label.cgColor
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
NSColor.labelColor.cgColor
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct QRSDisplayView: View {
|
||||||
|
private let data: Data
|
||||||
|
|
||||||
|
@State private var encoder: LubyTransformEncoder?
|
||||||
|
@State private var currentBlock: EncodedBlock?
|
||||||
|
@State private var frameCount = 0
|
||||||
|
@State private var isPlaying = true
|
||||||
|
@State private var fps: Double = 10
|
||||||
|
@State private var sliceSize: Int = 500
|
||||||
|
@State private var timer: Timer?
|
||||||
|
|
||||||
|
public init(data: Data) {
|
||||||
|
self.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if let block = currentBlock {
|
||||||
|
QRCodeViewUI(
|
||||||
|
content: block.toBase64(),
|
||||||
|
errorCorrection: .low,
|
||||||
|
foregroundColor: .labelColor,
|
||||||
|
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
|
||||||
|
)
|
||||||
|
.aspectRatio(1, contentMode: .fit)
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(minWidth: 280, minHeight: 280)
|
||||||
|
#else
|
||||||
|
.frame(maxWidth: 300, maxHeight: 300)
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: 280, height: 280)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Frame: \(frameCount)")
|
||||||
|
.font(.caption)
|
||||||
|
if let encoder {
|
||||||
|
Text("Source blocks: \(encoder.k)")
|
||||||
|
.font(.caption)
|
||||||
|
Text("Data size: \(encoder.bytes) bytes")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
isPlaying.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(String(localized: "Ideal FPS"))
|
||||||
|
.font(.caption)
|
||||||
|
|
||||||
|
Slider(value: $fps, in: 1 ... 30, step: 1)
|
||||||
|
.frame(maxWidth: 120)
|
||||||
|
|
||||||
|
Text("\(Int(fps))")
|
||||||
|
.font(.caption)
|
||||||
|
.frame(width: 24, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "Slice Size"))
|
||||||
|
.font(.caption)
|
||||||
|
|
||||||
|
Picker("", selection: $sliceSize) {
|
||||||
|
Text("200").tag(200)
|
||||||
|
Text("500").tag(500)
|
||||||
|
Text("1000").tag(1000)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.onAppear {
|
||||||
|
setupEncoder()
|
||||||
|
startAnimation()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopAnimation()
|
||||||
|
}
|
||||||
|
.onChange(of: fps) { _ in
|
||||||
|
if isPlaying {
|
||||||
|
restartTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: isPlaying) { playing in
|
||||||
|
if playing {
|
||||||
|
startAnimation()
|
||||||
|
} else {
|
||||||
|
stopAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: sliceSize) { _ in
|
||||||
|
setupEncoder()
|
||||||
|
frameCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupEncoder() {
|
||||||
|
encoder = LubyTransformEncoder(data: data, sliceSize: sliceSize, compress: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAnimation() {
|
||||||
|
nextFrame()
|
||||||
|
restartTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / fps, repeats: true) { _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
nextFrame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAnimation() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nextFrame() {
|
||||||
|
guard let encoder else { return }
|
||||||
|
currentBlock = encoder.fountain().next()
|
||||||
|
frameCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct QRSSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
private let profileName: String
|
||||||
|
private let profileData: Data
|
||||||
|
|
||||||
|
public init(profileName: String, profileData: Data) {
|
||||||
|
self.profileName = profileName
|
||||||
|
self.profileData = profileData
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
NavigationSheet(title: String(localized: "Share as QRS")) {
|
||||||
|
VStack {
|
||||||
|
QRSDisplayView(data: profileData)
|
||||||
|
Text("Ask the receiver to scan continuously until complete.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 400, minHeight: 520)
|
||||||
|
#elseif os(iOS) || os(tvOS)
|
||||||
|
NavigationSheet(title: String(localized: "Share as QRS"), size: .large) {
|
||||||
|
VStack {
|
||||||
|
QRSDisplayView(data: profileData)
|
||||||
|
Text("Ask the receiver to scan continuously until complete.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#if os(iOS)
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class QRSScannerController: NSObject, ObservableObject {
|
||||||
|
private var captureSession: AVCaptureSession?
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
private var metadataOutput: AVCaptureMetadataOutput?
|
||||||
|
|
||||||
|
@Published var decoder = LubyTransformDecoder()
|
||||||
|
@Published var lastError: String?
|
||||||
|
@Published var isComplete = false
|
||||||
|
@Published var progress: Double = 0
|
||||||
|
@Published var framesScanned = 0
|
||||||
|
@Published var availableCameras: [AVCaptureDevice] = []
|
||||||
|
@Published var selectedCamera: AVCaptureDevice?
|
||||||
|
|
||||||
|
private var seenBlockHashes = Set<Data>()
|
||||||
|
|
||||||
|
let previewView = UIView()
|
||||||
|
var onComplete: ((Data) -> Void)?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
previewView.backgroundColor = .black
|
||||||
|
refreshCameraList()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshCameraList() {
|
||||||
|
var deviceTypes: [AVCaptureDevice.DeviceType] = [
|
||||||
|
.builtInWideAngleCamera,
|
||||||
|
.builtInUltraWideCamera,
|
||||||
|
.builtInTelephotoCamera,
|
||||||
|
]
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
deviceTypes.append(.external)
|
||||||
|
}
|
||||||
|
let discoverySession = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: deviceTypes,
|
||||||
|
mediaType: .video,
|
||||||
|
position: .unspecified
|
||||||
|
)
|
||||||
|
availableCameras = discoverySession.devices
|
||||||
|
if selectedCamera == nil {
|
||||||
|
selectedCamera = availableCameras.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectCamera(_ camera: AVCaptureDevice) {
|
||||||
|
guard camera.uniqueID != selectedCamera?.uniqueID else { return }
|
||||||
|
selectedCamera = camera
|
||||||
|
|
||||||
|
if captureSession != nil {
|
||||||
|
stopScanning()
|
||||||
|
captureSession = nil
|
||||||
|
previewLayer?.removeFromSuperlayer()
|
||||||
|
previewLayer = nil
|
||||||
|
startScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
decoder = LubyTransformDecoder()
|
||||||
|
seenBlockHashes.removeAll()
|
||||||
|
lastError = nil
|
||||||
|
isComplete = false
|
||||||
|
progress = 0
|
||||||
|
framesScanned = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func startScanning() {
|
||||||
|
guard captureSession == nil else {
|
||||||
|
if captureSession?.isRunning == false {
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .authorized:
|
||||||
|
setupCaptureSession()
|
||||||
|
case .notDetermined:
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if granted {
|
||||||
|
self?.setupCaptureSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopScanning() {
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupCaptureSession() {
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
|
||||||
|
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoInput: AVCaptureDeviceInput
|
||||||
|
do {
|
||||||
|
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard session.canAddInput(videoInput) else { return }
|
||||||
|
session.addInput(videoInput)
|
||||||
|
|
||||||
|
let metadataOutput = AVCaptureMetadataOutput()
|
||||||
|
guard session.canAddOutput(metadataOutput) else { return }
|
||||||
|
session.addOutput(metadataOutput)
|
||||||
|
metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
|
||||||
|
metadataOutput.metadataObjectTypes = [.qr]
|
||||||
|
|
||||||
|
self.metadataOutput = metadataOutput
|
||||||
|
captureSession = session
|
||||||
|
|
||||||
|
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
previewLayer.videoGravity = .resizeAspectFill
|
||||||
|
previewLayer.frame = previewView.bounds
|
||||||
|
previewView.layer.addSublayer(previewLayer)
|
||||||
|
self.previewLayer = previewLayer
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePreviewFrame(_ frame: CGRect) {
|
||||||
|
previewLayer?.frame = frame
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processQRContent(_ content: String) {
|
||||||
|
guard !isComplete else { return }
|
||||||
|
|
||||||
|
guard let block = EncodedBlock.fromBase64(content) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = block.toBinary()
|
||||||
|
guard !seenBlockHashes.contains(hash) else { return }
|
||||||
|
seenBlockHashes.insert(hash)
|
||||||
|
|
||||||
|
framesScanned += 1
|
||||||
|
|
||||||
|
do {
|
||||||
|
let complete = try decoder.addBlock(block)
|
||||||
|
progress = decoder.progress
|
||||||
|
|
||||||
|
if complete {
|
||||||
|
isComplete = true
|
||||||
|
stopScanning()
|
||||||
|
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
||||||
|
|
||||||
|
if let data = try? decoder.getDecoded() {
|
||||||
|
onComplete?(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension QRSScannerController: AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
nonisolated func metadataOutput(
|
||||||
|
_ output: AVCaptureMetadataOutput,
|
||||||
|
didOutput metadataObjects: [AVMetadataObject],
|
||||||
|
from connection: AVCaptureConnection
|
||||||
|
) {
|
||||||
|
Task { @MainActor in
|
||||||
|
for metadataObject in metadataObjects {
|
||||||
|
guard let readable = metadataObject as? AVMetadataMachineReadableCodeObject,
|
||||||
|
let content = readable.stringValue
|
||||||
|
else { continue }
|
||||||
|
processQRContent(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QRSScannerControllerView: UIViewControllerRepresentable {
|
||||||
|
let controller: QRSScannerController
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIViewController {
|
||||||
|
let viewController = QRSScannerViewController(controller: controller)
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class QRSScannerViewController: UIViewController {
|
||||||
|
let controller: QRSScannerController
|
||||||
|
|
||||||
|
init(controller: QRSScannerController) {
|
||||||
|
self.controller = controller
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .black
|
||||||
|
view.addSubview(controller.previewView)
|
||||||
|
controller.previewView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
controller.previewView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
controller.previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
controller.previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
controller.previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
controller.updatePreviewFrame(view.bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
controller.startScanning()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
controller.stopScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
import Vision
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class QRSScannerController: NSObject, ObservableObject {
|
||||||
|
private var captureSession: AVCaptureSession?
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
private var videoOutput: AVCaptureVideoDataOutput?
|
||||||
|
|
||||||
|
@Published var decoder = LubyTransformDecoder()
|
||||||
|
@Published var lastError: String?
|
||||||
|
@Published var isComplete = false
|
||||||
|
@Published var progress: Double = 0
|
||||||
|
@Published var framesScanned = 0
|
||||||
|
@Published var availableCameras: [AVCaptureDevice] = []
|
||||||
|
@Published var selectedCamera: AVCaptureDevice?
|
||||||
|
|
||||||
|
private var seenBlockHashes = Set<Data>()
|
||||||
|
|
||||||
|
let previewView = NSView()
|
||||||
|
var onComplete: ((Data) -> Void)?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
previewView.wantsLayer = true
|
||||||
|
previewView.layer?.backgroundColor = NSColor.black.cgColor
|
||||||
|
refreshCameraList()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshCameraList() {
|
||||||
|
let discoverySession = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: [.builtInWideAngleCamera, .externalUnknown],
|
||||||
|
mediaType: .video,
|
||||||
|
position: .unspecified
|
||||||
|
)
|
||||||
|
availableCameras = discoverySession.devices
|
||||||
|
if selectedCamera == nil {
|
||||||
|
selectedCamera = availableCameras.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectCamera(_ camera: AVCaptureDevice) {
|
||||||
|
guard camera.uniqueID != selectedCamera?.uniqueID else { return }
|
||||||
|
selectedCamera = camera
|
||||||
|
|
||||||
|
if captureSession != nil {
|
||||||
|
stopScanning()
|
||||||
|
captureSession = nil
|
||||||
|
previewLayer?.removeFromSuperlayer()
|
||||||
|
previewLayer = nil
|
||||||
|
startScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
decoder = LubyTransformDecoder()
|
||||||
|
seenBlockHashes.removeAll()
|
||||||
|
lastError = nil
|
||||||
|
isComplete = false
|
||||||
|
progress = 0
|
||||||
|
framesScanned = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func startScanning() {
|
||||||
|
guard captureSession == nil else {
|
||||||
|
if captureSession?.isRunning == false {
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .authorized:
|
||||||
|
setupCaptureSession()
|
||||||
|
case .notDetermined:
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if granted {
|
||||||
|
self?.setupCaptureSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopScanning() {
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupCaptureSession() {
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
|
||||||
|
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoInput: AVCaptureDeviceInput
|
||||||
|
do {
|
||||||
|
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard session.canAddInput(videoInput) else { return }
|
||||||
|
session.addInput(videoInput)
|
||||||
|
|
||||||
|
let videoOutput = AVCaptureVideoDataOutput()
|
||||||
|
videoOutput.videoSettings = [
|
||||||
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||||
|
]
|
||||||
|
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "QRSScannerQueue"))
|
||||||
|
|
||||||
|
guard session.canAddOutput(videoOutput) else { return }
|
||||||
|
session.addOutput(videoOutput)
|
||||||
|
|
||||||
|
self.videoOutput = videoOutput
|
||||||
|
captureSession = session
|
||||||
|
|
||||||
|
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
previewLayer.videoGravity = .resizeAspectFill
|
||||||
|
previewLayer.frame = previewView.bounds
|
||||||
|
previewView.layer?.addSublayer(previewLayer)
|
||||||
|
self.previewLayer = previewLayer
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePreviewFrame(_ frame: CGRect) {
|
||||||
|
previewLayer?.frame = frame
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processQRContent(_ content: String) {
|
||||||
|
guard !isComplete else { return }
|
||||||
|
|
||||||
|
guard let block = EncodedBlock.fromBase64(content) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = block.toBinary()
|
||||||
|
guard !seenBlockHashes.contains(hash) else { return }
|
||||||
|
seenBlockHashes.insert(hash)
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
framesScanned += 1
|
||||||
|
|
||||||
|
do {
|
||||||
|
let complete = try decoder.addBlock(block)
|
||||||
|
progress = decoder.progress
|
||||||
|
|
||||||
|
if complete {
|
||||||
|
isComplete = true
|
||||||
|
stopScanning()
|
||||||
|
NSSound.beep()
|
||||||
|
|
||||||
|
if let data = try? decoder.getDecoded() {
|
||||||
|
onComplete?(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension QRSScannerController: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
|
nonisolated func captureOutput(
|
||||||
|
_ output: AVCaptureOutput,
|
||||||
|
didOutput sampleBuffer: CMSampleBuffer,
|
||||||
|
from connection: AVCaptureConnection
|
||||||
|
) {
|
||||||
|
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||||
|
|
||||||
|
let request = VNDetectBarcodesRequest { [weak self] request, _ in
|
||||||
|
guard let results = request.results as? [VNBarcodeObservation] else { return }
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
if result.symbology == .qr, let payload = result.payloadStringValue {
|
||||||
|
self?.processQRContent(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.symbologies = [.qr]
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
|
||||||
|
try? handler.perform([request])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QRSScannerControllerView: NSViewControllerRepresentable {
|
||||||
|
let controller: QRSScannerController
|
||||||
|
|
||||||
|
func makeNSViewController(context: Context) -> NSViewController {
|
||||||
|
let viewController = QRSScannerViewController(controller: controller)
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSViewController(_ nsViewController: NSViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class QRSScannerViewController: NSViewController {
|
||||||
|
let controller: QRSScannerController
|
||||||
|
|
||||||
|
init(controller: QRSScannerController) {
|
||||||
|
self.controller = controller
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView()
|
||||||
|
view.wantsLayer = true
|
||||||
|
view.layer?.backgroundColor = NSColor.black.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.addSubview(controller.previewView)
|
||||||
|
controller.previewView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
controller.previewView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
controller.previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
controller.previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
controller.previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayout() {
|
||||||
|
super.viewDidLayout()
|
||||||
|
controller.updatePreviewFrame(view.bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear() {
|
||||||
|
super.viewWillAppear()
|
||||||
|
controller.startScanning()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear() {
|
||||||
|
super.viewWillDisappear()
|
||||||
|
controller.stopScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#if !os(tvOS)
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct QRSScannerView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var alert: AlertState?
|
||||||
|
@StateObject private var controller = QRSScannerController()
|
||||||
|
|
||||||
|
private let onComplete: (Data) -> Void
|
||||||
|
|
||||||
|
public init(onComplete: @escaping (Data) -> Void) {
|
||||||
|
self.onComplete = onComplete
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
#if os(iOS)
|
||||||
|
iOSBody
|
||||||
|
#elseif os(macOS)
|
||||||
|
macOSBody
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private var iOSBody: some View {
|
||||||
|
NavigationStackCompat {
|
||||||
|
ZStack {
|
||||||
|
QRSScannerControllerView(controller: controller)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
progressOverlay
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Scan QRS")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
controller.reset()
|
||||||
|
controller.startScanning()
|
||||||
|
} label: {
|
||||||
|
Label("Reset", systemImage: "arrow.counterclockwise")
|
||||||
|
}
|
||||||
|
|
||||||
|
if controller.availableCameras.count > 1 {
|
||||||
|
Menu("Camera") {
|
||||||
|
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
|
||||||
|
Button {
|
||||||
|
controller.selectCamera(camera)
|
||||||
|
} label: {
|
||||||
|
if camera.uniqueID == controller.selectedCamera?.uniqueID {
|
||||||
|
Label(camera.localizedName, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(camera.localizedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert($alert)
|
||||||
|
.onAppear {
|
||||||
|
controller.onComplete = { data in
|
||||||
|
dismiss()
|
||||||
|
onComplete(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private var macOSBody: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZStack {
|
||||||
|
QRSScannerControllerView(controller: controller)
|
||||||
|
.frame(minWidth: 400, minHeight: 300)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
progressOverlay
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if controller.availableCameras.count > 1 {
|
||||||
|
Picker("Camera", selection: Binding(
|
||||||
|
get: { controller.selectedCamera },
|
||||||
|
set: { camera in
|
||||||
|
if let camera {
|
||||||
|
controller.selectCamera(camera)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
|
||||||
|
Text(camera.localizedName).tag(camera as AVCaptureDevice?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Reset") {
|
||||||
|
controller.reset()
|
||||||
|
controller.startScanning()
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.alert($alert)
|
||||||
|
.onAppear {
|
||||||
|
controller.onComplete = { data in
|
||||||
|
dismiss()
|
||||||
|
onComplete(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private var progressOverlay: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ProgressView(value: controller.progress)
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
.frame(maxWidth: 200)
|
||||||
|
|
||||||
|
Text("Decoded: \(Int(controller.progress * 100))%")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if controller.decoder.k > 0 {
|
||||||
|
Text("\(controller.decoder.decodedCount)/\(controller.decoder.k) blocks")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Frames scanned: \(controller.framesScanned)")
|
||||||
|
.font(.caption)
|
||||||
|
|
||||||
|
if let error = controller.lastError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -109,3 +109,61 @@ public struct TypedProfile: Transferable, Codable {
|
|||||||
public extension UTType {
|
public extension UTType {
|
||||||
static var profile: UTType { .init(exportedAs: "io.nekohasekai.sfavt.profile") }
|
static var profile: UTType { .init(exportedAs: "io.nekohasekai.sfavt.profile") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - FileDocument for Export
|
||||||
|
|
||||||
|
public struct ProfileExportDocument: FileDocument {
|
||||||
|
public static var readableContentTypes: [UTType] { [.profile] }
|
||||||
|
|
||||||
|
public let data: Data
|
||||||
|
public let filename: String
|
||||||
|
|
||||||
|
public init(content: LibboxProfileContent) throws {
|
||||||
|
guard let encoded = content.encode() else {
|
||||||
|
throw NSError(domain: "ProfileExportDocument", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode profile"])
|
||||||
|
}
|
||||||
|
data = encoded
|
||||||
|
filename = "\(content.name).bpf"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(configuration: ReadConfiguration) throws {
|
||||||
|
guard let data = configuration.file.regularFileContents else {
|
||||||
|
throw CocoaError(.fileReadCorruptFile)
|
||||||
|
}
|
||||||
|
self.data = data
|
||||||
|
filename = "profile.bpf"
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper {
|
||||||
|
FileWrapper(regularFileWithContents: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ProfileJSONExportDocument: FileDocument {
|
||||||
|
public static var readableContentTypes: [UTType] { [.json] }
|
||||||
|
|
||||||
|
public let content: String
|
||||||
|
public let filename: String
|
||||||
|
|
||||||
|
public init(jsonContent: String, name: String) {
|
||||||
|
content = jsonContent
|
||||||
|
filename = "\(name).json"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(configuration: ReadConfiguration) throws {
|
||||||
|
guard let data = configuration.file.regularFileContents,
|
||||||
|
let content = String(data: data, encoding: .utf8)
|
||||||
|
else {
|
||||||
|
throw CocoaError(.fileReadCorruptFile)
|
||||||
|
}
|
||||||
|
self.content = content
|
||||||
|
filename = "profile.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper {
|
||||||
|
guard let data = content.data(using: .utf8) else {
|
||||||
|
throw CocoaError(.fileWriteInapplicableStringEncoding)
|
||||||
|
}
|
||||||
|
return FileWrapper(regularFileWithContents: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user