Files
oai-swift/oAI/Services/BackupService.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

204 lines
6.6 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: - 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<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()
}
// 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."
}
}
}