Two machines that already had favorites before this sync feature shipped will both have an empty favoriteModelsUpdatedAt, so the first sync between them ties. Previously that meant the second machine silently kept only its own set; now tied timestamps merge via union and re-push, so no pre-existing favorites are lost.
298 lines
11 KiB
Swift
298 lines
11 KiB
Swift
//
|
|
// 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
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://oai.pm>.
|
|
|
|
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<String> = [
|
|
"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")
|
|
}
|
|
|
|
/// 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
|
|
let localIds = settings.favoriteModelIds
|
|
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()
|
|
} 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.
|
|
settings.favoriteModelIds = localIds.union(remote.ids)
|
|
settings.favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date())
|
|
await pushFavorites()
|
|
log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote.ids.count) remote")
|
|
}
|
|
// Equal timestamps and identical sets: 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 {
|
|
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."
|
|
}
|
|
}
|
|
}
|