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:
世界
2026-01-01 20:25:28 +08:00
parent 55e01a89b5
commit 313cb5d213
11 changed files with 1674 additions and 26 deletions
@@ -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: &degree, 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)
}
}