Sign the macOS app and keep its state in SwiftData with iCloud sync

This commit is contained in:
Ian Gulliver
2026-10-17 10:13:26 -07:00
parent d21215b442
commit 1808ca8be6
11 changed files with 195 additions and 36 deletions
+46 -13
View File
@@ -1,5 +1,6 @@
import Foundation
import Observation
import SwiftData
private struct Saved: Codable {
struct Composer: Codable {
@@ -39,30 +40,43 @@ final class Drafts {
private var lostImages: [String: Int] = [:]
private var checked: [String: [String]] = [:]
private var texts: [String: String] = [:]
@ObservationIgnored private let storeKey: String
@ObservationIgnored private let scope: String
@ObservationIgnored private var timer: Task<Void, Never>?
@ObservationIgnored private var dirty = false
init(origin: URL, login: String) {
storeKey = "byre.drafts.\(draftOrigin(origin)).\(login)"
let saved = Self.read(storeKey)
for (key, d) in saved.composer {
if !d.text.isEmpty {
composerTexts[key] = d.text
}
if d.images > 0 {
lostImages[key] = d.images
scope = "\(draftOrigin(origin)).\(login)"
load(Self.read(scope))
}
private func load(_ saved: Saved) {
composerTexts = saved.composer.filter { !$0.value.text.isEmpty }.mapValues(\.text)
lostImages = saved.composer.reduce(into: [:]) { lost, d in
let n = d.value.images - (composerImages[d.key] ?? 0)
if n > 0 {
lost[d.key] = n
}
}
checked = saved.checked
texts = saved.texts
}
private static func read(_ key: String) -> Saved {
guard let raw = UserDefaults.standard.string(forKey: key) else {
func reload() {
if !dirty && sending.isEmpty {
load(Self.read(scope))
}
}
private static func records(_ scope: String) -> [DraftRecord] {
fetchRecords(FetchDescriptor(predicate: #Predicate { $0.scope == scope }, sortBy: [SortDescriptor(\.saved, order: .reverse)]))
}
private static func read(_ scope: String) -> Saved {
guard let record = records(scope).first else {
return Saved()
}
do {
return try JSONDecoder().decode(Saved.self, from: Data(raw.utf8))
return try JSONDecoder().decode(Saved.self, from: record.json)
} catch {
log.error("read drafts: \(String(describing: error), privacy: .public)")
return Saved()
@@ -91,6 +105,7 @@ final class Drafts {
}
private func save() {
dirty = true
timer?.cancel()
timer = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(400))
@@ -103,6 +118,10 @@ final class Drafts {
func flush() {
timer?.cancel()
timer = nil
guard dirty else {
return
}
dirty = false
var out = Saved(checked: checked, texts: texts)
let keys = Set(composerTexts.keys).union(composerImages.keys).union(sending.map(\.key)).union(lostImages.keys)
for key in keys {
@@ -114,7 +133,18 @@ final class Drafts {
}
}
do {
UserDefaults.standard.set(String(decoding: try JSONEncoder().encode(out), as: UTF8.self), forKey: storeKey)
let json = try JSONEncoder().encode(out)
let records = Self.records(scope)
if records.isEmpty {
persistence.insert(DraftRecord(scope: scope, json: json))
} else {
records[0].json = json
records[0].saved = Date()
for record in records.dropFirst() {
persistence.delete(record)
}
}
saveRecords()
} catch {
log.error("save drafts: \(String(describing: error), privacy: .public)")
}
@@ -153,6 +183,7 @@ final class Drafts {
let send = DraftSend(key: key, text: text, images: images)
sending.append(send)
composerTexts[key] = nil
dirty = true
return send
}
@@ -166,6 +197,7 @@ final class Drafts {
composerTexts[send.key] = text
}
}
dirty = true
flush()
}
@@ -199,6 +231,7 @@ final class Drafts {
func clearAnswer(_ id: String) {
checked[id] = nil
texts[id] = nil
dirty = true
flush()
}
}