2.4.2 #8
@@ -34,6 +34,15 @@ struct BackupManifest: Codable {
|
|||||||
let credentials: [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
|
// MARK: - BackupService
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -51,6 +60,8 @@ final class BackupService {
|
|||||||
/// URL of the last backup file
|
/// URL of the last backup file
|
||||||
var lastBackupURL: URL?
|
var lastBackupURL: URL?
|
||||||
|
|
||||||
|
private var autoBackupTimer: Timer?
|
||||||
|
|
||||||
// Keys excluded from backup — encrypted_ prefix + internal migration flags
|
// Keys excluded from backup — encrypted_ prefix + internal migration flags
|
||||||
private static let excludedKeys: Set<String> = [
|
private static let excludedKeys: Set<String> = [
|
||||||
"encrypted_openrouterAPIKey",
|
"encrypted_openrouterAPIKey",
|
||||||
@@ -71,6 +82,7 @@ final class BackupService {
|
|||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
checkForExistingBackup()
|
checkForExistingBackup()
|
||||||
|
startAutoBackupTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iCloud Path Resolution
|
// MARK: - iCloud Path Resolution
|
||||||
@@ -176,6 +188,79 @@ final class BackupService {
|
|||||||
log.info("Restored \(manifest.settings.count) settings from backup (v\(manifest.version))")
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func appVersion() -> String {
|
private func appVersion() -> String {
|
||||||
|
|||||||
@@ -520,10 +520,33 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ISO8601 timestamp of the last local change to favoriteModelIds — used to
|
||||||
|
/// resolve last-write-wins conflicts when syncing favorites across machines.
|
||||||
|
var favoriteModelsUpdatedAt: String {
|
||||||
|
get { cache["favoriteModelsUpdatedAt"] ?? "" }
|
||||||
|
set {
|
||||||
|
cache["favoriteModelsUpdatedAt"] = newValue
|
||||||
|
DatabaseService.shared.setSetting(key: "favoriteModelsUpdatedAt", value: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toggleFavoriteModel(_ id: String) {
|
func toggleFavoriteModel(_ id: String) {
|
||||||
var favs = favoriteModelIds
|
var favs = favoriteModelIds
|
||||||
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
|
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
|
||||||
favoriteModelIds = favs
|
favoriteModelIds = favs
|
||||||
|
favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date())
|
||||||
|
Task { await BackupService.shared.pushFavorites() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Automatic Backup
|
||||||
|
|
||||||
|
/// "manual" (default), "daily", or "weekly"
|
||||||
|
var autoBackupFrequency: String {
|
||||||
|
get { cache["autoBackupFrequency"] ?? "manual" }
|
||||||
|
set {
|
||||||
|
cache["autoBackupFrequency"] = newValue
|
||||||
|
DatabaseService.shared.setSetting(key: "autoBackupFrequency", value: newValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Anytype MCP Settings
|
// MARK: - Anytype MCP Settings
|
||||||
|
|||||||
@@ -2596,6 +2596,37 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatic Backup
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Automatic Backup")
|
||||||
|
formSection {
|
||||||
|
row("Frequency") {
|
||||||
|
Picker("", selection: $settingsService.autoBackupFrequency) {
|
||||||
|
Text("Off").tag("manual")
|
||||||
|
Text("Daily").tag("daily")
|
||||||
|
Text("Weekly").tag("weekly")
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 220)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("When enabled, oAI backs up automatically in the background (checked at launch and hourly while running) — no need to press \"Back Up Now\" yourself.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites Sync
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Favorite Models")
|
||||||
|
Text("Starred models sync automatically via the same iCloud Drive folder — star a model on one Mac and it appears starred on your others within a launch or two.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Actions")
|
sectionHeader("Actions")
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ struct oAIApp: App {
|
|||||||
await GitSyncService.shared.syncOnStartup()
|
await GitSyncService.shared.syncOnStartup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconcile starred models with the iCloud copy (cross-machine favorites sync)
|
||||||
|
Task {
|
||||||
|
await BackupService.shared.syncFavoritesOnLaunch()
|
||||||
|
}
|
||||||
|
|
||||||
// Check for updates in the background
|
// Check for updates in the background
|
||||||
UpdateCheckService.shared.checkForUpdates()
|
UpdateCheckService.shared.checkForUpdates()
|
||||||
}
|
}
|
||||||
@@ -56,6 +61,11 @@ struct oAIApp: App {
|
|||||||
AboutView()
|
AboutView()
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
|
Task {
|
||||||
|
await BackupService.shared.syncFavoritesOnLaunch()
|
||||||
|
}
|
||||||
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
Reference in New Issue
Block a user