// // BackupService.swift // oAI // // iCloud Drive backup of non-encrypted settings (Option C, v1) // // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is licensed under the PolyForm Noncommercial License 1.0.0. // You may use, study, modify, and share it for any noncommercial // purpose. Commercial use — including selling oAI or any part of // it, standalone or bundled into another product or service — // requires a separate commercial license from the copyright holder. // // See the LICENSE file or // for // the full license text. For commercial licensing, contact Rune // Olsen via . import Foundation import os // MARK: - BackupManifest struct BackupManifest: Codable { let version: Int let createdAt: String let appVersion: String let credentialsIncluded: Bool let settings: [String: String] 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 final class BackupService { static let shared = BackupService() private let log = Logger(subsystem: "oAI", category: "backup") /// Whether iCloud Drive is available on this machine var iCloudAvailable: Bool = false /// Date of the last backup file on disk (from file attributes) var lastBackupDate: Date? /// 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", "encrypted_anthropicAPIKey", "encrypted_openaiAPIKey", "encrypted_googleAPIKey", "encrypted_googleSearchEngineID", "encrypted_anytypeMcpAPIKey", "encrypted_paperlessAPIToken", "encrypted_syncUsername", "encrypted_syncPassword", "encrypted_syncAccessToken", "encrypted_emailUsername", "encrypted_emailPassword", "_migrated", "_keychain_migrated", ] private init() { checkForExistingBackup() startAutoBackupTimer() } // MARK: - iCloud Path Resolution private func resolveBackupDirectory() -> URL { let home = FileManager.default.homeDirectoryForCurrentUser let icloudRoot = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs") if FileManager.default.fileExists(atPath: icloudRoot.path) { let icloudOAI = icloudRoot.appendingPathComponent("oAI") try? FileManager.default.createDirectory(at: icloudOAI, withIntermediateDirectories: true) return icloudOAI } // Fallback: Downloads return FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory } func checkForExistingBackup() { let home = FileManager.default.homeDirectoryForCurrentUser let icloudRoot = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs") iCloudAvailable = FileManager.default.fileExists(atPath: icloudRoot.path) let dir = resolveBackupDirectory() let fileURL = dir.appendingPathComponent("oai_backup.json") if FileManager.default.fileExists(atPath: fileURL.path), let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path), let modified = attrs[.modificationDate] as? Date { lastBackupDate = modified lastBackupURL = fileURL } } // MARK: - Export /// Export all non-encrypted settings to iCloud Drive (or Downloads). /// Returns the URL where the file was written. @discardableResult func exportSettings() async throws -> URL { // Load raw settings from DB guard let allSettings = try? DatabaseService.shared.loadAllSettings() else { throw BackupError.databaseReadFailed } // Filter out excluded keys let filtered = allSettings.filter { !Self.excludedKeys.contains($0.key) } // Build manifest let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] let manifest = BackupManifest( version: 1, createdAt: formatter.string(from: Date()), appVersion: appVersion(), credentialsIncluded: false, settings: filtered, credentials: nil ) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(manifest) let dir = resolveBackupDirectory() let fileURL = dir.appendingPathComponent("oai_backup.json") try data.write(to: fileURL, options: .atomic) log.info("Backup written to \(fileURL.path, privacy: .public) (\(filtered.count) settings)") await MainActor.run { self.lastBackupDate = Date() self.lastBackupURL = fileURL } return fileURL } // MARK: - Import /// Restore settings from a backup JSON file. func importSettings(from url: URL) async throws { let data = try Data(contentsOf: url) let decoder = JSONDecoder() let manifest: BackupManifest do { manifest = try decoder.decode(BackupManifest.self, from: data) } catch { throw BackupError.invalidFormat(error.localizedDescription) } guard manifest.version == 1 else { throw BackupError.unsupportedVersion(manifest.version) } // Write each setting to the database for (key, value) in manifest.settings { DatabaseService.shared.setSetting(key: key, value: value) } // Refresh in-memory cache SettingsService.shared.reloadFromDatabase() 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") } /// Outcome of reconciling local favorites against the iCloud copy. Pure result type -- /// see `decideFavoritesSync` for the actual decision logic. enum FavoritesSyncDecision: Equatable { case applyRemote(ids: Set, updatedAt: String) case pushLocal case mergeAndPush(ids: Set, updatedAt: String) case noop } /// Last-write-wins reconciliation, pulled out of `syncFavoritesOnLaunch` so the branching /// logic (5 cases: remote absent / remote newer / local newer / tied-but-different / /// tied-and-identical) can be tested without touching iCloud Drive or SettingsService. static func decideFavoritesSync( localIds: Set, localUpdatedAt: String, remote: FavoritesPayload?, now: Date ) -> FavoritesSyncDecision { guard let remote else { return .pushLocal } if remote.updatedAt > localUpdatedAt { return .applyRemote(ids: Set(remote.ids), updatedAt: remote.updatedAt) } else if localUpdatedAt > remote.updatedAt { return .pushLocal } else if Set(remote.ids) != localIds { // Tied timestamps (both empty is the common case: two machines that already had // favorites before this sync feature existed, neither ever bumped the timestamp). // Union rather than silently dropping one side's favorites. let merged = localIds.union(remote.ids) return .mergeAndPush(ids: merged, updatedAt: ISO8601DateFormatter().string(from: now)) } return .noop } /// 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() let remote: FavoritesPayload? = { guard let data = try? Data(contentsOf: fileURL) else { return nil } return try? JSONDecoder().decode(FavoritesPayload.self, from: data) }() let localIds = settings.favoriteModelIds switch Self.decideFavoritesSync(localIds: localIds, localUpdatedAt: settings.favoriteModelsUpdatedAt, remote: remote, now: Date()) { case .pushLocal: await pushFavorites() case .applyRemote(let ids, let updatedAt): settings.favoriteModelIds = ids settings.favoriteModelsUpdatedAt = updatedAt log.info("Applied \(ids.count) favorite model(s) from iCloud (remote was newer)") case .mergeAndPush(let ids, let updatedAt): settings.favoriteModelIds = ids settings.favoriteModelsUpdatedAt = updatedAt await pushFavorites() log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote?.ids.count ?? 0) remote") case .noop: break } } // 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() } } } /// Whether an automatic backup should run now, given the chosen frequency and the last /// known backup time. Pulled out of `checkAndPerformAutoBackupIfDue` for testability -- /// "manual" is never due, a missing last-backup-date is always due, otherwise it's due /// once the elapsed time reaches the frequency's interval. static func isBackupDue(frequency: String, lastBackupDate: Date?, now: Date) -> Bool { let interval: TimeInterval switch frequency { case "daily": interval = dailyInterval case "weekly": interval = weeklyInterval default: return false // "manual" — user triggers backups by hand } guard let last = lastBackupDate else { return true } return now.timeIntervalSince(last) >= interval } func checkAndPerformAutoBackupIfDue() async { let frequency = SettingsService.shared.autoBackupFrequency guard frequency == "daily" || frequency == "weekly" else { return } checkForExistingBackup() guard Self.isBackupDue(frequency: frequency, lastBackupDate: lastBackupDate, now: Date()) else { 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 { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" } } // MARK: - BackupError enum BackupError: LocalizedError { case databaseReadFailed case invalidFormat(String) case unsupportedVersion(Int) var errorDescription: String? { switch self { case .databaseReadFailed: return "Could not read settings from the database." case .invalidFormat(let detail): return "The backup file is not valid: \(detail)" case .unsupportedVersion(let v): return "Backup version \(v) is not supported by this version of oAI." } } }