The oAITests PBXNativeTarget (PBXContainerItemProxy, PBXTargetDependency,
XCBuildConfiguration with TEST_HOST/BUNDLE_LOADER, the "recommended
settings" changes accepted when the target was created -- DEAD_CODE_STRIPPING,
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED, STRING_CATALOG_GENERATE_SYMBOLS,
DEVELOPMENT_TEAM moved to project-level inheritance) was somehow never
actually staged in the very first "Add real oAITests target" commit
(8c7fb59) despite every xcodebuild test run since then depending on it
being present on disk. Every subsequent commit this session only staged
specific file paths (never oAI.xcodeproj again), so the gap went
unnoticed until a full `git status` review here.
Without this, anyone else pulling the branch (or a truly clean checkout
on this machine) would have all the .swift test files but no target to
compile them into -- xcodebuild test would fail to find oAITests at all.
Confirmed the diff is exactly the expected target-wiring content, nothing
unrelated or corrupted, before committing.
339 lines
12 KiB
Swift
339 lines
12 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")
|
|
}
|
|
|
|
/// 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<String>, updatedAt: String)
|
|
case pushLocal
|
|
case mergeAndPush(ids: Set<String>, 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<String>,
|
|
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."
|
|
}
|
|
}
|
|
}
|