iCloud Backup, better chatview exp. bugfixes++
This commit is contained in:
205
oAI/Services/BackupService.swift
Normal file
205
oAI/Services/BackupService.swift
Normal file
@@ -0,0 +1,205 @@
|
||||
//
|
||||
// BackupService.swift
|
||||
// oAI
|
||||
//
|
||||
// iCloud Drive backup of non-encrypted settings (Option C, v1)
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of oAI.
|
||||
//
|
||||
// oAI is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
||||
// Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public
|
||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user