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
|
||||
|
||||
@@ -2596,6 +2596,37 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// Automatic Backup
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Automatic Backup")
|
||||
formSection {
|
||||
row("Frequency") {
|
||||
Picker("", selection: $settingsService.autoBackupFrequency) {
|
||||
Text("Off").tag("manual")
|
||||
Text("Daily").tag("daily")
|
||||
Text("Weekly").tag("weekly")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 220)
|
||||
}
|
||||
}
|
||||
Text("When enabled, oAI backs up automatically in the background (checked at launch and hourly while running) — no need to press \"Back Up Now\" yourself.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
// Favorites Sync
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Favorite Models")
|
||||
Text("Starred models sync automatically via the same iCloud Drive folder — star a model on one Mac and it appears starred on your others within a launch or two.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
// Actions
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Actions")
|
||||
|
||||
@@ -43,6 +43,11 @@ struct oAIApp: App {
|
||||
await GitSyncService.shared.syncOnStartup()
|
||||
}
|
||||
|
||||
// Reconcile starred models with the iCloud copy (cross-machine favorites sync)
|
||||
Task {
|
||||
await BackupService.shared.syncFavoritesOnLaunch()
|
||||
}
|
||||
|
||||
// Check for updates in the background
|
||||
UpdateCheckService.shared.checkForUpdates()
|
||||
}
|
||||
@@ -56,6 +61,11 @@ struct oAIApp: App {
|
||||
AboutView()
|
||||
}
|
||||
#if os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
Task {
|
||||
await BackupService.shared.syncFavoritesOnLaunch()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||
Task {
|
||||
|
||||
Reference in New Issue
Block a user