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()
}
}
+70
View File
@@ -0,0 +1,70 @@
import Foundation
import SwiftData
@Model
final class ServerRecord {
var origin = ""
var saved = Date.distantPast
init(origin: String) {
self.origin = origin
saved = Date()
}
}
@Model
final class DraftRecord {
var scope = ""
var json = Data()
var saved = Date.distantPast
init(scope: String, json: Data) {
self.scope = scope
self.json = json
saved = Date()
}
}
@Model
final class VisitRecord {
var taskId = ""
var at = 0.0
init(taskId: String, at: Double) {
self.taskId = taskId
self.at = at
}
}
private let container: ModelContainer = {
let schema = Schema([ServerRecord.self, DraftRecord.self, VisitRecord.self])
do {
return try ModelContainer(for: schema, configurations: ModelConfiguration(schema: schema, cloudKitDatabase: .private("iCloud.run.fc.byre.mac")))
} catch {
fatalError("open persistent store: \(error)")
}
}()
@MainActor
var persistence: ModelContext {
container.mainContext
}
@MainActor
func fetchRecords<T: PersistentModel>(_ descriptor: FetchDescriptor<T>) -> [T] {
do {
return try persistence.fetch(descriptor)
} catch {
log.error("fetch \(String(describing: T.self), privacy: .public): \(String(describing: error), privacy: .public)")
return []
}
}
@MainActor
func saveRecords() {
do {
try persistence.save()
} catch {
log.error("save records: \(String(describing: error), privacy: .public)")
}
}
+30 -9
View File
@@ -1,9 +1,9 @@
import CoreData
import Foundation
import Observation
import SwiftData
import SwiftUI
private let originKey = "byre.server"
@MainActor
@Observable
final class Connection {
@@ -21,16 +21,33 @@ final class Connection {
@ObservationIgnored private var generation = 0
init() {
let saved = UserDefaults.standard.string(forKey: originKey)
serverText = saved ?? ""
if let saved, let origin = parseOrigin(saved) {
phase = .starting
Task {
await start(origin)
serverText = ""
resume()
Task {
for await note in NotificationCenter.default.notifications(named: NSPersistentCloudKitContainer.eventChangedNotification) {
if let event = note.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event, event.type == .import, event.endDate != nil {
store?.drafts?.reload()
resume()
}
}
}
}
private func resume() {
guard case .connect = phase, serverText.isEmpty, !connecting, let saved = Self.servers().first?.origin, let origin = parseOrigin(saved) else {
return
}
serverText = saved
phase = .starting
Task {
await start(origin)
}
}
private static func servers() -> [ServerRecord] {
fetchRecords(FetchDescriptor(sortBy: [SortDescriptor(\.saved, order: .reverse)]))
}
var store: Store? {
if case .running(let store) = phase {
return store
@@ -59,7 +76,11 @@ final class Connection {
return
}
connecting = false
UserDefaults.standard.set(origin.absoluteString, forKey: originKey)
for server in Self.servers() {
persistence.delete(server)
}
persistence.insert(ServerRecord(origin: origin.absoluteString))
saveRecords()
serverText = origin.absoluteString
phase = .starting
await start(origin)
+15 -5
View File
@@ -1,11 +1,11 @@
import Foundation
import Observation
import SwiftData
let taskTabs: [TaskTab] = [.transcript, .changes, .files, .terminal, .inbox, .reviews, .workflow, .project]
private let historyPage = 1000
private let leadPage = 50
private let visitsKey = "byre.visits"
struct TaskState {
enum Kind {
@@ -360,16 +360,26 @@ final class Store {
}
func lastVisits() -> [String: Double] {
UserDefaults.standard.dictionary(forKey: visitsKey) as? [String: Double] ?? [:]
fetchRecords(FetchDescriptor<VisitRecord>()).reduce(into: [:]) { visits, v in
visits[v.taskId] = max(visits[v.taskId] ?? 0, v.at)
}
}
private func markVisited(_ taskId: String?) {
guard let taskId else {
return
}
var visits = lastVisits()
visits[taskId] = Date().timeIntervalSince1970 * 1000
UserDefaults.standard.set(visits, forKey: visitsKey)
let at = Date().timeIntervalSince1970 * 1000
let records = fetchRecords(FetchDescriptor<VisitRecord>(predicate: #Predicate { $0.taskId == taskId }))
if records.isEmpty {
persistence.insert(VisitRecord(taskId: taskId, at: at))
} else {
records[0].at = at
for record in records.dropFirst() {
persistence.delete(record)
}
}
saveRecords()
}
func loadSummaryReads() async throws {
+2 -1
View File
@@ -23,5 +23,6 @@ cp "$build/release/Byre" "$app/Contents/MacOS/Byre"
cp "$mac/Info.plist" "$app/Contents/Info.plist"
cp "$root"/web/fonts/*.woff2 "$app/Contents/Resources/fonts/"
cp "$icns" "$app/Contents/Resources/byre.icns"
codesign --force --sign - "$app"
cp "$mac/byre.provisionprofile" "$app/Contents/embedded.provisionprofile"
codesign --force --sign "Apple Development" --entitlements "$mac/byre.entitlements" "$app"
echo "$app"
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.application-identifier</key>
<string>XDZ54KDM4W.run.fc.byre.mac</string>
<key>com.apple.developer.team-identifier</key>
<string>XDZ54KDM4W</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.run.fc.byre.mac</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
</dict>
</plist>
Binary file not shown.