From fc786d48f8ab33c37dcd16ec92d8cd0efacc3390 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 21 Jul 2026 13:19:50 +0200 Subject: [PATCH] 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. --- oAI/Services/BackupService.swift | 85 ++++++++++++++++++++++++++++ oAI/Services/SettingsService.swift | 23 ++++++++ oAI/Views/Screens/SettingsView.swift | 31 ++++++++++ oAI/oAIApp.swift | 10 ++++ 4 files changed, 149 insertions(+) diff --git a/oAI/Services/BackupService.swift b/oAI/Services/BackupService.swift index 36b52ff..522a862 100644 --- a/oAI/Services/BackupService.swift +++ b/oAI/Services/BackupService.swift @@ -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 = [ "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 { diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 8c3060b..eadcbe8 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -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 diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 5911fee..6852497 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -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") diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index 064b093..132864d 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -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 {