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:
2026-07-21 13:19:50 +02:00
parent 86027001c7
commit fc786d48f8
4 changed files with 149 additions and 0 deletions
+85
View File
@@ -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 {