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.
289 lines
9.9 KiB
Swift
289 lines
9.9 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
|
|
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 {
|
|
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."
|
|
}
|
|
}
|
|
}
|