Sign the macOS app and keep its state in SwiftData with iCloud sync
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user