Files
oai-swift/oAI/Services/BackupService.swift
T
rune 76b58e6fdc Rename app from oAI to Confab
"oAI" reads as easily confused with OpenAI, both visually and in
casual conversation. Renamed to "Confab" throughout: Xcode
target/scheme/bundle ID (com.oai.Confab), Info.plist and Help Book
identity, all user-facing UI text, internal Log subsystem and color
identifiers, localization catalogs (6 languages, including a proper
reworded/retranslated Intel-deprecation notice), Help Book HTML
content, and docs (README/DEVELOPMENT/PRIVACY/SECURITY).

Deliberately cosmetic-only: the on-disk data folder
(~/Library/Application Support/oAI/), database/backup filenames,
Keychain service identifiers, and EncryptionService's key-derivation
inputs are all left untouched so existing conversations, settings,
and stored API keys survive the update with zero migration and no
re-entering credentials. Verified live: a real signed build
successfully decrypted a stored API key and loaded an existing
conversation database after the bundle ID change.

Also includes a small already-completed, previously uncommitted
model-release-date feature (ModelInfo/OpenRouterModels/
OpenRouterProvider/ModelInfoView) that happened to share several
files with this rename.

Gitignored on this branch and updated on disk but not part of this
commit: CLAUDE.md, RELEASE_NOTES.md, and the build*.sh scripts.
2026-08-02 14:58:14 +02:00

339 lines
12 KiB
Swift

//
// BackupService.swift
// Confab
//
// 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 Confab.
//
// Confab 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 Confab 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: Log.subsystem, 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 Confab."
}
}
}