Sync starred models across machines; add automatic backup scheduling
Favorites now push/pull through a small oai_favorites.json file in the same iCloud Drive folder used by Settings > Backup, reconciled by last-write-wins timestamp on launch and app-become-active. Also adds an Off/Daily/Weekly frequency picker so the full settings backup can run itself (checked at launch and hourly) instead of requiring a manual "Back Up Now" click every time.
This commit is contained in:
@@ -34,6 +34,15 @@ struct BackupManifest: Codable {
|
||||
let credentials: [String: String]?
|
||||
}
|
||||
|
||||
// MARK: - FavoritesPayload
|
||||
|
||||
/// Small standalone file (separate from the full settings backup) so starring a model
|
||||
/// syncs near-instantly across machines instead of waiting for the next full backup.
|
||||
struct FavoritesPayload: Codable {
|
||||
let updatedAt: String
|
||||
let ids: [String]
|
||||
}
|
||||
|
||||
// MARK: - BackupService
|
||||
|
||||
@Observable
|
||||
@@ -51,6 +60,8 @@ final class BackupService {
|
||||
/// URL of the last backup file
|
||||
var lastBackupURL: URL?
|
||||
|
||||
private var autoBackupTimer: Timer?
|
||||
|
||||
// Keys excluded from backup — encrypted_ prefix + internal migration flags
|
||||
private static let excludedKeys: Set<String> = [
|
||||
"encrypted_openrouterAPIKey",
|
||||
@@ -71,6 +82,7 @@ final class BackupService {
|
||||
|
||||
private init() {
|
||||
checkForExistingBackup()
|
||||
startAutoBackupTimer()
|
||||
}
|
||||
|
||||
// MARK: - iCloud Path Resolution
|
||||
@@ -176,6 +188,79 @@ final class BackupService {
|
||||
log.info("Restored \(manifest.settings.count) settings from backup (v\(manifest.version))")
|
||||
}
|
||||
|
||||
// MARK: - Favorite Models Sync
|
||||
|
||||
private func favoritesFileURL() -> URL {
|
||||
resolveBackupDirectory().appendingPathComponent("oai_favorites.json")
|
||||
}
|
||||
|
||||
/// Write the current local favorites to iCloud Drive. Called whenever a favorite is toggled.
|
||||
func pushFavorites() async {
|
||||
let settings = SettingsService.shared
|
||||
let payload = FavoritesPayload(
|
||||
updatedAt: settings.favoriteModelsUpdatedAt,
|
||||
ids: settings.favoriteModelIds.sorted()
|
||||
)
|
||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
||||
try? data.write(to: favoritesFileURL(), options: .atomic)
|
||||
log.debug("Pushed \(payload.ids.count) favorite model(s) to iCloud")
|
||||
}
|
||||
|
||||
/// Reconcile local favorites with the iCloud copy using last-write-wins (by timestamp).
|
||||
/// Call on launch (and optionally on app-become-active) to pick up changes from other machines.
|
||||
func syncFavoritesOnLaunch() async {
|
||||
let settings = SettingsService.shared
|
||||
let fileURL = favoritesFileURL()
|
||||
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let remote = try? JSONDecoder().decode(FavoritesPayload.self, from: data) else {
|
||||
// No remote copy yet — push local state (may be empty, that's fine).
|
||||
await pushFavorites()
|
||||
return
|
||||
}
|
||||
|
||||
let localUpdatedAt = settings.favoriteModelsUpdatedAt
|
||||
if remote.updatedAt > localUpdatedAt {
|
||||
settings.favoriteModelIds = Set(remote.ids)
|
||||
settings.favoriteModelsUpdatedAt = remote.updatedAt
|
||||
log.info("Applied \(remote.ids.count) favorite model(s) from iCloud (remote was newer)")
|
||||
} else if localUpdatedAt > remote.updatedAt {
|
||||
await pushFavorites()
|
||||
}
|
||||
// Equal timestamps: already in sync, nothing to do.
|
||||
}
|
||||
|
||||
// MARK: - Automatic Backup
|
||||
|
||||
private static let dailyInterval: TimeInterval = 24 * 3600
|
||||
private static let weeklyInterval: TimeInterval = 7 * 24 * 3600
|
||||
|
||||
/// Checked once at launch and hourly thereafter while the app is running.
|
||||
private func startAutoBackupTimer() {
|
||||
Task { await checkAndPerformAutoBackupIfDue() }
|
||||
autoBackupTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in
|
||||
Task { await self?.checkAndPerformAutoBackupIfDue() }
|
||||
}
|
||||
}
|
||||
|
||||
func checkAndPerformAutoBackupIfDue() async {
|
||||
let frequency = SettingsService.shared.autoBackupFrequency
|
||||
let interval: TimeInterval
|
||||
switch frequency {
|
||||
case "daily": interval = Self.dailyInterval
|
||||
case "weekly": interval = Self.weeklyInterval
|
||||
default: return // "manual" — user triggers backups by hand
|
||||
}
|
||||
|
||||
checkForExistingBackup()
|
||||
if let last = lastBackupDate, Date().timeIntervalSince(last) < interval {
|
||||
return // not due yet
|
||||
}
|
||||
|
||||
log.info("Automatic backup (\(frequency, privacy: .public)) is due — backing up now")
|
||||
_ = try? await exportSettings()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func appVersion() -> String {
|
||||
|
||||
@@ -520,10 +520,33 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO8601 timestamp of the last local change to favoriteModelIds — used to
|
||||
/// resolve last-write-wins conflicts when syncing favorites across machines.
|
||||
var favoriteModelsUpdatedAt: String {
|
||||
get { cache["favoriteModelsUpdatedAt"] ?? "" }
|
||||
set {
|
||||
cache["favoriteModelsUpdatedAt"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "favoriteModelsUpdatedAt", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
func toggleFavoriteModel(_ id: String) {
|
||||
var favs = favoriteModelIds
|
||||
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
|
||||
favoriteModelIds = favs
|
||||
favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date())
|
||||
Task { await BackupService.shared.pushFavorites() }
|
||||
}
|
||||
|
||||
// MARK: - Automatic Backup
|
||||
|
||||
/// "manual" (default), "daily", or "weekly"
|
||||
var autoBackupFrequency: String {
|
||||
get { cache["autoBackupFrequency"] ?? "manual" }
|
||||
set {
|
||||
cache["autoBackupFrequency"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "autoBackupFrequency", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Anytype MCP Settings
|
||||
|
||||
Reference in New Issue
Block a user