// // BackupService.swift // oAI // // iCloud Drive backup of non-encrypted settings (Option C, v1) // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . 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: - 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? // 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() } // 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: - 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." } } }