From f0fdcf4ddb740ad35b69a90832a3531e7cd8148a Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 10 Sep 2026 23:15:25 +0800 Subject: [PATCH] Sync Vault and settings across devices --- Memory/ARCHITECTURE.md | 38 ++ Memory/SETTINGS_SYNC_RELEASE.md | 36 ++ Termind/App/AppDependencies.swift | 5 +- Termind/App/AppModel.swift | 22 +- Termind/App/SettingsKey.swift | 18 + Termind/App/TermindApp.swift | 16 +- .../FeatureModels/AIConfigurationModel.swift | 72 ++- .../SkillLibraryModel+BuiltIn.swift | 3 + .../FeatureModels/SkillLibraryModel.swift | 20 +- .../FeatureModels/TerminalSettingsModel.swift | 41 ++ .../AIConfigurationPersistence.swift | 6 + .../TerminalPreferencesPersistence.swift | 7 + .../Security/AISecretStoring.swift | 7 + Termind/Domain/AI/ModelAPIType.swift | 2 +- .../Domain/Sessions/TerminalPreferences.swift | 14 + .../Domain/Settings/SettingsSyncTypes.swift | 202 ++++++ .../Features/Commerce/ProPaywallView.swift | 4 +- Termind/Features/Settings/MCPSettings.swift | 7 + .../Features/Settings/SettingsPageView.swift | 147 ++--- Termind/Features/Settings/SkillSettings.swift | 7 + Termind/Features/Settings/SyncSettings.swift | 131 ++++ ...rminalCustomConfigurationEditorSheet.swift | 29 +- .../Features/Settings/WebSearchSettings.swift | 20 +- .../Cloud/SettingsSyncCoordinator.swift | 603 ++++++++++++++++++ .../Cloud/SettingsSyncJournal.swift | 393 ++++++++++++ .../Cloud/SettingsSyncPersistence.swift | 284 +++++++++ .../Cloud/VaultSyncCoordinator.swift | 55 +- .../Database/AIPersistence.swift | 63 +- .../Database/HostPersistence.swift | 30 +- .../PersistentTerminalPreferences.swift | 45 ++ .../Database/SkillPersistence.swift | 13 +- .../Security/AISecretStore.swift | 108 ++++ Termind/Resources/Localizable.xcstrings | 123 ++++ TermindTests/ArchitectureBoundaryTests.swift | 15 + TermindTests/SettingsSyncTests.swift | 460 +++++++++++++ 35 files changed, 2863 insertions(+), 183 deletions(-) create mode 100644 Memory/SETTINGS_SYNC_RELEASE.md create mode 100644 Termind/Application/FeatureModels/TerminalSettingsModel.swift create mode 100644 Termind/Application/Repositories/TerminalPreferencesPersistence.swift create mode 100644 Termind/Application/Security/AISecretStoring.swift create mode 100644 Termind/Domain/Sessions/TerminalPreferences.swift create mode 100644 Termind/Domain/Settings/SettingsSyncTypes.swift create mode 100644 Termind/Features/Settings/SyncSettings.swift create mode 100644 Termind/Infrastructure/Cloud/SettingsSyncCoordinator.swift create mode 100644 Termind/Infrastructure/Cloud/SettingsSyncJournal.swift create mode 100644 Termind/Infrastructure/Cloud/SettingsSyncPersistence.swift create mode 100644 Termind/Infrastructure/Database/PersistentTerminalPreferences.swift create mode 100644 Termind/Infrastructure/Security/AISecretStore.swift create mode 100644 TermindTests/SettingsSyncTests.swift diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 9c3476e8..e1a6ef98 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -452,3 +452,41 @@ windows and bulk selection cannot overrun the cap. Batch admission happens after the chooser dismisses, before presenting the existing Pro paywall. Restored or previously purchased configurations are retained rather than deleted on downgrade; all retained cards count against future additions. + +## Settings replication + +The iCloud page has one master preference and separate Vault/Settings selections. +Vault is selected by default; Settings is opt-in. Disabling the master preserves +both selections. Upgrading with Vault sync disabled keeps the master disabled. +Pro access gates both CloudKit coordinators, whose zones, subscriptions, account +bindings and journals are independent. Settings use `TermindAIConfigurationV1`; the +existing `TermindVaultV1` protocol is unchanged. + +`SettingsSyncPersistence` commits configuration and upload intent together in +SwiftData V14. V13 already exists in installed development stores: its +`PersistentAIConfigurationSync*` names and defaults stay frozen. V14 adds only +`PersistentTerminalPreferences`; the Settings rename preserves the original +CloudKit identifiers as well. Providers (including their models), MCP clients, skills, role +assignments, model selection, Web Search and Terminal preferences have separate +records. Terminal custom configuration text is included. Remote commits refresh +the application models without entering the local mutation path. Web Search and +Terminal preferences are migrated from UserDefaults once, then mirrored there +for existing runtime readers. Active agent runs retain their configuration snapshots. +Conversations, approval mode, local MCP listener settings and other app preferences +are excluded. Only explicitly selected settings are projected, never all defaults. + +The journal retains tombstones and unresolved references across partial fetches. +Preexisting defaults use baseline versions and yield to cloud records. Built-in +skills use stable IDs; migration preserves customized skills and records deleted +built-ins. Duplicate skill names or MCP namespaces retain their data but only the +lowest UUID is eligible for execution until the conflict is resolved. + +AI credentials use `AISecretStore`. First Settings sync checks the iCloud account +before migrating local Keychain entries. A verified cloud copy and durable local +storage marker precede local-copy cleanup. Migrated entries remain in iCloud +Keychain when configuration sync pauses. Production reads do not cache secrets; +missing synchronized credentials may arrive later. Pending credential deletion +markers prevent delayed Keychain deliveries from reviving a cleared value. +Explicit credentials stay out of CloudKit; custom MCP headers and custom terminal +configuration are ordinary user-authored settings. The private-account sync +adapter is the only additional trusted source of skill-library snapshots. diff --git a/Memory/SETTINGS_SYNC_RELEASE.md b/Memory/SETTINGS_SYNC_RELEASE.md new file mode 100644 index 00000000..eaeb9479 --- /dev/null +++ b/Memory/SETTINGS_SYNC_RELEASE.md @@ -0,0 +1,36 @@ +# Settings sync release verification + +Settings sync ships disabled by default under the Settings checkbox. Vault keeps +its existing zone and record type. Settings retains the first AI-sync zone and +record identifiers for compatibility. No AgentKit dependency update is required. + +V13 entity names/defaults are frozen. V14 adds Terminal preferences. The repair +was verified against an isolated copy of the installed V13 database: migration +to V14 succeeded, SQLite integrity was valid, and every preexisting table kept +its row count. The live database was subsequently observed at V14 with valid +integrity. + +Before shipping the app and privacy policy 1.4 together: + +- In `iCloud.com.akinokaede.termind`, create/validate the Development + `AIConfigurationItem` record type, then deploy its schema to Production. Use the same + field types as `VaultItem`: `formatVersion`, `counter`, and `isDeleted` are + integers; `kind`, `entityID`, `writerID`, and `payloadHash` are strings; + `modifiedAt` is a date; `payload` is bytes; `payloadAsset` is an asset. + The private custom zone is `TermindAIConfigurationV1`; subscription ID is + `termind-ai-configuration-v1-changes`. No public database access is needed. +- Test two signed Macs using the same Apple account: add/edit/delete a provider, + role, MCP client, skill and Terminal configuration; exercise offline edits, + master/category pause and resume, delayed Keychain arrival and key rotation. +- Switch one Mac's iCloud account and confirm that settings upload waits for the + merge action. Verify that the other category's selection and account state are + independent. Repeat with the master disabled and after Pro expiration. +- Check light/dark themes, fonts, custom terminal text and shell-integration + changes on existing windows; ensure conversations and approval mode stay local. +- Match the privacy policy effective date to the actual release date before + publishing the website. The prepared date is September 10, 2026. + +The implementation environment did not have a CloudKit management token +(`cktool get-teams` reported that none was available). Production schema +validation/deployment and two-Mac delivery verification require that environment +access; local tests do not establish real iCloud delivery. diff --git a/Termind/App/AppDependencies.swift b/Termind/App/AppDependencies.swift index f9ff63c7..4787a09d 100644 --- a/Termind/App/AppDependencies.swift +++ b/Termind/App/AppDependencies.swift @@ -8,6 +8,7 @@ struct AppDependencies { let connectionCoordinator: RemoteConnectionCoordinator let serialPortService: any SerialPortServing let agentRunRepository: (any AgentRunPersisting)? + let settingsSync: SettingsSyncCoordinator? let vaultSync: VaultSyncCoordinator? init( @@ -15,7 +16,8 @@ struct AppDependencies { connectionCoordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator(), serialPortService: any SerialPortServing = SystemSerialPortService(), agentRunRepository: (any AgentRunPersisting)? = nil, - vaultSync: VaultSyncCoordinator? = nil + vaultSync: VaultSyncCoordinator? = nil, + settingsSync: SettingsSyncCoordinator? = nil ) { self.hostRepository = hostRepository self.outboxRunner = outboxRunner @@ -23,6 +25,7 @@ struct AppDependencies { self.serialPortService = serialPortService self.agentRunRepository = agentRunRepository self.vaultSync = vaultSync + self.settingsSync = settingsSync } static func inMemory(hosts: [Host], groups: [HostGroup]) -> Self { diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index 35d052c4..1b899b4d 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -70,6 +70,7 @@ final class AppModel { let connections: RemoteConnectionCoordinator let serialPorts: any SerialPortServing let hostRepository: any HostRepository + let settingsSync: SettingsSyncCoordinator? let vaultSync: VaultSyncCoordinator? let forwarding: PortForwardingController let sftpTransfers: SFTPTransferCoordinator @@ -83,6 +84,7 @@ final class AppModel { /// The procedures the user installed for the agent to follow. Its own store /// rather than more fields on `ai`: that one is about endpoints you spend a /// key at, this is a library of documents. See `SkillLibraryModel`. + let terminalSettings: TerminalSettingsModel let skills: SkillLibraryModel let commandGenerator: CommandGeneratorController /// Names conversations for the chat list. Held as the protocol, not the @@ -113,6 +115,7 @@ final class AppModel { init( hosts: HostLibraryModel, ai: AIConfigurationModel = AIConfigurationModel(), skills: SkillLibraryModel = SkillLibraryModel(), + terminalSettings: TerminalSettingsModel = TerminalSettingsModel(), commandHistory: CommandHistoryModel = CommandHistoryModel(), sftpHistory: SFTPHistoryModel = SFTPHistoryModel(), connectionHistory: ConnectionHistoryModel = ConnectionHistoryModel(), @@ -168,6 +171,7 @@ final class AppModel { ) hostRepository = dependencies.hostRepository vaultSync = dependencies.vaultSync + settingsSync = dependencies.settingsSync forwarding = PortForwardingController(hosts: hosts) sftpTransfers = SFTPTransferCoordinator( store: hosts, @@ -175,6 +179,7 @@ final class AppModel { ) self.ai = ai self.skills = skills + self.terminalSettings = terminalSettings commandGenerator = CommandGeneratorController(ai: ai) conversationTitles = ConversationTitleController(ai: ai) self.speechTranscription = @@ -254,15 +259,28 @@ final class AppModel { vaultSync?.didApplyRemote = { [weak self] snapshot, keys in self?.applyRemoteVaultSnapshot(snapshot, changedKeys: keys) } - if proAccess.canUse(.vaultSync) { vaultSync?.start() } + terminalSettings.didChangeShellIntegration = { [weak self] enabled in + self?.shellIntegrationPreferenceDidChange(isEnabled: enabled) + } + settingsSync?.didApplyRemote = { [weak self] snapshot, _ in + self?.ai.applyRemoteSnapshot(snapshot.configuration, webSearch: snapshot.webSearch) + self?.skills.applyRemoteSnapshot(snapshot.skills) + self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal) + } + if proAccess.canUse(.vaultSync) { + vaultSync?.start() + settingsSync?.start() + } } func reconcileProAccess() { if proAccess.canUse(.vaultSync) { vaultSync?.start() + settingsSync?.start() mcpServer.resumeForProAccess() } else { vaultSync?.pause() + settingsSync?.pause() } if !proAccess.canUse(.mcpServer), mcpServer.isEnabled { mcpServer.suspendForProAccess() @@ -445,7 +463,7 @@ final class AppModel { // uses, rather than reaching into the conversation directly: a tool // that touched `@Observable` state from a task group would be // mutating the UI from off the main actor. - let servers = ai.mcpServers.filter(\.isEnabled).map { server in + let servers = ai.usableMCPServers.map { server in (server: server, bearerToken: ai.secret(for: server.credentialRef)) } // Local definitions are registered once per catalog — the runtime's diff --git a/Termind/App/SettingsKey.swift b/Termind/App/SettingsKey.swift index 08b4b2b9..3e3cf2d8 100644 --- a/Termind/App/SettingsKey.swift +++ b/Termind/App/SettingsKey.swift @@ -12,6 +12,11 @@ nonisolated enum SettingsKey { static let appLanguage = "app.language" /// App-wide appearance. `system` inherits changes from macOS. static let appAppearance = "app.appearance" + static let iCloudSyncEnabled = "icloud.sync.enabled" + static let defaultICloudSyncEnabled = true + static let settingsSyncEnabled = "settings.sync.enabled" + static let settingsSyncEnvironment = "settings.sync.environment" + static let defaultSettingsSyncEnabled = false static let vaultSyncEnabled = "vault.sync.enabled" /// The CloudKit environment owning the persisted sync engine state and /// record system fields. Debug and TestFlight share the app container, but @@ -19,6 +24,19 @@ nonisolated enum SettingsKey { /// versa), so an environment switch must rebind the local journal. static let vaultSyncEnvironment = "vault.sync.environment" static let defaultVaultSyncEnabled = true + static func migrateSyncPreferences(in defaults: UserDefaults = .standard) { + if defaults.object(forKey: settingsSyncEnabled) == nil, + let legacy = defaults.object(forKey: "ai.sync.enabled") as? Bool + { + defaults.set(legacy, forKey: settingsSyncEnabled) + } + guard defaults.object(forKey: iCloudSyncEnabled) == nil else { return } + let vault = defaults.object(forKey: vaultSyncEnabled) as? Bool ?? defaultVaultSyncEnabled + let ai = defaults.object(forKey: settingsSyncEnabled) as? Bool ?? defaultSettingsSyncEnabled + defaults.set(vault || ai, forKey: iCloudSyncEnabled) + if !vault && !ai { defaults.set(true, forKey: vaultSyncEnabled) } + } + static let mcpServerEnabled = "mcpServer.enabled" static let mcpServerPort = "mcpServer.port" static let mcpServerDisabledTools = "mcpServer.disabledTools" diff --git a/Termind/App/TermindApp.swift b/Termind/App/TermindApp.swift index a32b2ed3..cca79cd1 100644 --- a/Termind/App/TermindApp.swift +++ b/Termind/App/TermindApp.swift @@ -63,6 +63,14 @@ struct TermindApp: App { LegacyWebSearchSettings.remove { reference in try secrets.setString(nil, for: reference, storage: .thisDevice) } + SettingsKey.migrateSyncPreferences() + let settingsPersistence = SettingsSyncPersistence(context: container.mainContext) + try settingsPersistence.prepare() + let aiSecrets = AISecretStore(context: container.mainContext, keychain: secrets) + let settingsSync: SettingsSyncCoordinator? = + isRunningTests + ? nil + : SettingsSyncCoordinator(persistence: settingsPersistence, secrets: aiSecrets) let hosts = HostLibraryModel( modelContext: container.mainContext, secrets: secrets ) @@ -85,9 +93,11 @@ struct TermindApp: App { initialValue: AppModel( hosts: hosts, ai: AIConfigurationModel( - modelContext: container.mainContext, secrets: secrets + persistence: SyncedAIConfigurationPersistence(settingsPersistence), secrets: secrets, + permissionDefaults: .standard, aiSecrets: aiSecrets, webSearchPersistence: settingsPersistence ), - skills: SkillLibraryModel(modelContext: container.mainContext), + skills: SkillLibraryModel(persistence: SyncedSkillLibraryPersistence(settingsPersistence)), + terminalSettings: TerminalSettingsModel(persistence: settingsPersistence), commandHistory: CommandHistoryModel(modelContext: container.mainContext), sftpHistory: SFTPHistoryModel(modelContext: container.mainContext), connectionHistory: ConnectionHistoryModel( @@ -100,7 +110,7 @@ struct TermindApp: App { agentRunRepository: SwiftDataAgentRunRepository( context: container.mainContext ), - vaultSync: vaultSync + vaultSync: vaultSync, settingsSync: settingsSync ), proAccess: proAccess ) diff --git a/Termind/Application/FeatureModels/AIConfigurationModel.swift b/Termind/Application/FeatureModels/AIConfigurationModel.swift index e89adab7..993a9156 100644 --- a/Termind/Application/FeatureModels/AIConfigurationModel.swift +++ b/Termind/Application/FeatureModels/AIConfigurationModel.swift @@ -56,6 +56,9 @@ final class AIConfigurationModel { /// through to the Data Protection Keychain; previews keep their fake secret /// in memory so demo data never creates a real Keychain item. private var secrets: [String: String] = [:] + private let aiSecrets: (any AISecretStoring)? + private let webSearchPersistence: (any AIWebSearchPersisting)? + private(set) var webSearch = AIWebSearchPreferences() private let keychain: any SecretStoring private let persistsSecrets: Bool private let permissionDefaults: UserDefaults? @@ -73,8 +76,12 @@ final class AIConfigurationModel { persistence: (any AIConfigurationPersisting)? = nil, secrets secretStore: any SecretStoring = InMemorySecretStore(), persistsSecrets: Bool = true, - permissionDefaults: UserDefaults? = nil + permissionDefaults: UserDefaults? = nil, + aiSecrets: (any AISecretStoring)? = nil, + webSearchPersistence: (any AIWebSearchPersisting)? = nil ) { + self.aiSecrets = aiSecrets + self.webSearchPersistence = webSearchPersistence self.modelProviders = modelProviders self.endpoints = endpoints self.mcpServers = mcpServers @@ -89,6 +96,11 @@ final class AIConfigurationModel { self.keychain = secretStore self.persistsSecrets = persistsSecrets self.permissionDefaults = permissionDefaults + if let webSearchPersistence { + do { webSearch = try webSearchPersistence.loadWebSearch() } catch { + storageError = error.localizedDescription + } + } guard let persistence else { return } do { let loaded = try persistence.load() @@ -226,17 +238,67 @@ final class AIConfigurationModel { /// Every tool the model may be offered: enabled tools on enabled servers. var availableTools: [(server: MCPServer, tool: MCPTool)] { - mcpServers.filter(\.isEnabled).flatMap { server in + usableMCPServers.flatMap { server in server.enabledTools.map { (server, $0) } } } + var conflictingMCPServerIDs: Set { + var names = Set() + var conflicts = Set() + for server in mcpServers.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + if !server.effectiveNamespaceID.isEmpty, !names.insert(server.effectiveNamespaceID).inserted { + conflicts.insert(server.id) + } + } + return conflicts + } + + var usableMCPServers: [MCPServer] { + let conflicts = conflictingMCPServerIDs + return mcpServers.filter { $0.isEnabled && !conflicts.contains($0.id) } + } + + func setWebSearch(_ value: AIWebSearchPreferences) { + do { + try webSearchPersistence?.saveWebSearch(value) + webSearch = value + storageError = nil + } catch { storageError = error.localizedDescription } + } + + /// Applies an already committed replica. It never calls the local write path. + func applyRemoteSnapshot(_ snapshot: AIConfigurationSnapshot, webSearch: AIWebSearchPreferences) { + let previousServers = Dictionary(uniqueKeysWithValues: mcpServers.map { ($0.id, $0) }) + modelProviders = snapshot.providers + endpoints = snapshot.endpoints + roles = snapshot.roles + lastSelectedModel = snapshot.lastSelectedModel + mcpServers = snapshot.mcpServers + mcpStatuses = mcpStatuses.filter { id, _ in + previousServers[id] == mcpServers.first { $0.id == id } + } + self.webSearch = webSearch + secrets.removeAll() + storageError = nil + } + // MARK: - Secrets /// The secret behind a `credentialRef`, loaded from the Termind Data /// Protection Keychain and cached only for this process. func secret(for ref: String) -> String? { guard !ref.isEmpty else { return nil } + if let aiSecrets, persistsSecrets { + do { + let value = try aiSecrets.string(for: ref) + secretStorageError = nil + return value + } catch { + secretStorageError = error.localizedDescription + return nil + } + } if let cached = secrets[ref] { return cached } guard persistsSecrets else { return nil } do { @@ -256,7 +318,11 @@ final class AIConfigurationModel { guard !ref.isEmpty else { return } do { if persistsSecrets { - try keychain.setString(secret, for: ref, storage: .thisDevice) + if let aiSecrets { + try aiSecrets.setString(secret, for: ref) + } else { + try keychain.setString(secret, for: ref, storage: .thisDevice) + } } secrets[ref] = secret secretStorageError = nil diff --git a/Termind/Application/FeatureModels/SkillLibraryModel+BuiltIn.swift b/Termind/Application/FeatureModels/SkillLibraryModel+BuiltIn.swift index 130c1709..91277391 100644 --- a/Termind/Application/FeatureModels/SkillLibraryModel+BuiltIn.swift +++ b/Termind/Application/FeatureModels/SkillLibraryModel+BuiltIn.swift @@ -46,6 +46,7 @@ nonisolated enum AgentSkillLibrary { private static var diskFullTriage: AgentSkill { AgentSkill( + id: UUID(uuidString: "9C362C57-BCCF-4B44-A1C4-000000000001")!, name: "disk-full-triage", title: "Disk Full Triage", summary: """ @@ -112,6 +113,7 @@ nonisolated enum AgentSkillLibrary { private static var failingService: AgentSkill { AgentSkill( + id: UUID(uuidString: "9C362C57-BCCF-4B44-A1C4-000000000002")!, name: "failing-service-triage", title: "Failing Service Triage", summary: """ @@ -167,6 +169,7 @@ nonisolated enum AgentSkillLibrary { private static var safeConfigEdit: AgentSkill { AgentSkill( + id: UUID(uuidString: "9C362C57-BCCF-4B44-A1C4-000000000003")!, name: "safe-config-edit", title: "Safe Remote Config Edit", summary: """ diff --git a/Termind/Application/FeatureModels/SkillLibraryModel.swift b/Termind/Application/FeatureModels/SkillLibraryModel.swift index 6968edc3..31f9e9f9 100644 --- a/Termind/Application/FeatureModels/SkillLibraryModel.swift +++ b/Termind/Application/FeatureModels/SkillLibraryModel.swift @@ -68,7 +68,8 @@ final class SkillLibraryModel { /// What a run may be offered. Incomplete drafts are excluded rather than /// listed as names with nothing to match on — see `AgentSkill.isComplete`. var enabledSkills: [AgentSkill] { - skills.filter { $0.isEnabled && $0.isComplete } + let conflicts = conflictingSkillIDs + return skills.filter { $0.isEnabled && $0.isComplete && !conflicts.contains($0.id) } } /// The snapshot a run is given. Empty when nothing is enabled, which is what @@ -88,6 +89,23 @@ final class SkillLibraryModel { !name.isEmpty && skills.contains { $0.id != id && $0.effectiveName == name } } + var conflictingSkillIDs: Set { + var names = Set() + var conflicts = Set() + for skill in skills.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + if !skill.effectiveName.isEmpty, !names.insert(skill.effectiveName).inserted { + conflicts.insert(skill.id) + } + } + return conflicts + } + + /// Only the trusted persistence/sync composition root supplies this snapshot. + func applyRemoteSnapshot(_ values: [AgentSkill]) { + skills = values + storageError = nil + } + // MARK: - Writes func upsert(_ skill: AgentSkill) { diff --git a/Termind/Application/FeatureModels/TerminalSettingsModel.swift b/Termind/Application/FeatureModels/TerminalSettingsModel.swift new file mode 100644 index 00000000..c7170ca0 --- /dev/null +++ b/Termind/Application/FeatureModels/TerminalSettingsModel.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Settings edits persist first, then update every window and the terminal runtime. +@Observable +final class TerminalSettingsModel { + private(set) var preferences = TerminalPreferences() + private(set) var storageError: String? + private let persistence: (any TerminalPreferencesPersisting)? + var didChangeShellIntegration: ((Bool) -> Void)? + + init(persistence: (any TerminalPreferencesPersisting)? = nil) { + self.persistence = persistence + do { if let persistence { preferences = try persistence.loadTerminal() } } catch { + storageError = error.localizedDescription + } + } + + @discardableResult + func set(_ value: Value, for keyPath: WritableKeyPath) -> Bool { + var next = preferences + next[keyPath: keyPath] = value + guard next != preferences else { return true } + do { + try persistence?.saveTerminal(next) + applyRemoteSnapshot(next) + return true + } catch { + storageError = error.localizedDescription + return false + } + } + + func applyRemoteSnapshot(_ value: TerminalPreferences) { + let previous = preferences + preferences = value + storageError = nil + if previous.shellIntegration != value.shellIntegration { + didChangeShellIntegration?(value.shellIntegration) + } + } +} diff --git a/Termind/Application/Repositories/AIConfigurationPersistence.swift b/Termind/Application/Repositories/AIConfigurationPersistence.swift index adfba8c7..afcec0f6 100644 --- a/Termind/Application/Repositories/AIConfigurationPersistence.swift +++ b/Termind/Application/Repositories/AIConfigurationPersistence.swift @@ -15,3 +15,9 @@ protocol AIConfigurationPersisting: AnyObject { func load() throws -> AIConfigurationSnapshot func save(_ snapshot: AIConfigurationSnapshot) throws } + +@MainActor +protocol AIWebSearchPersisting: AnyObject { + func loadWebSearch() throws -> AIWebSearchPreferences + func saveWebSearch(_ value: AIWebSearchPreferences) throws +} diff --git a/Termind/Application/Repositories/TerminalPreferencesPersistence.swift b/Termind/Application/Repositories/TerminalPreferencesPersistence.swift new file mode 100644 index 00000000..9e19043a --- /dev/null +++ b/Termind/Application/Repositories/TerminalPreferencesPersistence.swift @@ -0,0 +1,7 @@ +import Foundation + +@MainActor +protocol TerminalPreferencesPersisting: AnyObject { + func loadTerminal() throws -> TerminalPreferences + func saveTerminal(_ value: TerminalPreferences) throws +} diff --git a/Termind/Application/Security/AISecretStoring.swift b/Termind/Application/Security/AISecretStoring.swift new file mode 100644 index 00000000..d231c989 --- /dev/null +++ b/Termind/Application/Security/AISecretStoring.swift @@ -0,0 +1,7 @@ +import Foundation + +@MainActor +protocol AISecretStoring: AnyObject { + func string(for reference: String) throws -> String? + func setString(_ value: String?, for reference: String) throws +} diff --git a/Termind/Domain/AI/ModelAPIType.swift b/Termind/Domain/AI/ModelAPIType.swift index 2800673a..9a18b9ba 100644 --- a/Termind/Domain/AI/ModelAPIType.swift +++ b/Termind/Domain/AI/ModelAPIType.swift @@ -173,7 +173,7 @@ nonisolated extension ModelAPIFormat { /// recoverable from the composed result: a URL ending in `/messages` looks the /// same whether the user typed that suffix or Termind appended it, and /// `.automatic` looks the same as picking the format it resolved to. -nonisolated struct ProviderEndpoint: Hashable, Sendable { +nonisolated struct ProviderEndpoint: Codable, Hashable, Sendable { var apiType: ModelAPIType = .automatic var url: String = "" diff --git a/Termind/Domain/Sessions/TerminalPreferences.swift b/Termind/Domain/Sessions/TerminalPreferences.swift new file mode 100644 index 00000000..a49b137c --- /dev/null +++ b/Termind/Domain/Sessions/TerminalPreferences.swift @@ -0,0 +1,14 @@ +import Foundation + +nonisolated struct TerminalPreferences: Codable, Hashable, Sendable { + var fontSize = 12.0 + var fontFollowTheme = true + var darkTheme = "Termind Dark" + var lightTheme = "Termind Light" + var darkCustomEnabled = false + var lightCustomEnabled = false + var darkCustomConfiguration = "" + var lightCustomConfiguration = "" + var shellIntegration = true + var remoteWindowTitleInMenuBar = true +} diff --git a/Termind/Domain/Settings/SettingsSyncTypes.swift b/Termind/Domain/Settings/SettingsSyncTypes.swift new file mode 100644 index 00000000..7f41e692 --- /dev/null +++ b/Termind/Domain/Settings/SettingsSyncTypes.swift @@ -0,0 +1,202 @@ +import AgentKit +import Foundation + +/// App-owned wire types: only persisted configuration crosses this boundary. +nonisolated struct SyncedAIModel: Codable, Hashable, Sendable { + var modelID = "" + var displayName = "" + var ownedBy = "" + var kind = AIModel.Kind.chat.rawValue + var input: [String] = [] + var output: [String] = [] + var abilities: [String] = [] + var contextLength: Int? + var maxOutputTokens: Int? + var iconHint = "" + var createdAt: Date? + var origin = AIModel.Origin.manual.rawValue + var isShownInAssistant = true + var reasoningProfileData: Data? + + init(_ value: AIModel) { + modelID = value.id + displayName = value.displayName + ownedBy = value.ownedBy + kind = value.kind.rawValue + input = value.input.map(\.rawValue).sorted() + output = value.output.map(\.rawValue).sorted() + abilities = value.abilities.map(\.rawValue).sorted() + contextLength = value.contextLength + maxOutputTokens = value.maxOutputTokens + iconHint = value.iconHint + createdAt = value.createdAt + origin = value.origin.rawValue + isShownInAssistant = value.isShownInAssistant + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + reasoningProfileData = value.reasoningProfile.flatMap { try? encoder.encode($0) } + } + + var value: AIModel { + var model = AIModel(id: modelID) + model.displayName = displayName + model.ownedBy = ownedBy + model.kind = AIModel.Kind(rawValue: kind) ?? .chat + model.input = Set(input.compactMap(AIModel.Modality.init(rawValue:))) + if model.input.isEmpty { model.input = [.text] } + model.output = Set(output.compactMap(AIModel.Modality.init(rawValue:))) + if model.output.isEmpty { model.output = [.text] } + model.abilities = Set(abilities.compactMap(AIModel.Ability.init(rawValue:))) + model.contextLength = contextLength + model.maxOutputTokens = maxOutputTokens + model.iconHint = iconHint + model.createdAt = createdAt + model.origin = AIModel.Origin(rawValue: origin) ?? .manual + model.isShownInAssistant = isShownInAssistant + model.reasoningProfile = reasoningProfileData.flatMap { + try? JSONDecoder().decode(ReasoningProfile.self, from: $0) + } + return model + } +} + +nonisolated struct SyncedModelProvider: Codable, Hashable, Sendable { + var id: UUID + var name: String + var endpoint: ProviderEndpoint + var isEnabled: Bool + var credentialRef: String + var models: [SyncedAIModel] + var modelsFetchedAt: Date? + var sortIndex: Int + + init(_ provider: ModelProvider, endpoint: ProviderEndpoint, sortIndex: Int) { + id = provider.id + name = provider.name + self.endpoint = endpoint + isEnabled = provider.isEnabled + credentialRef = provider.credentialRef + models = provider.models.map(SyncedAIModel.init) + modelsFetchedAt = provider.modelsFetchedAt + self.sortIndex = sortIndex + } + + var value: ModelProvider { + ModelProvider( + id: id, name: name, apiFormat: endpoint.format, isEnabled: isEnabled, + inferenceURL: endpoint.requestURL, baseURL: endpoint.baseURL, + credentialRef: credentialRef, models: models.map(\.value), modelsFetchedAt: modelsFetchedAt) + } +} + +nonisolated struct SyncedMCPServer: Codable, Hashable, Sendable { + var id: UUID + var name: String + var namespaceID: String + var transport: MCPServer.Transport + var url: String + var isEnabled: Bool + var credentialRef: String + var headers: [MCPHeader] + var tools: [MCPTool] + var sortIndex: Int + + init(_ server: MCPServer, sortIndex: Int) { + id = server.id + name = server.name + namespaceID = server.namespaceID + transport = server.transport + url = server.url + isEnabled = server.isEnabled + credentialRef = server.credentialRef + headers = server.headers + tools = server.tools + self.sortIndex = sortIndex + } + + var value: MCPServer { + MCPServer( + id: id, name: name, namespaceID: namespaceID, transport: transport, + url: url, isEnabled: isEnabled, credentialRef: credentialRef, headers: headers, tools: tools) + } +} + +nonisolated struct AIWebSearchPreferences: Codable, Hashable, Sendable { + var isEnabled = true + var prefersBuiltIn = true +} + +nonisolated enum SettingsRecordKind: String, Codable, CaseIterable, Sendable { + case provider, mcpServer, skill, role, selection, webSearch, terminal +} + +nonisolated struct SettingsRecordKey: Codable, Hashable, Sendable { + var kind: SettingsRecordKind + var id: String + var recordName: String { "v1/\(kind.rawValue)/\(id)" } + + init(kind: SettingsRecordKind, id: String) { + self.kind = kind + self.id = id + } + + init?(recordName: String) { + let parts = recordName.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 3, parts[0] == "v1", + let kind = SettingsRecordKind(rawValue: String(parts[1])) + else { return nil } + let id = String(parts[2]) + switch kind { + case .provider, .mcpServer, .skill: + guard let uuid = UUID(uuidString: id), uuid.uuidString.lowercased() == id else { return nil } + case .role: + guard AIRole(rawValue: id) != nil else { return nil } + case .selection, .webSearch, .terminal: + guard id == "default" else { return nil } + } + self.init(kind: kind, id: id) + } +} + +nonisolated enum SettingsRecordPayload: Codable, Hashable, Sendable { + case provider(SyncedModelProvider) + case mcpServer(SyncedMCPServer) + case skill(AgentSkill, sortIndex: Int) + case role(AIRole, AIModelRef?) + case selection(AIModelRef?) + case webSearch(AIWebSearchPreferences) + case terminal(TerminalPreferences) + + var key: SettingsRecordKey { + switch self { + case .provider(let value): .init(kind: .provider, id: value.id.uuidString.lowercased()) + case .mcpServer(let value): .init(kind: .mcpServer, id: value.id.uuidString.lowercased()) + case .skill(let value, _): .init(kind: .skill, id: value.id.uuidString.lowercased()) + case .role(let role, _): .init(kind: .role, id: role.rawValue) + case .selection: .init(kind: .selection, id: "default") + case .webSearch: .init(kind: .webSearch, id: "default") + case .terminal: .init(kind: .terminal, id: "default") + } + } +} + +nonisolated struct SettingsEnvelope: Codable, Hashable, Sendable { + static let currentFormatVersion = 1 + var formatVersion = Self.currentFormatVersion + var key: SettingsRecordKey + var version: VaultVersion + var isDeleted = false + var payload: SettingsRecordPayload? + + var isValid: Bool { + formatVersion == Self.currentFormatVersion + && SettingsRecordKey(recordName: key.recordName) == key + && (isDeleted ? payload == nil : payload?.key == key) + } +} + +nonisolated struct SettingsMutationSet: Equatable, Sendable { + var keys: Set = [] + static let empty = Self() + var isEmpty: Bool { keys.isEmpty } +} diff --git a/Termind/Features/Commerce/ProPaywallView.swift b/Termind/Features/Commerce/ProPaywallView.swift index f9e740a5..593d555b 100644 --- a/Termind/Features/Commerce/ProPaywallView.swift +++ b/Termind/Features/Commerce/ProPaywallView.swift @@ -24,7 +24,7 @@ struct ProPaywallView: View { isReviewDemoUnlocked = true isReviewDemoExpanded = true } - Text("One purchase. Your advanced workspace, AI, MCP, and iCloud tools—forever.") + Text("One purchase for advanced workspaces, AI, MCP, and iCloud sync.") .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 430) @@ -39,7 +39,7 @@ struct ProPaywallView: View { feature("Monitor more than 3 servers", "waveform.path.ecg") feature("Upload files by dropping them on a terminal", "arrow.up.doc") feature("Connect MCP tools and run the built-in MCP Server", "server.rack") - feature("Sync your Vault through your private iCloud database", "icloud") + feature("Sync Vault and Settings across devices", "icloud") } .padding(.vertical, 28) diff --git a/Termind/Features/Settings/MCPSettings.swift b/Termind/Features/Settings/MCPSettings.swift index 467892ce..c40e3500 100644 --- a/Termind/Features/Settings/MCPSettings.swift +++ b/Termind/Features/Settings/MCPSettings.swift @@ -14,6 +14,13 @@ struct MCPSettings: View { var body: some View { Section { + if !app.aiConfiguration.conflictingMCPServerIDs.isEmpty { + Label( + "Some MCP servers have duplicate namespaces and are paused. Edit their namespace IDs to resolve the conflict.", + systemImage: "exclamationmark.triangle" + ) + .foregroundStyle(.orange) + } ForEach(app.aiConfiguration.mcpServers) { server in MCPServerRow(server: server) { edit(server.id, false) } } diff --git a/Termind/Features/Settings/SettingsPageView.swift b/Termind/Features/Settings/SettingsPageView.swift index e33b95ee..10f3a03f 100644 --- a/Termind/Features/Settings/SettingsPageView.swift +++ b/Termind/Features/Settings/SettingsPageView.swift @@ -150,10 +150,7 @@ struct SettingsPageView: View { case .terminal: SettingsFormPage { TerminalSettings( - editConfiguration: { navigate(to: .terminalConfiguration($0)) }, - shellIntegrationChanged: { - app.shellIntegrationPreferenceDidChange(isEnabled: $0) - } + editConfiguration: { navigate(to: .terminalConfiguration($0)) } ) } case .sftp: @@ -174,7 +171,9 @@ struct SettingsPageView: View { ) } case .sync: - SettingsFormPage { SyncSettings() } + SettingsFormPage { + SyncSettings() + } #if DEBUG case .developer: DeveloperSettingsPage() @@ -444,7 +443,7 @@ private struct ProSettings: View { Section("Termind Pro") { Text( - "Unlock multiple-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and private iCloud Vault sync with one permanent purchase." + "One purchase unlocks multi-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and iCloud sync for your Vault and settings across devices." ) .foregroundStyle(.secondary) if app.proAccess.status.canPurchase { @@ -608,16 +607,21 @@ private struct SFTPSettings: View { private struct TerminalSettings: View { let editConfiguration: (TerminalCustomConfigurationScheme) -> Void - let shellIntegrationChanged: (Bool) -> Void - @AppStorage(SettingsKey.terminalFontSize) private var fontSize = 12.0 - @AppStorage(SettingsKey.terminalFontFollowTheme) private var fontFollowTheme = true - @AppStorage(SettingsKey.shellIntegration) private var shellIntegration = true - @AppStorage(SettingsKey.remoteWindowTitleInMenuBar) private var remoteWindowTitleInMenuBar = - SettingsKey.defaultRemoteWindowTitleInMenuBarEnabled - @AppStorage(SettingsKey.terminalThemeDark) private var darkTheme = SettingsKey.defaultTerminalThemeDark - @AppStorage(SettingsKey.terminalThemeLight) private var lightTheme = SettingsKey.defaultTerminalThemeLight - @AppStorage(SettingsKey.terminalCustomConfigurationDarkEnabled) private var darkCustomEnabled = false - @AppStorage(SettingsKey.terminalCustomConfigurationLightEnabled) private var lightCustomEnabled = false + @Environment(AppModel.self) private var app + private var fontSize: Double { app.terminalSettings.preferences.fontSize } + private var fontFollowTheme: Bool { app.terminalSettings.preferences.fontFollowTheme } + private var shellIntegration: Bool { app.terminalSettings.preferences.shellIntegration } + private var remoteWindowTitleInMenuBar: Bool { app.terminalSettings.preferences.remoteWindowTitleInMenuBar } + private var darkTheme: String { app.terminalSettings.preferences.darkTheme } + private var lightTheme: String { app.terminalSettings.preferences.lightTheme } + private var darkCustomEnabled: Bool { app.terminalSettings.preferences.darkCustomEnabled } + private var lightCustomEnabled: Bool { app.terminalSettings.preferences.lightCustomEnabled } + + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { app.terminalSettings.preferences[keyPath: keyPath] }, + set: { app.terminalSettings.set($0, for: keyPath) }) + } private var darkThemes: [GhosttyThemeDefinition] { TermindTerminalThemeCatalog.allThemes.filter(\.isDark).sorted { $0.name < $1.name } @@ -628,10 +632,15 @@ private struct TerminalSettings: View { var body: some View { Group { + if let error = app.terminalSettings.storageError { + Text(error).foregroundStyle(.orange) + } Section("Light Configuration") { - Picker("Theme", selection: $lightTheme) { ForEach(lightThemes) { Text($0.name).tag($0.name) } } - .disabled(lightCustomEnabled) - Toggle("Custom Configuration", isOn: $lightCustomEnabled) + Picker("Theme", selection: binding(\.lightTheme)) { + ForEach(lightThemes) { Text($0.name).tag($0.name) } + } + .disabled(lightCustomEnabled) + Toggle("Custom Configuration", isOn: binding(\.lightCustomEnabled)) if lightCustomEnabled { FormNavigationAction(title: "Edit Custom Configuration") { editConfiguration(.light) @@ -639,9 +648,9 @@ private struct TerminalSettings: View { } } Section("Dark Configuration") { - Picker("Theme", selection: $darkTheme) { ForEach(darkThemes) { Text($0.name).tag($0.name) } } + Picker("Theme", selection: binding(\.darkTheme)) { ForEach(darkThemes) { Text($0.name).tag($0.name) } } .disabled(darkCustomEnabled) - Toggle("Custom Configuration", isOn: $darkCustomEnabled) + Toggle("Custom Configuration", isOn: binding(\.darkCustomEnabled)) if darkCustomEnabled { FormNavigationAction(title: "Edit Custom Configuration") { editConfiguration(.dark) @@ -649,11 +658,11 @@ private struct TerminalSettings: View { } } Section("Font Configuration") { - Toggle("Follow Theme", isOn: $fontFollowTheme) + Toggle("Follow Theme", isOn: binding(\.fontFollowTheme)) if !fontFollowTheme { LabeledContent("Font Size") { HStack { - Slider(value: $fontSize, in: 9...24, step: 1) + Slider(value: binding(\.fontSize), in: 9...24, step: 1) Text(fontSize, format: .number.precision(.fractionLength(0))) .monospacedDigit().frame(width: 24, alignment: .trailing) } @@ -661,7 +670,7 @@ private struct TerminalSettings: View { } } Section("Integration") { - Toggle(isOn: $shellIntegration) { + Toggle(isOn: binding(\.shellIntegration)) { VStack(alignment: .leading, spacing: Metrics.Space.xxs) { Text("Shell Integration") Text( @@ -672,7 +681,7 @@ private struct TerminalSettings: View { } } - Toggle(isOn: $remoteWindowTitleInMenuBar) { + Toggle(isOn: binding(\.remoteWindowTitleInMenuBar)) { VStack(alignment: .leading, spacing: Metrics.Space.xxs) { Text("Show Terminal Title in Menu Bar") Text( @@ -686,95 +695,11 @@ private struct TerminalSettings: View { } .onAppear { if TermindTerminalThemeCatalog.darkTheme(named: darkTheme) == nil { - darkTheme = SettingsKey.defaultTerminalThemeDark + app.terminalSettings.set(SettingsKey.defaultTerminalThemeDark, for: \.darkTheme) } if TermindTerminalThemeCatalog.lightTheme(named: lightTheme) == nil { - lightTheme = SettingsKey.defaultTerminalThemeLight + app.terminalSettings.set(SettingsKey.defaultTerminalThemeLight, for: \.lightTheme) } } - .onChange(of: shellIntegration) { _, isEnabled in - shellIntegrationChanged(isEnabled) - } - } -} - -private struct SyncSettings: View { - @Environment(AppModel.self) private var app - @Environment(\.locale) private var locale - @AppStorage(SettingsKey.vaultSyncEnabled) private var isEnabled = - SettingsKey.defaultVaultSyncEnabled - - var body: some View { - Section("iCloud") { - Toggle("Sync Vault with iCloud", isOn: syncEnabled) - LabeledContent("Status") { statusLabel } - - if case .accountChangeConfirmationRequired = status { - Text( - "This Mac is signed in to a different iCloud account. Your local Vault will not be uploaded until you confirm the merge." - ) - .font(.caption).foregroundStyle(.secondary) - Button("Merge with This iCloud Account") { - app.vaultSync?.confirmAccountSwitchMerge() - } - } else if case .failed = status { - Button("Retry") { app.vaultSync?.retry() } - } - - Text( - "Hosts, identities, public key metadata, Known Hosts, snippets, and port forwards sync through your private iCloud database. Passwords and private keys remain protected separately by Keychain. Credentials marked “This Mac” will be missing on other Macs." - ) - .font(.caption).foregroundStyle(.secondary) - } - } - - private var syncEnabled: Binding { - Binding( - get: { isEnabled && app.proAccess.canUse(.vaultSync) }, - set: { enabled in - guard !enabled || app.proAccess.require(.vaultSync) else { return } - isEnabled = enabled - app.vaultSync?.setEnabled(enabled) - } - ) - } - - private var status: VaultSyncStatus { - guard app.proAccess.canUse(.vaultSync) else { return .paused } - return app.vaultSync?.status ?? (isEnabled ? .checkingAccount : .paused) - } - - @ViewBuilder - private var statusLabel: some View { - switch status { - case .checkingAccount: - Label("Checking iCloud…", systemImage: "icloud") - case .paused: - Label("Paused", systemImage: "pause.circle") - case .unavailable(let message): - Label(message, systemImage: "icloud.slash").foregroundStyle(.secondary) - case .accountChangeConfirmationRequired: - Label("Waiting for confirmation", systemImage: "person.crop.circle.badge.exclamationmark") - .foregroundStyle(.orange) - case .syncing(let pendingCount): - Label( - pendingCount == 1 ? "Syncing 1 change…" : "Syncing \(pendingCount) changes…", - systemImage: "arrow.triangle.2.circlepath.icloud" - ) - case .upToDate(let lastSync): - if let lastSync { - let formatted = lastSync.formatted( - Date.FormatStyle(date: .abbreviated, time: .shortened).locale(locale) - ) - Label( - "Up to date · \(formatted)", - systemImage: "checkmark.icloud" - ) - } else { - Label("Up to date", systemImage: "checkmark.icloud") - } - case .failed(let message): - Label(message, systemImage: "exclamationmark.icloud").foregroundStyle(.red) - } } } diff --git a/Termind/Features/Settings/SkillSettings.swift b/Termind/Features/Settings/SkillSettings.swift index fb80aa93..22d556ff 100644 --- a/Termind/Features/Settings/SkillSettings.swift +++ b/Termind/Features/Settings/SkillSettings.swift @@ -19,6 +19,13 @@ struct SkillSettings: View { var body: some View { Section { + if !app.skills.conflictingSkillIDs.isEmpty { + Label( + "Some skills have duplicate names and are paused. Rename them to resolve the conflict.", + systemImage: "exclamationmark.triangle" + ) + .foregroundStyle(.orange) + } ForEach(app.skills.skills) { skill in SkillRow(skill: skill) { edit(skill.id, false) } } diff --git a/Termind/Features/Settings/SyncSettings.swift b/Termind/Features/Settings/SyncSettings.swift new file mode 100644 index 00000000..1e908248 --- /dev/null +++ b/Termind/Features/Settings/SyncSettings.swift @@ -0,0 +1,131 @@ +import SwiftUI + +struct SyncSettings: View { + @Environment(AppModel.self) private var app + @AppStorage(SettingsKey.iCloudSyncEnabled) private var isEnabled = SettingsKey.defaultICloudSyncEnabled + @AppStorage(SettingsKey.vaultSyncEnabled) private var includesVault = SettingsKey.defaultVaultSyncEnabled + @AppStorage(SettingsKey.settingsSyncEnabled) private var includesSettings = SettingsKey.defaultSettingsSyncEnabled + + private var isActive: Bool { isEnabled && app.proAccess.canUse(.vaultSync) } + + var body: some View { + Section("iCloud") { + Toggle( + "iCloud Sync", + isOn: Binding( + get: { isActive }, + set: { enabled in + guard !enabled || app.proAccess.require(.vaultSync) else { return } + isEnabled = enabled + app.reconcileProAccess() + }) + ) + .toggleStyle(.switch) + + VStack(alignment: .leading, spacing: Metrics.Space.m) { + SyncSelectionRow( + title: "Vault", detail: "Hosts, identities, snippets, and port forwards.", + isSelected: Binding( + get: { includesVault }, + set: { value in + includesVault = value + app.vaultSync?.setEnabled(value) + }), status: isActive ? app.vaultSync?.status : nil, + retry: { app.vaultSync?.retry() }, + merge: { app.vaultSync?.confirmAccountSwitchMerge() }) + SyncSelectionRow( + title: "Settings", detail: "Terminal and AI preferences.", + isSelected: Binding( + get: { includesSettings }, + set: { value in + includesSettings = value + app.settingsSync?.setEnabled(value) + }), status: isActive ? app.settingsSync?.status : nil, + retry: { app.settingsSync?.retry() }, + merge: { app.settingsSync?.confirmAccountSwitchMerge() }) + } + .disabled(!isActive) + + if includesSettings { + Text("API keys use iCloud Keychain, even when configuration sync is paused.") + .font(.caption).foregroundStyle(.secondary) + } + } + } +} + +private struct SyncSelectionRow: View { + let title: LocalizedStringKey + let detail: LocalizedStringKey + @Binding var isSelected: Bool + let status: VaultSyncStatus? + let retry: () -> Void + let merge: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: Metrics.Space.xs) { + HStack { + Toggle(isOn: $isSelected) { + VStack(alignment: .leading, spacing: Metrics.Space.xxs) { + Text(title) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + Spacer() + if isSelected, let status { + SyncStatusLabel(status: status) + .font(.caption).foregroundStyle(.secondary) + } + } + if isSelected, let status { + switch status { + case .accountChangeConfirmationRequired: + Button("Merge with This iCloud Account", action: merge) + case .failed: + Button("Retry", action: retry) + default: EmptyView() + } + } + } + } +} + +struct SyncStatusLabel: View { + let status: VaultSyncStatus + @Environment(\.locale) private var locale + + @ViewBuilder + var body: some View { + switch status { + case .checkingAccount: + Label("Checking iCloud…", systemImage: "icloud") + case .paused: + Label("Paused", systemImage: "pause.circle") + case .unavailable(let message): + Label(message, systemImage: "icloud.slash").foregroundStyle(.secondary) + case .accountChangeConfirmationRequired: + Label("Waiting for confirmation", systemImage: "person.crop.circle.badge.exclamationmark") + .foregroundStyle(.orange) + case .syncing(let pendingCount): + Label( + pendingCount == 1 ? "Syncing 1 change…" : "Syncing \(pendingCount) changes…", + systemImage: "arrow.triangle.2.circlepath.icloud" + ) + case .upToDate(let lastSync): + if let lastSync { + let formatted = lastSync.formatted( + Date.FormatStyle(date: .abbreviated, time: .shortened).locale(locale) + ) + Label( + "Up to date · \(formatted)", + systemImage: "checkmark.icloud" + ) + } else { + Label("Up to date", systemImage: "checkmark.icloud") + } + case .failed(let message): + Label(message, systemImage: "exclamationmark.icloud").foregroundStyle(.red) + } + } +} diff --git a/Termind/Features/Settings/TerminalCustomConfigurationEditorSheet.swift b/Termind/Features/Settings/TerminalCustomConfigurationEditorSheet.swift index a03ee2dd..12b480fa 100644 --- a/Termind/Features/Settings/TerminalCustomConfigurationEditorSheet.swift +++ b/Termind/Features/Settings/TerminalCustomConfigurationEditorSheet.swift @@ -14,6 +14,13 @@ nonisolated enum TerminalCustomConfigurationScheme: String, Identifiable, Hashab } } + var preferenceKeyPath: WritableKeyPath { + switch self { + case .light: \.lightCustomConfiguration + case .dark: \.darkCustomConfiguration + } + } + var title: String { switch self { case .light: String(localized: "Light Custom Configuration") @@ -23,16 +30,19 @@ nonisolated enum TerminalCustomConfigurationScheme: String, Identifiable, Hashab } struct TerminalCustomConfigurationEditorPage: View { + @Environment(AppModel.self) private var app let scheme: TerminalCustomConfigurationScheme @State private var content = "" + @State private var lastSavedContent = "" + @State private var isLoaded = false @State private var isImporting = false @State private var importError: String? @State private var saveTask: Task? var body: some View { VStack(spacing: 0) { - if let importError { + if let importError = importError ?? app.terminalSettings.storageError { HStack(spacing: Metrics.Space.m) { Label(importError, systemImage: "exclamationmark.triangle.fill") .font(.caption) @@ -67,9 +77,17 @@ struct TerminalCustomConfigurationEditorPage: View { .padding(Metrics.Space.m) } .onAppear { - content = UserDefaults.standard.string(forKey: scheme.settingsKey) ?? "" + content = app.terminalSettings.preferences[keyPath: scheme.preferenceKeyPath] + lastSavedContent = content + isLoaded = true } .onChange(of: content) { _, newValue in scheduleSave(newValue) } + .onChange(of: app.terminalSettings.preferences[keyPath: scheme.preferenceKeyPath]) { _, value in + if content == lastSavedContent { + lastSavedContent = value + content = value + } + } .onDisappear { saveNow() } .dropDestination(for: URL.self) { urls, _ in guard let url = urls.first else { return false } @@ -92,17 +110,18 @@ struct TerminalCustomConfigurationEditorPage: View { private func scheduleSave(_ value: String) { saveTask?.cancel() - let key = scheme.settingsKey + guard isLoaded, value != lastSavedContent else { return } saveTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } - UserDefaults.standard.set(value, forKey: key) + if app.terminalSettings.set(value, for: scheme.preferenceKeyPath) { lastSavedContent = value } } } private func saveNow() { saveTask?.cancel() - UserDefaults.standard.set(content, forKey: scheme.settingsKey) + guard isLoaded, content != lastSavedContent else { return } + if app.terminalSettings.set(content, for: scheme.preferenceKeyPath) { lastSavedContent = content } } private func importFile(_ url: URL) { diff --git a/Termind/Features/Settings/WebSearchSettings.swift b/Termind/Features/Settings/WebSearchSettings.swift index a3cd218c..40f571af 100644 --- a/Termind/Features/Settings/WebSearchSettings.swift +++ b/Termind/Features/Settings/WebSearchSettings.swift @@ -1,16 +1,24 @@ import SwiftUI struct WebSearchSettings: View { - @AppStorage(SettingsKey.webSearchEnabled) private var isEnabled = - SettingsKey.defaultWebSearchEnabled - @AppStorage(SettingsKey.webSearchBuiltIn) private var usesBuiltIn = - SettingsKey.defaultWebSearchBuiltIn + @Environment(AppModel.self) private var app + private var isEnabled: Bool { app.aiConfiguration.webSearch.isEnabled } + private var usesBuiltIn: Bool { app.aiConfiguration.webSearch.prefersBuiltIn } + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { app.aiConfiguration.webSearch[keyPath: keyPath] }, + set: { value in + var preferences = app.aiConfiguration.webSearch + preferences[keyPath: keyPath] = value + app.aiConfiguration.setWebSearch(preferences) + }) + } var body: some View { Section { - Toggle("Enable Web Search", isOn: $isEnabled) + Toggle("Enable Web Search", isOn: binding(\.isEnabled)) - Toggle("Prefer Built-in Web Search", isOn: $usesBuiltIn) + Toggle("Prefer Built-in Web Search", isOn: binding(\.prefersBuiltIn)) .disabled(!isEnabled) Text( diff --git a/Termind/Infrastructure/Cloud/SettingsSyncCoordinator.swift b/Termind/Infrastructure/Cloud/SettingsSyncCoordinator.swift new file mode 100644 index 00000000..b1ba472e --- /dev/null +++ b/Termind/Infrastructure/Cloud/SettingsSyncCoordinator.swift @@ -0,0 +1,603 @@ +import CloudKit +import CryptoKit +import Foundation +import SwiftData + +/// Settings use their own zone, account binding, and upload queue. +@MainActor +@Observable +final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sendable { + nonisolated static let containerIdentifier = "iCloud.com.akinokaede.termind" + // Keep the initial AI-sync identifiers so existing replicas remain reachable. + nonisolated static let zoneName = "TermindAIConfigurationV1" + nonisolated static let recordType = "AIConfigurationItem" + nonisolated static let subscriptionID = "termind-ai-configuration-v1-changes" + nonisolated static let inlinePayloadLimit = 700_000 + + private let context: ModelContext + private let persistence: SettingsSyncPersistence + private let secrets: AISecretStore + private var isReadyToSend = false + private let journal: SettingsSyncJournal + private let container: CKContainer + private let defaults: UserDefaults + private let environmentIdentifier: String + private let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) + private var engine: CKSyncEngine? + private var operation: Task? + private var operationID = UUID() + private var isStarted = false + private var terminalError: String? + + private(set) var status: VaultSyncStatus = .checkingAccount + var didApplyRemote: ((SettingsSyncSnapshot, Set) -> Void)? + + var isEnabled: Bool { + guard + defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool + ?? SettingsKey.defaultICloudSyncEnabled + else { return false } + return defaults.object(forKey: SettingsKey.settingsSyncEnabled) as? Bool + ?? SettingsKey.defaultSettingsSyncEnabled + } + + init( + persistence: SettingsSyncPersistence, + secrets: AISecretStore, + container: CKContainer = CKContainer(identifier: SettingsSyncCoordinator.containerIdentifier), + defaults: UserDefaults = .standard, + environmentIdentifier: String = SettingsSyncCoordinator.runtimeEnvironmentIdentifier + ) { + context = persistence.context + self.persistence = persistence + self.secrets = secrets + journal = persistence.journal + self.container = container + self.defaults = defaults + self.environmentIdentifier = environmentIdentifier + super.init() + persistence.didRecordChanges = { [weak self] mutations in self?.noteLocalChanges(mutations) } + } + + nonisolated static var runtimeEnvironmentIdentifier: String { + Bundle.main.object(forInfoDictionaryKey: "TermindCloudKitEnvironment") as? String + ?? "Unknown" + } + + func start() { + guard isEnabled else { + pause() + return + } + guard !isStarted else { return } + isStarted = true + run { [weak self] in try await self?.prepareAndSynchronize() } + } + + func setEnabled(_ enabled: Bool) { + defaults.set(enabled, forKey: SettingsKey.settingsSyncEnabled) + if enabled { start() } else { pause() } + } + + func pause() { + isReadyToSend = false + secrets.uploadsAuthorized = false + operationID = UUID() + operation?.cancel() + operation = nil + isStarted = false + let engine = self.engine + self.engine = nil + status = .paused + if let engine { Task { await engine.cancelOperations() } } + } + + func retry() { + guard isEnabled else { return } + terminalError = nil + if engine == nil { + isStarted = false + start() + } else { + run { [weak self] in try await self?.synchronizeNow() } + } + } + + func noteLocalChanges(_ mutations: SettingsMutationSet) { + guard !mutations.isEmpty else { return } + addPendingChanges(mutations.keys) + guard isEnabled, engine != nil else { return } + run { [weak self] in try await self?.sendPendingChanges() } + } + + func confirmAccountSwitchMerge() { + guard isEnabled else { return } + do { + let state = try journal.state() + guard let pendingUser = state.pendingUserRecordName else { return } + try journal.markAllLiveRecordsNeedsUploadForNewAccount() + state.boundUserRecordName = pendingUser + state.pendingUserRecordName = nil + try context.save() + engine = nil + isStarted = false + start() + } catch { + context.rollback() + status = .failed(error.localizedDescription) + } + } + + private func run(_ body: @escaping @MainActor () async throws -> Void) { + guard operation == nil else { return } + let id = UUID() + operationID = id + operation = Task { [weak self] in + defer { if self?.operationID == id { self?.operation = nil } } + do { try await body() } catch is CancellationError {} catch { + if self?.operationID == id { self?.status = Self.status(for: error) } + } + } + } + + private func prepareAndSynchronize() async throws { + let id = operationID + status = .checkingAccount + let accountStatus = try await container.accountStatus() + guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return } + guard accountStatus == .available else { + status = .unavailable(Self.accountStatusDescription(accountStatus)) + isStarted = false + return + } + + let userRecordID = try await container.userRecordID() + guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return } + let currentUser = userRecordID.recordName + let state = try journal.state() + if let boundUser = state.boundUserRecordName, boundUser != currentUser { + state.pendingUserRecordName = currentUser + try context.save() + status = .accountChangeConfirmationRequired + isStarted = false + return + } + state.boundUserRecordName = currentUser + state.pendingUserRecordName = nil + + try prepareForCurrentEnvironment() + + let serialization = state.engineStateData.flatMap { + try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: $0) + } + var configuration = CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: serialization, + delegate: self + ) + configuration.automaticallySync = true + configuration.subscriptionID = Self.subscriptionID + let engine = CKSyncEngine(configuration) + self.engine = engine + + let pending = try journal.pendingEnvelopes() + addPendingChanges(Set(pending.map(\.key))) + try context.save() + + let zone = CKRecordZone(zoneID: zoneID) + _ = try await container.privateCloudDatabase.modifyRecordZones( + saving: [zone], deleting: [] + ) + try await engine.fetchChanges(.init(scope: .zoneIDs([zoneID]))) + guard self.engine === engine, isEnabled, !Task.isCancelled else { return } + secrets.uploadsAuthorized = true + isReadyToSend = true + try await sendPendingChanges() + try updateUpToDateStatus() + } + + private func synchronizeNow() async throws { + guard let engine else { return } + status = .syncing(pendingCount: try journal.pendingEnvelopes().count) + try await engine.fetchChanges(.init(scope: .zoneIDs([zoneID]))) + guard self.engine === engine, isEnabled, !Task.isCancelled else { return } + secrets.uploadsAuthorized = true + isReadyToSend = true + try await sendPendingChanges() + try updateUpToDateStatus() + } + + private func sendPendingChanges() async throws { + guard let engine, isEnabled, isReadyToSend else { return } + try migrateSecrets() + let pending = try journal.pendingEnvelopes() + guard !pending.isEmpty else { return } + addPendingChanges(Set(pending.map(\.key))) + status = .syncing(pendingCount: pending.count) + try await engine.sendChanges() + } + + private func addPendingChanges(_ keys: Set) { + guard let engine else { return } + engine.state.add( + pendingRecordZoneChanges: keys.map { + .saveRecord(CKRecord.ID(recordName: $0.recordName, zoneID: zoneID)) + }) + } + + private func updateUpToDateStatus() throws { + if let quarantineReason = try journal.firstQuarantineReason() { + status = .failed(quarantineReason) + return + } + if let terminalError { + status = .failed(terminalError) + return + } + let pendingCount = try journal.pendingEnvelopes().count + let state = try journal.state() + if pendingCount == 0 { + state.lastSuccessfulSync = .now + try context.save() + status = .upToDate(lastSuccessfulSync: state.lastSuccessfulSync) + } else { + status = .syncing(pendingCount: pendingCount) + } + } + + private func migrateSecrets() throws { + let config = try persistence.load().configuration + try secrets.migrate(Set(config.providers.map(\.credentialRef) + config.mcpServers.map(\.credentialRef))) + } + + private func applyRemote(_ envelopes: [SettingsEnvelope]) throws + -> ( + snapshot: SettingsSyncSnapshot, acceptedKeys: Set, + needsUpload: Set + ) + { + let result = try persistence.applyRemote(envelopes) + let references = envelopes.filter { result.acceptedKeys.contains($0.key) }.compactMap { envelope -> String? in + switch envelope.payload { + case .provider(let value): value.credentialRef + case .mcpServer(let value): value.credentialRef + default: nil + } + } + try secrets.acceptRemoteReferences(Set(references)) + return result + } + + nonisolated func nextFetchChangesOptions( + _ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine + ) async -> CKSyncEngine.FetchChangesOptions { + .init(scope: .zoneIDs([CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName)])) + } + + nonisolated func handleEvent( + _ event: CKSyncEngine.Event, syncEngine: CKSyncEngine + ) async { + await handle(event, engine: syncEngine) + } + + nonisolated func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + await makeChangeBatch(context, engine: syncEngine) + } + + @MainActor + private func makeChangeBatch( + _ context: CKSyncEngine.SendChangesContext, engine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + guard self.engine === engine, isReadyToSend, isEnabled else { return nil } + do { try migrateSecrets() } catch { + status = .failed(error.localizedDescription) + return nil + } + let pending = engine.state.pendingRecordZoneChanges.filter { + context.options.scope.contains($0) + } + return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { + [weak self] recordID in + guard let self else { return nil } + return await self.recordToSave(recordID) + } + } + + @MainActor + private func recordToSave(_ recordID: CKRecord.ID) -> CKRecord? { + guard recordID.zoneID == zoneID, let key = SettingsRecordKey(recordName: recordID.recordName), + let outgoing = try? journal.outgoingEntry(for: key) + else { return nil } + do { return try Self.makeRecord(outgoing, recordID: recordID) } catch { + terminalError = error.localizedDescription + status = .failed(error.localizedDescription) + return nil + } + } + + @MainActor + private func handle(_ event: CKSyncEngine.Event, engine: CKSyncEngine) async { + guard self.engine === engine, isEnabled else { return } + do { + switch event { + case .stateUpdate(let update): + let state = try journal.state() + state.engineStateData = try JSONEncoder().encode(update.stateSerialization) + try context.save() + + case .accountChange(let change): + try handleAccountChange(change) + + case .fetchedRecordZoneChanges(let changes): + try handleFetchedChanges(changes, engine: engine) + + case .fetchedDatabaseChanges(let changes): + if changes.deletions.contains(where: { $0.zoneID == zoneID }) { + try handleZoneDeletion(engine: engine) + } + + case .sentRecordZoneChanges(let changes): + try handleSentChanges(changes, engine: engine) + + case .didFetchChanges, .didSendChanges: + try updateUpToDateStatus() + + default: + break + } + } catch { + context.rollback() + status = Self.status(for: error) + } + } + + private func handleAccountChange(_ change: CKSyncEngine.Event.AccountChange) throws { + let state = try journal.state() + switch change.changeType { + case .signIn(let currentUser): + if let bound = state.boundUserRecordName, bound != currentUser.recordName { + stopEngineForAccountChange() + state.pendingUserRecordName = currentUser.recordName + status = .accountChangeConfirmationRequired + } + case .signOut: + stopEngineForAccountChange() + status = .unavailable(String(localized: "Sign in to iCloud to sync settings.")) + case .switchAccounts(_, let currentUser): + stopEngineForAccountChange() + state.pendingUserRecordName = currentUser.recordName + status = .accountChangeConfirmationRequired + @unknown default: + stopEngineForAccountChange() + status = .unavailable(String(localized: "The iCloud account changed.")) + } + try context.save() + } + + private func stopEngineForAccountChange() { + isReadyToSend = false + secrets.uploadsAuthorized = false + let activeEngine = engine + engine = nil + isStarted = false + if let activeEngine { Task { await activeEngine.cancelOperations() } } + } + + private func handleFetchedChanges( + _ changes: CKSyncEngine.Event.FetchedRecordZoneChanges, engine: CKSyncEngine + ) throws { + var recordsByKey: [SettingsRecordKey: CKRecord] = [:] + var envelopes: [SettingsEnvelope] = [] + var quarantinedError: String? + for modification in changes.modifications { + let record = modification.record + guard record.recordType == Self.recordType, record.recordID.zoneID == zoneID else { + continue + } + do { + let envelope = try Self.envelope(from: record) + try journal.clearQuarantine(recordName: record.recordID.recordName) + recordsByKey[envelope.key] = record + envelopes.append(envelope) + } catch { + try journal.quarantine( + recordName: record.recordID.recordName, + payloadData: (try? Self.payloadData(from: record)) ?? Data(), + reason: error.localizedDescription + ) + quarantinedError = error.localizedDescription + } + } + + let result = try applyRemote(envelopes) + for (key, record) in recordsByKey { + try journal.updateSystemFields(Self.systemFields(of: record), for: key) + } + try journal.markNeedsUpload(result.needsUpload) + + // Physical deletes are not part of the protocol; recreate the local + let physicallyDeleted = Set( + changes.deletions.compactMap { + $0.recordID.zoneID == zoneID ? SettingsRecordKey(recordName: $0.recordID.recordName) : nil + } + ).filter { (try? journal.envelope(for: $0)) != nil } + try journal.markNeedsUpload(physicallyDeleted) + addPendingChanges(result.needsUpload.union(physicallyDeleted)) + try context.save() + if !result.acceptedKeys.isEmpty { + didApplyRemote?(result.snapshot, result.acceptedKeys) + } + if let quarantinedError { status = .failed(quarantinedError) } + } + + private func handleSentChanges( + _ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine + ) throws { + for record in changes.savedRecords where record.recordID.zoneID == zoneID { + guard let envelope = try? Self.envelope(from: record) else { continue } + try journal.markUploaded(envelope.key, version: envelope.version) + try journal.updateSystemFields(Self.systemFields(of: record), for: envelope.key) + try? FileManager.default.removeItem(at: Self.assetURL(for: envelope.key)) + } + + for failure in changes.failedRecordSaves where failure.record.recordID.zoneID == zoneID { + let error = failure.error + if error.code == .serverRecordChanged, let serverRecord = error.serverRecord, + let serverEnvelope = try? Self.envelope(from: serverRecord) + { + let localEnvelope = try journal.envelope(for: serverEnvelope.key) + let result = try applyRemote([serverEnvelope]) + try journal.updateSystemFields( + Self.systemFields(of: serverRecord), for: serverEnvelope.key + ) + if localEnvelope == serverEnvelope { + try journal.markUploaded( + serverEnvelope.key, version: serverEnvelope.version + ) + } else { + try journal.markNeedsUpload(result.needsUpload) + addPendingChanges(result.needsUpload) + } + try context.save() + if !result.acceptedKeys.isEmpty { + didApplyRemote?(result.snapshot, result.acceptedKeys) + } + } else if error.code == .zoneNotFound { + try handleZoneDeletion(engine: engine) + } else if !Self.isRetryable(error) { + terminalError = error.localizedDescription + status = .failed(error.localizedDescription) + } + } + try context.save() + } + + private func handleZoneDeletion(engine: CKSyncEngine) throws { + try journal.markAllEntriesNeedsUploadAfterZoneReset() + let zone = CKRecordZone(zoneID: zoneID) + engine.state.add(pendingDatabaseChanges: [.saveZone(zone)]) + addPendingChanges(Set((try journal.pendingEnvelopes()).map(\.key))) + try context.save() + } + + private func prepareForCurrentEnvironment() throws { + guard + defaults.string(forKey: SettingsKey.settingsSyncEnvironment) + != environmentIdentifier + else { return } + + try journal.markAllEntriesNeedsUploadAfterEnvironmentChange() + try context.save() + defaults.set(environmentIdentifier, forKey: SettingsKey.settingsSyncEnvironment) + terminalError = nil + } + + nonisolated static func makeRecord( + _ outgoing: SettingsOutgoingEntry, recordID: CKRecord.ID + ) throws -> CKRecord { + let envelope = outgoing.envelope + let record: CKRecord + if let data = outgoing.systemFieldsData, + let restored = try Self.record(fromSystemFields: data) + { + record = restored + } else { + record = CKRecord(recordType: recordType, recordID: recordID) + } + let payload = try SettingsSyncCodec.encode(envelope) + record["formatVersion"] = NSNumber(value: envelope.formatVersion) + record["kind"] = envelope.key.kind.rawValue as NSString + record["entityID"] = envelope.key.id as NSString + record["modifiedAt"] = envelope.version.modifiedAt as NSDate + record["counter"] = NSNumber(value: Int64(clamping: envelope.version.counter)) + record["writerID"] = envelope.version.writerID.uuidString.lowercased() as NSString + record["isDeleted"] = NSNumber(value: envelope.isDeleted) + record["payloadHash"] = SHA256.hash(data: payload).map({ String(format: "%02x", $0) }).joined() as NSString + if payload.count > inlinePayloadLimit { + let url = try assetURL(for: envelope.key) + try payload.write(to: url, options: .atomic) + record["payload"] = nil + record["payloadAsset"] = CKAsset(fileURL: url) + } else { + record["payload"] = payload as NSData + record["payloadAsset"] = nil + } + return record + } + + nonisolated static func envelope(from record: CKRecord) throws -> SettingsEnvelope { + let data = try payloadData(from: record) + let expectedHash = record["payloadHash"] as? String + guard expectedHash == SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() else { + throw SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + let envelope = try SettingsSyncCodec.decode(data) + guard envelope.key.recordName == record.recordID.recordName, envelope.isValid else { + throw SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + return envelope + } + + nonisolated static func payloadData(from record: CKRecord) throws -> Data { + if let inline = record["payload"] as? Data { return inline } + if let asset = record["payloadAsset"] as? CKAsset, let url = asset.fileURL { + return try Data(contentsOf: url) + } + throw SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + + nonisolated static func systemFields(of record: CKRecord) throws -> Data { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + record.encodeSystemFields(with: archiver) + archiver.finishEncoding() + return archiver.encodedData + } + + private nonisolated static func record(fromSystemFields data: Data) throws -> CKRecord? { + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = true + defer { unarchiver.finishDecoding() } + return CKRecord(coder: unarchiver) + } + + private nonisolated static func assetURL(for key: SettingsRecordKey) throws -> URL { + let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appending(path: "Termind/SettingsSyncAssets", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appending(path: key.recordName.replacingOccurrences(of: "/", with: "-")) + } + + private nonisolated static func isRetryable(_ error: CKError) -> Bool { + switch error.code { + case .networkFailure, .networkUnavailable, .serviceUnavailable, + .requestRateLimited, .zoneBusy, .resultsTruncated: + true + default: false + } + } + + private nonisolated static func status(for error: any Error) -> VaultSyncStatus { + if let cloudError = error as? CKError { + if cloudError.code == .notAuthenticated { + return .unavailable(String(localized: "Sign in to iCloud to sync settings.")) + } + if isRetryable(cloudError) { return .syncing(pendingCount: 0) } + } + return .failed(error.localizedDescription) + } + + private nonisolated static func accountStatusDescription( + _ status: CKAccountStatus + ) -> String { + switch status { + case .noAccount: String(localized: "Sign in to iCloud to sync settings.") + case .restricted: String(localized: "iCloud access is restricted on this Mac.") + case .couldNotDetermine: String(localized: "The iCloud account could not be checked.") + case .temporarilyUnavailable: String(localized: "iCloud is temporarily unavailable.") + case .available: "" + @unknown default: String(localized: "iCloud is unavailable.") + } + } +} diff --git a/Termind/Infrastructure/Cloud/SettingsSyncJournal.swift b/Termind/Infrastructure/Cloud/SettingsSyncJournal.swift new file mode 100644 index 00000000..27c4e63d --- /dev/null +++ b/Termind/Infrastructure/Cloud/SettingsSyncJournal.swift @@ -0,0 +1,393 @@ +import Foundation +import SwiftData + +@Model +final class PersistentAIConfigurationSyncEntry { + @Attribute(.unique) var recordName = "" + var kind = SettingsRecordKind.provider.rawValue + var entityID = "" + var envelopeData = Data() + var systemFieldsData: Data? + var needsUpload = false + + init(envelope: SettingsEnvelope, needsUpload: Bool) throws { + try apply(envelope, needsUpload: needsUpload) + } + + func apply(_ envelope: SettingsEnvelope, needsUpload: Bool) throws { + recordName = envelope.key.recordName + kind = envelope.key.kind.rawValue + entityID = envelope.key.id + envelopeData = try SettingsSyncCodec.encode(envelope) + self.needsUpload = needsUpload + } + + var envelope: SettingsEnvelope? { try? SettingsSyncCodec.decode(envelopeData) } +} + +@Model +final class PersistentAIConfigurationSyncState { + @Attribute(.unique) var key = "ai-configuration-sync" + var installationID = UUID() + var engineStateData: Data? + var boundUserRecordName: String? + var pendingUserRecordName: String? + var bootstrapCompleted = false + var lastSuccessfulSync: Date? + + init() {} +} + +@Model +final class PersistentAIConfigurationSyncQuarantine { + @Attribute(.unique) var recordName = "" + var payloadData = Data() + var reason = "" + var receivedAt = Date() + + init(recordName: String, payloadData: Data, reason: String) { + self.recordName = recordName + self.payloadData = payloadData + self.reason = reason + } +} + +nonisolated enum SettingsSyncPersistenceError: LocalizedError { + case invalidEnvelope(String) + case unsupportedFormat(Int) + + var errorDescription: String? { + switch self { + case .invalidEnvelope(let recordName): + String(localized: "The iCloud settings record \(recordName) is invalid.") + case .unsupportedFormat(let version): + String(localized: "This settings record requires sync format \(version).") + } + } +} + +nonisolated enum SettingsSyncCodec { + static func encode(_ envelope: SettingsEnvelope) throws -> Data { + guard envelope.isValid else { + throw SettingsSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(envelope) + } + + static func decode(_ data: Data) throws -> SettingsEnvelope { + try JSONDecoder().decode(SettingsEnvelope.self, from: data) + } +} + +nonisolated struct SettingsRemoteMerge: Sendable { + var accepted: [SettingsEnvelope] = [] + var needsUpload: Set = [] +} + +nonisolated struct SettingsOutgoingEntry: Sendable { + var envelope: SettingsEnvelope + var systemFieldsData: Data? +} + +@MainActor +final class SettingsSyncJournal { + private let context: ModelContext + + init(context: ModelContext) { self.context = context } + + func state() throws -> PersistentAIConfigurationSyncState { + var descriptor = FetchDescriptor() + descriptor.fetchLimit = 1 + if let existing = try context.fetch(descriptor).first { return existing } + let state = PersistentAIConfigurationSyncState() + context.insert(state) + return state + } + + @discardableResult + func bootstrapIfNeeded(_ snapshot: SettingsSyncSnapshot) throws -> SettingsMutationSet { + let state = try state() + guard !state.bootstrapCompleted else { return .empty } + let existing = try entriesByName() + var changed = Set() + for payload in snapshot.recordPayloads.values { + let key = payload.key + guard existing[key.recordName] == nil else { continue } + let envelope = SettingsEnvelope( + key: key, version: .baseline(writerID: state.installationID), payload: payload + ) + context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: true)) + changed.insert(key) + } + state.bootstrapCompleted = true + return SettingsMutationSet(keys: changed) + } + + func recordLocalChanges( + from oldSnapshot: SettingsSyncSnapshot, to newSnapshot: SettingsSyncSnapshot, + now: Date = .now + ) throws -> SettingsMutationSet { + let state = try state() + let old = oldSnapshot.recordPayloads + let new = newSnapshot.recordPayloads + var entries = try entriesByName() + var changed = Set() + + for (key, payload) in new where old[key] != payload { + let row = entries[key.recordName] + let envelope = SettingsEnvelope( + key: key, + version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), + payload: payload + ) + try store(envelope, needsUpload: true, in: &entries) + changed.insert(key) + } + + for key in old.keys where new[key] == nil { + let row = entries[key.recordName] + let envelope = SettingsEnvelope( + key: key, + version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), + isDeleted: true + ) + try store(envelope, needsUpload: true, in: &entries) + changed.insert(key) + } + return SettingsMutationSet(keys: changed) + } + + func pendingEnvelopes() throws -> [SettingsEnvelope] { + try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.needsUpload } + ) + ).compactMap(\.envelope) + } + + func outgoingEntry(for key: SettingsRecordKey) throws -> SettingsOutgoingEntry? { + guard let row = try entry(recordName: key.recordName), let envelope = row.envelope else { + return nil + } + return SettingsOutgoingEntry(envelope: envelope, systemFieldsData: row.systemFieldsData) + } + + func envelope(for key: SettingsRecordKey) throws -> SettingsEnvelope? { + try entry(recordName: key.recordName)?.envelope + } + + func mergeDecision(for remote: [SettingsEnvelope]) throws -> SettingsRemoteMerge { + var result = SettingsRemoteMerge() + let entries = try entriesByName() + // A fetch batch can contain several versions of one record. Compare the + // newest candidate once, independently of delivery order. + var candidates: [SettingsRecordKey: SettingsEnvelope] = [:] + for envelope in remote { + guard envelope.isValid else { + throw SettingsSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + if let previous = candidates[envelope.key] { + if envelope.version < previous.version { continue } + let previousData = try SettingsSyncCodec.encode(previous) + let incomingData = try SettingsSyncCodec.encode(envelope) + if envelope.version == previous.version, !previousData.lexicographicallyPrecedes(incomingData) { + continue + } + } + candidates[envelope.key] = envelope + } + for envelope in candidates.values { + guard envelope.formatVersion <= SettingsEnvelope.currentFormatVersion else { + throw SettingsSyncPersistenceError.unsupportedFormat(envelope.formatVersion) + } + guard envelope.isValid else { + throw SettingsSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + guard let local = entries[envelope.key.recordName]?.envelope else { + result.accepted.append(envelope) + continue + } + if local.version.modifiedAt == VaultVersion.baselineDate + && !local.isDeleted && local != envelope + { + result.accepted.append(envelope) + } else if local.version < envelope.version { + result.accepted.append(envelope) + } else if envelope.version < local.version { + result.needsUpload.insert(local.key) + } else if local != envelope { + let localData = try SettingsSyncCodec.encode(local) + let remoteData = try SettingsSyncCodec.encode(envelope) + if localData.lexicographicallyPrecedes(remoteData) { + result.accepted.append(envelope) + } else { + result.needsUpload.insert(local.key) + } + } + } + return result + } + + func acceptRemote(_ envelopes: [SettingsEnvelope]) throws { + var entries = try entriesByName() + for envelope in envelopes { + try store(envelope, needsUpload: false, in: &entries) + } + } + + func markNeedsUpload(_ keys: Set) throws { + let entries = try entriesByName() + for key in keys { entries[key.recordName]?.needsUpload = true } + } + + func markUploaded(_ key: SettingsRecordKey, version: VaultVersion) throws { + guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else { + return + } + row.needsUpload = false + } + + func updateSystemFields(_ data: Data?, for key: SettingsRecordKey) throws { + try entry(recordName: key.recordName)?.systemFieldsData = data + } + + func quarantine(recordName: String, payloadData: Data, reason: String) throws { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.recordName == recordName } + ) + descriptor.fetchLimit = 1 + if let row = try context.fetch(descriptor).first { + row.payloadData = payloadData + row.reason = reason + row.receivedAt = .now + } else { + context.insert( + PersistentAIConfigurationSyncQuarantine( + recordName: recordName, payloadData: payloadData, reason: reason + )) + } + } + + func clearQuarantine(recordName: String) throws { + try context.delete( + model: PersistentAIConfigurationSyncQuarantine.self, + where: #Predicate { $0.recordName == recordName } + ) + } + + func firstQuarantineReason() throws -> String? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first?.reason + } + + func recordCurrentSnapshot(_ snapshot: SettingsSyncSnapshot, now: Date = .now) throws + -> SettingsMutationSet + { + let state = try state() + let current = snapshot.recordPayloads + var entries = try entriesByName() + var changed = Set() + + for (key, payload) in current { + let row = entries[key.recordName] + guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else { + continue + } + let envelope = SettingsEnvelope( + key: key, + version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), + payload: payload + ) + try store(envelope, needsUpload: true, in: &entries) + changed.insert(key) + } + + for row in entries.values { + guard let envelope = row.envelope, !envelope.isDeleted, + current[envelope.key] == nil + else { continue } + let tombstone = SettingsEnvelope( + key: envelope.key, + version: .next(after: envelope.version, writerID: state.installationID, now: now), + isDeleted: true + ) + try row.apply(tombstone, needsUpload: true) + changed.insert(envelope.key) + } + return SettingsMutationSet(keys: changed) + } + + func markAllLiveRecordsNeedsUploadForNewAccount() throws { + for row in try context.fetch(FetchDescriptor()) { + guard let envelope = row.envelope else { continue } + if envelope.isDeleted { + context.delete(row) + } else { + row.needsUpload = true + row.systemFieldsData = nil + } + } + let state = try state() + state.engineStateData = nil + state.lastSuccessfulSync = nil + } + + func markAllEntriesNeedsUploadAfterZoneReset() throws { + for row in try context.fetch(FetchDescriptor()) { + row.needsUpload = true + row.systemFieldsData = nil + } + let state = try state() + state.engineStateData = nil + state.lastSuccessfulSync = nil + } + + func markAllEntriesNeedsUploadAfterEnvironmentChange() throws { + try markAllEntriesNeedsUploadAfterZoneReset() + } + + private func entry(recordName: String) throws -> PersistentAIConfigurationSyncEntry? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.recordName == recordName } + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + private func store( + _ envelope: SettingsEnvelope, needsUpload: Bool, + in entries: inout [String: PersistentAIConfigurationSyncEntry] + ) throws { + if let row = entries[envelope.key.recordName] { + try row.apply(envelope, needsUpload: needsUpload) + } else { + let row = try PersistentAIConfigurationSyncEntry( + envelope: envelope, needsUpload: needsUpload + ) + context.insert(row) + entries[envelope.key.recordName] = row + } + } + + private func entriesByName() throws -> [String: PersistentAIConfigurationSyncEntry] { + Dictionary( + try context.fetch(FetchDescriptor()).map { + ($0.recordName, $0) + }, + uniquingKeysWith: { first, duplicate in + if let firstVersion = first.envelope?.version, + let duplicateVersion = duplicate.envelope?.version, + firstVersion < duplicateVersion + { + return duplicate + } + return first + } + ) + } +} diff --git a/Termind/Infrastructure/Cloud/SettingsSyncPersistence.swift b/Termind/Infrastructure/Cloud/SettingsSyncPersistence.swift new file mode 100644 index 00000000..c5c7a5ab --- /dev/null +++ b/Termind/Infrastructure/Cloud/SettingsSyncPersistence.swift @@ -0,0 +1,284 @@ +import AgentKit +import Foundation +import SwiftData + +nonisolated struct SettingsSyncSnapshot: Sendable { + var configuration: AIConfigurationSnapshot + var skills: [AgentSkill] + var webSearch: AIWebSearchPreferences + var terminal = TerminalPreferences() + + var recordPayloads: [SettingsRecordKey: SettingsRecordPayload] { + var payloads: [SettingsRecordPayload] = [] + for (index, provider) in configuration.providers.enumerated() { + payloads.append( + .provider( + SyncedModelProvider( + provider, + endpoint: configuration.endpoints[provider.id] ?? ProviderEndpoint(url: provider.inferenceURL), + sortIndex: index))) + } + for (index, server) in configuration.mcpServers.enumerated() { + payloads.append(.mcpServer(SyncedMCPServer(server, sortIndex: index))) + } + for (index, skill) in skills.enumerated() { payloads.append(.skill(skill, sortIndex: index)) } + for role in AIRole.allCases { payloads.append(.role(role, configuration.roles[role])) } + payloads.append(.selection(configuration.lastSelectedModel)) + payloads.append(.webSearch(webSearch)) + payloads.append(.terminal(terminal)) + return Dictionary(uniqueKeysWithValues: payloads.map { ($0.key, $0) }) + } +} + +@Model +final class PersistentAIWebSearchPreferences { + @Attribute(.unique) var key = "default" + var isEnabled = true + var prefersBuiltIn = true + + init(_ value: AIWebSearchPreferences) { apply(value) } + var value: AIWebSearchPreferences { + AIWebSearchPreferences(isEnabled: isEnabled, prefersBuiltIn: prefersBuiltIn) + } + func apply(_ value: AIWebSearchPreferences) { + isEnabled = value.isEnabled + prefersBuiltIn = value.prefersBuiltIn + } +} + +/// Configuration and upload intent commit together; remote updates skip the local write path. +@MainActor +final class SettingsSyncPersistence { + let context: ModelContext + private let defaults: UserDefaults + private let ai: AIPersistence + private let skills: SkillPersistence + let journal: SettingsSyncJournal + var didRecordChanges: ((SettingsMutationSet) -> Void)? + + init(context: ModelContext, defaults: UserDefaults = .standard) { + self.context = context + self.defaults = defaults + ai = AIPersistence(context: context) + skills = SkillPersistence(context: context) + journal = SettingsSyncJournal(context: context) + } + + func prepare() throws { + let seeded = defaults.integer(forKey: SettingsKey.seededSkillGeneration) + var mutations = SettingsMutationSet.empty + try context.commit { + if try !journal.state().bootstrapCompleted { + let installed = try skills.load() + let normalized = Self.normalizeBuiltIns(installed, seededGeneration: seeded) + try skills.replace(normalized.skills) + let snapshot = try load() + try journal.bootstrapIfNeeded(snapshot) + let writer = try journal.state().installationID + for id in normalized.deleted { + let key = SettingsRecordKey(kind: .skill, id: id.uuidString.lowercased()) + let envelope = SettingsEnvelope( + key: key, version: .baseline(writerID: writer), isDeleted: true) + context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: true)) + } + } else if seeded < AgentSkillLibrary.currentGeneration { + let previous = try load() + var installed = previous.skills + for skill in AgentSkillLibrary.builtIns(after: seeded) { + let key = SettingsRecordKey(kind: .skill, id: skill.id.uuidString.lowercased()) + if try journal.envelope(for: key) == nil { installed.append(skill) } + } + try skills.replace(installed) + mutations = try journal.recordLocalChanges(from: previous, to: load()) + } + // V14 adds Terminal preferences to stores whose V13 bootstrap is already complete. + let terminal = SettingsRecordPayload.terminal(try terminalPreferences().load()) + if try journal.envelope(for: terminal.key) == nil { + let version = VaultVersion.baseline(writerID: try journal.state().installationID) + let envelope = SettingsEnvelope(key: terminal.key, version: version, payload: terminal) + context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: true)) + mutations.keys.insert(terminal.key) + } + } + defaults.set(AgentSkillLibrary.currentGeneration, forKey: SettingsKey.seededSkillGeneration) + let snapshot = try load() + mirror(snapshot.webSearch) + snapshot.terminal.mirror(to: defaults) + didRecordChanges?(mutations) + } + + static func normalizeBuiltIns(_ installed: [AgentSkill], seededGeneration: Int) + -> (skills: [AgentSkill], deleted: Set) + { + var result = installed + var deleted = Set() + let newIDs = Set(AgentSkillLibrary.builtIns(after: seededGeneration).map(\.id)) + for builtIn in AgentSkillLibrary.builtIns(after: 0) { + let matching = result.indices.filter { + result[$0].id == builtIn.id + || (result[$0].name == builtIn.name && result[$0].title == builtIn.title + && result[$0].summary == builtIn.summary && result[$0].body == builtIn.body) + } + if let first = matching.first { + result[first].id = builtIn.id + for index in matching.dropFirst().reversed() { result.remove(at: index) } + } else if newIDs.contains(builtIn.id) { + result.append(builtIn) + } else { + deleted.insert(builtIn.id) + } + } + return (result, deleted) + } + + func load() throws -> SettingsSyncSnapshot { + SettingsSyncSnapshot( + configuration: try ai.load(preservingUnresolvedReferences: true), + skills: try skills.load(), webSearch: try preferences().value, terminal: try terminalPreferences().load()) + } + + func saveConfiguration(_ configuration: AIConfigurationSnapshot) throws { + try saveLocal { try ai.replace(configuration) } + } + + func saveSkills(_ values: [AgentSkill]) throws { + try saveLocal { try skills.replace(values) } + } + + func saveWebSearch(_ value: AIWebSearchPreferences) throws { + try saveLocal { try preferences().apply(value) } + mirror(value) + } + + private func saveLocal(_ change: () throws -> Void) throws { + var mutations = SettingsMutationSet.empty + try context.commit { + let previous = try load() + try journal.bootstrapIfNeeded(previous) + try change() + mutations = try journal.recordLocalChanges(from: previous, to: load()) + } + didRecordChanges?(mutations) + } + + func applyRemote(_ envelopes: [SettingsEnvelope]) throws + -> ( + snapshot: SettingsSyncSnapshot, acceptedKeys: Set, + needsUpload: Set + ) + { + var merge = SettingsRemoteMerge() + var snapshot = try load() + try context.commit { + merge = try journal.mergeDecision(for: envelopes) + guard !merge.accepted.isEmpty else { + try journal.markNeedsUpload(merge.needsUpload) + return + } + try journal.acceptRemote(merge.accepted) + // Rebuild from the complete journal, so roles arriving before providers survive. + snapshot = try materialize() + try ai.replace(snapshot.configuration) + try skills.replace(snapshot.skills) + try preferences().apply(snapshot.webSearch) + try terminalPreferences().apply(snapshot.terminal) + try journal.markNeedsUpload(merge.needsUpload) + } + mirror(snapshot.webSearch) + snapshot.terminal.mirror(to: defaults) + return (snapshot, Set(merge.accepted.map(\.key)), merge.needsUpload) + } + + private func materialize() throws -> SettingsSyncSnapshot { + let envelopes = try context.fetch(FetchDescriptor()).compactMap(\.envelope) + var providers: [SyncedModelProvider] = [] + var servers: [SyncedMCPServer] = [] + var skillRows: [(AgentSkill, Int)] = [] + var configuration = AIConfigurationSnapshot(providers: [], roles: [:]) + var webSearch = try preferences().value + var terminal = try terminalPreferences().load() + for envelope in envelopes where !envelope.isDeleted { + switch envelope.payload { + case .provider(let value): providers.append(value) + case .mcpServer(let value): servers.append(value) + case .skill(let value, let index): skillRows.append((value, index)) + case .role(let role, let ref): configuration.roles[role] = ref + case .selection(let ref): configuration.lastSelectedModel = ref + case .webSearch(let value): webSearch = value + case .terminal(let value): terminal = value + case nil: break + } + } + providers.sort { ($0.sortIndex, $0.id.uuidString) < ($1.sortIndex, $1.id.uuidString) } + servers.sort { ($0.sortIndex, $0.id.uuidString) < ($1.sortIndex, $1.id.uuidString) } + skillRows.sort { ($0.1, $0.0.id.uuidString) < ($1.1, $1.0.id.uuidString) } + configuration.providers = providers.map(\.value) + configuration.endpoints = Dictionary(uniqueKeysWithValues: providers.map { ($0.id, $0.endpoint) }) + configuration.mcpServers = servers.map(\.value) + let deletedProviders = Set(envelopes.filter { $0.isDeleted && $0.key.kind == .provider }.map(\.key.id)) + func wasRemoved(_ ref: AIModelRef) -> Bool { + if deletedProviders.contains(ref.modelProviderID.uuidString.lowercased()) { return true } + guard let provider = configuration.providers.first(where: { $0.id == ref.modelProviderID }) else { + return false + } + return provider.model(ref.modelID) == nil + } + configuration.roles = configuration.roles.filter { !wasRemoved($0.value) } + if let ref = configuration.lastSelectedModel, wasRemoved(ref) { configuration.lastSelectedModel = nil } + return SettingsSyncSnapshot( + configuration: configuration, skills: skillRows.map(\.0), webSearch: webSearch, terminal: terminal) + } + + private func terminalPreferences() throws -> PersistentTerminalPreferences { + if let row = try context.fetch(FetchDescriptor()).first { return row } + let row = try PersistentTerminalPreferences(TerminalPreferences(defaults: defaults)) + context.insert(row) + return row + } + + func loadTerminal() throws -> TerminalPreferences { try terminalPreferences().load() } + + func saveTerminal(_ value: TerminalPreferences) throws { + try saveLocal { try terminalPreferences().apply(value) } + value.mirror(to: defaults) + } + + private func preferences() throws -> PersistentAIWebSearchPreferences { + if let row = try context.fetch(FetchDescriptor()).first { return row } + let row = PersistentAIWebSearchPreferences( + AIWebSearchPreferences( + isEnabled: defaults.object(forKey: SettingsKey.webSearchEnabled) as? Bool + ?? SettingsKey.defaultWebSearchEnabled, + prefersBuiltIn: defaults.object(forKey: SettingsKey.webSearchBuiltIn) as? Bool + ?? SettingsKey.defaultWebSearchBuiltIn)) + context.insert(row) + return row + } + + private func mirror(_ value: AIWebSearchPreferences) { + defaults.set(value.isEnabled, forKey: SettingsKey.webSearchEnabled) + defaults.set(value.prefersBuiltIn, forKey: SettingsKey.webSearchBuiltIn) + } +} + +@MainActor +final class SyncedAIConfigurationPersistence: AIConfigurationPersisting { + private let store: SettingsSyncPersistence + init(_ store: SettingsSyncPersistence) { self.store = store } + func load() throws -> AIConfigurationSnapshot { try store.load().configuration } + func save(_ snapshot: AIConfigurationSnapshot) throws { try store.saveConfiguration(snapshot) } +} + +@MainActor +final class SyncedSkillLibraryPersistence: SkillLibraryPersisting { + private let store: SettingsSyncPersistence + init(_ store: SettingsSyncPersistence) { self.store = store } + func load() throws -> [AgentSkill] { try store.load().skills } + func save(_ skills: [AgentSkill]) throws { try store.saveSkills(skills) } +} + +extension SettingsSyncPersistence: AIWebSearchPersisting { + func loadWebSearch() throws -> AIWebSearchPreferences { try load().webSearch } +} + +extension SettingsSyncPersistence: TerminalPreferencesPersisting {} diff --git a/Termind/Infrastructure/Cloud/VaultSyncCoordinator.swift b/Termind/Infrastructure/Cloud/VaultSyncCoordinator.swift index 93dd89a2..6e0e9b72 100644 --- a/Termind/Infrastructure/Cloud/VaultSyncCoordinator.swift +++ b/Termind/Infrastructure/Cloud/VaultSyncCoordinator.swift @@ -21,6 +21,8 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen private let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) private var engine: CKSyncEngine? private var operation: Task? + private var operationID = UUID() + private var isReadyToSend = false private var isStarted = false private var terminalError: String? @@ -28,7 +30,11 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen var didApplyRemote: ((VaultSnapshot, Set) -> Void)? var isEnabled: Bool { - UserDefaults.standard.object(forKey: SettingsKey.vaultSyncEnabled) as? Bool + guard + defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool + ?? SettingsKey.defaultICloudSyncEnabled + else { return false } + return defaults.object(forKey: SettingsKey.vaultSyncEnabled) as? Bool ?? SettingsKey.defaultVaultSyncEnabled } @@ -53,20 +59,23 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen } func start() { - guard isEnabled, !isStarted else { - if !isEnabled { status = .paused } + guard isEnabled else { + pause() return } + guard !isStarted else { return } isStarted = true run { [weak self] in try await self?.prepareAndSynchronize() } } func setEnabled(_ enabled: Bool) { - UserDefaults.standard.set(enabled, forKey: SettingsKey.vaultSyncEnabled) + defaults.set(enabled, forKey: SettingsKey.vaultSyncEnabled) if enabled { start() } else { pause() } } func pause() { + operationID = UUID() + isReadyToSend = false operation?.cancel() operation = nil isStarted = false @@ -114,15 +123,21 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen private func run(_ body: @escaping @MainActor () async throws -> Void) { guard operation == nil else { return } + let id = UUID() + operationID = id operation = Task { [weak self] in - defer { self?.operation = nil } - do { try await body() } catch is CancellationError {} catch { self?.status = Self.status(for: error) } + defer { if self?.operationID == id { self?.operation = nil } } + do { try await body() } catch is CancellationError {} catch { + if self?.operationID == id { self?.status = Self.status(for: error) } + } } } private func prepareAndSynchronize() async throws { + let id = operationID status = .checkingAccount let accountStatus = try await container.accountStatus() + guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return } guard accountStatus == .available else { status = .unavailable(Self.accountStatusDescription(accountStatus)) isStarted = false @@ -130,6 +145,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen } let userRecordID = try await container.userRecordID() + guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return } let currentUser = userRecordID.recordName let state = try journal.state() if let boundUser = state.boundUserRecordName, boundUser != currentUser { @@ -167,7 +183,9 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen _ = try await container.privateCloudDatabase.modifyRecordZones( saving: [zone], deleting: [] ) - try await engine.fetchChanges() + try await engine.fetchChanges(.init(scope: .zoneIDs([zoneID]))) + guard self.engine === engine, isEnabled, !Task.isCancelled else { return } + isReadyToSend = true try await sendPendingChanges() try updateUpToDateStatus() } @@ -175,13 +193,15 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen private func synchronizeNow() async throws { guard let engine else { return } status = .syncing(pendingCount: try journal.pendingEnvelopes().count) - try await engine.fetchChanges() + try await engine.fetchChanges(.init(scope: .zoneIDs([zoneID]))) + guard self.engine === engine, isEnabled, !Task.isCancelled else { return } + isReadyToSend = true try await sendPendingChanges() try updateUpToDateStatus() } private func sendPendingChanges() async throws { - guard let engine, isEnabled else { return } + guard let engine, isEnabled, isReadyToSend else { return } let pending = try journal.pendingEnvelopes() guard !pending.isEmpty else { return } addPendingChanges(Set(pending.map(\.key))) @@ -217,6 +237,12 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen } } + nonisolated func nextFetchChangesOptions( + _ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine + ) async -> CKSyncEngine.FetchChangesOptions { + .init(scope: .zoneIDs([CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName)])) + } + // MARK: CKSyncEngineDelegate nonisolated func handleEvent( @@ -235,6 +261,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen private func makeChangeBatch( _ context: CKSyncEngine.SendChangesContext, engine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { + guard self.engine === engine, isEnabled, isReadyToSend else { return nil } let pending = engine.state.pendingRecordZoneChanges.filter { context.options.scope.contains($0) } @@ -247,7 +274,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen @MainActor private func recordToSave(_ recordID: CKRecord.ID) -> CKRecord? { - guard let key = VaultRecordKey(recordName: recordID.recordName), + guard recordID.zoneID == zoneID, let key = VaultRecordKey(recordName: recordID.recordName), let outgoing = try? journal.outgoingEntry(for: key) else { return nil } do { return try Self.makeRecord(outgoing, recordID: recordID) } catch { @@ -259,6 +286,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen @MainActor private func handle(_ event: CKSyncEngine.Event, engine: CKSyncEngine) async { + guard self.engine === engine, isEnabled else { return } do { switch event { case .stateUpdate(let update): @@ -316,6 +344,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen } private func stopEngineForAccountChange() { + isReadyToSend = false let activeEngine = engine engine = nil isStarted = false @@ -358,7 +387,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen // envelope instead of converting a console accident into data loss. let physicallyDeleted = Set( changes.deletions.compactMap { - VaultRecordKey(recordName: $0.recordID.recordName) + $0.recordID.zoneID == zoneID ? VaultRecordKey(recordName: $0.recordID.recordName) : nil } ).filter { (try? journal.envelope(for: $0)) != nil } try journal.markNeedsUpload(physicallyDeleted) @@ -373,14 +402,14 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen private func handleSentChanges( _ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine ) throws { - for record in changes.savedRecords { + for record in changes.savedRecords where record.recordID.zoneID == zoneID { guard let envelope = try? Self.envelope(from: record) else { continue } try journal.markUploaded(envelope.key, version: envelope.version) try journal.updateSystemFields(Self.systemFields(of: record), for: envelope.key) try? FileManager.default.removeItem(at: Self.assetURL(for: envelope.key)) } - for failure in changes.failedRecordSaves { + for failure in changes.failedRecordSaves where failure.record.recordID.zoneID == zoneID { let error = failure.error if error.code == .serverRecordChanged, let serverRecord = error.serverRecord, let serverEnvelope = try? Self.envelope(from: serverRecord) diff --git a/Termind/Infrastructure/Database/AIPersistence.swift b/Termind/Infrastructure/Database/AIPersistence.swift index fa45b992..e1974477 100644 --- a/Termind/Infrastructure/Database/AIPersistence.swift +++ b/Termind/Infrastructure/Database/AIPersistence.swift @@ -274,6 +274,10 @@ final class AIPersistence { } func load() throws -> AIConfigurationSnapshot { + try load(preservingUnresolvedReferences: false) + } + + func load(preservingUnresolvedReferences: Bool) throws -> AIConfigurationSnapshot { let modelRows = try context.fetch( FetchDescriptor(sortBy: [SortDescriptor(\.sortIndex)]) ) @@ -302,7 +306,7 @@ final class AIPersistence { for row in try context.fetch(FetchDescriptor()) { guard let role = AIRole(rawValue: row.role) else { continue } let ref = AIModelRef(modelProviderID: row.providerID, modelID: row.modelID) - if valid(ref) { roles[role] = ref } + if preservingUnresolvedReferences || valid(ref) { roles[role] = ref } } let selectionRow = try context.fetch(FetchDescriptor()).first @@ -311,7 +315,7 @@ final class AIPersistence { let modelID = selectionRow?.modelID else { return nil } let ref = AIModelRef(modelProviderID: providerID, modelID: modelID) - return valid(ref) ? ref : nil + return preservingUnresolvedReferences || valid(ref) ? ref : nil }() let mcpRows = try context.fetch( @@ -333,37 +337,40 @@ final class AIPersistence { } func save(_ snapshot: AIConfigurationSnapshot) throws { - try context.commit { - try context.delete(model: PersistentAIModel.self) - try context.delete(model: PersistentModelProvider.self) - try context.delete(model: PersistentAIRoleAssignment.self) - try context.delete(model: PersistentAISelection.self) - try context.delete(model: PersistentMCPServer.self) + try context.commit { try replace(snapshot) } + } - for (providerIndex, provider) in snapshot.providers.enumerated() { - // A provider with no recorded endpoint is read back as - // `Automatic` over its composed URL, which resolves to the same - // format it already has — see `AIConfigurationModel.endpoints`. - let endpoint = - snapshot.endpoints[provider.id] ?? ProviderEndpoint(url: provider.inferenceURL) + /// Stages rows in the caller-owned transaction. + func replace(_ snapshot: AIConfigurationSnapshot) throws { + try context.delete(model: PersistentAIModel.self) + try context.delete(model: PersistentModelProvider.self) + try context.delete(model: PersistentAIRoleAssignment.self) + try context.delete(model: PersistentAISelection.self) + try context.delete(model: PersistentMCPServer.self) + + for (providerIndex, provider) in snapshot.providers.enumerated() { + // A provider with no recorded endpoint is read back as + // `Automatic` over its composed URL, which resolves to the same + // format it already has — see `AIConfigurationModel.endpoints`. + let endpoint = + snapshot.endpoints[provider.id] ?? ProviderEndpoint(url: provider.inferenceURL) + context.insert( + PersistentModelProvider(provider, endpoint: endpoint, sortIndex: providerIndex) + ) + for (modelIndex, model) in provider.models.enumerated() { context.insert( - PersistentModelProvider(provider, endpoint: endpoint, sortIndex: providerIndex) + PersistentAIModel(model, providerID: provider.id, sortIndex: modelIndex) ) - for (modelIndex, model) in provider.models.enumerated() { - context.insert( - PersistentAIModel(model, providerID: provider.id, sortIndex: modelIndex) - ) - } - } - for role in AIRole.allCases { - guard let ref = snapshot.roles[role] else { continue } - context.insert(PersistentAIRoleAssignment(role: role, ref: ref)) - } - context.insert(PersistentAISelection(snapshot.lastSelectedModel)) - for (serverIndex, server) in snapshot.mcpServers.enumerated() { - context.insert(PersistentMCPServer(server, sortIndex: serverIndex)) } } + for role in AIRole.allCases { + guard let ref = snapshot.roles[role] else { continue } + context.insert(PersistentAIRoleAssignment(role: role, ref: ref)) + } + context.insert(PersistentAISelection(snapshot.lastSelectedModel)) + for (serverIndex, server) in snapshot.mcpServers.enumerated() { + context.insert(PersistentMCPServer(server, sortIndex: serverIndex)) + } } } diff --git a/Termind/Infrastructure/Database/HostPersistence.swift b/Termind/Infrastructure/Database/HostPersistence.swift index ff9e3626..c47c2e7e 100644 --- a/Termind/Infrastructure/Database/HostPersistence.swift +++ b/Termind/Infrastructure/Database/HostPersistence.swift @@ -599,13 +599,33 @@ enum TermindSchemaV12: VersionedSchema { } } +// V13 has already opened user stores. Preserve these entity names and defaults. +enum TermindSchemaV13: VersionedSchema { + static let versionIdentifier = Schema.Version(13, 0, 0) + static var models: [any PersistentModel.Type] { + TermindSchemaV12.models + [ + PersistentAIConfigurationSyncEntry.self, PersistentAIConfigurationSyncState.self, + PersistentAIConfigurationSyncQuarantine.self, PersistentAIWebSearchPreferences.self, + PersistentAISecretLocation.self, + ] + } +} + +enum TermindSchemaV14: VersionedSchema { + static let versionIdentifier = Schema.Version(14, 0, 0) + static var models: [any PersistentModel.Type] { + TermindSchemaV13.models + [PersistentTerminalPreferences.self] + } +} + enum TermindMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [ TermindSchemaV1.self, TermindSchemaV2.self, TermindSchemaV3.self, TermindSchemaV4.self, TermindSchemaV5.self, TermindSchemaV6.self, TermindSchemaV7.self, TermindSchemaV8.self, TermindSchemaV9.self, - TermindSchemaV10.self, TermindSchemaV11.self, TermindSchemaV12.self, + TermindSchemaV10.self, TermindSchemaV11.self, TermindSchemaV12.self, TermindSchemaV13.self, + TermindSchemaV14.self, ] } @@ -653,13 +673,19 @@ enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight( fromVersion: TermindSchemaV11.self, toVersion: TermindSchemaV12.self ), + MigrationStage.lightweight( + fromVersion: TermindSchemaV12.self, toVersion: TermindSchemaV13.self + ), + MigrationStage.lightweight( + fromVersion: TermindSchemaV13.self, toVersion: TermindSchemaV14.self + ), ] } } @MainActor final class HostPersistence { - static let schema = Schema(versionedSchema: TermindSchemaV12.self) + static let schema = Schema(versionedSchema: TermindSchemaV14.self) let context: ModelContext diff --git a/Termind/Infrastructure/Database/PersistentTerminalPreferences.swift b/Termind/Infrastructure/Database/PersistentTerminalPreferences.swift new file mode 100644 index 00000000..a6390e6d --- /dev/null +++ b/Termind/Infrastructure/Database/PersistentTerminalPreferences.swift @@ -0,0 +1,45 @@ +import Foundation +import SwiftData + +@Model +final class PersistentTerminalPreferences { + @Attribute(.unique) var key = "default" + var data = Data() + + init(_ value: TerminalPreferences) throws { try apply(value) } + func load() throws -> TerminalPreferences { try JSONDecoder().decode(TerminalPreferences.self, from: data) } + func apply(_ value: TerminalPreferences) throws { data = try JSONEncoder().encode(value) } +} + +extension TerminalPreferences { + nonisolated init(defaults: UserDefaults) { + self.init() + fontSize = defaults.object(forKey: SettingsKey.terminalFontSize) as? Double ?? fontSize + fontFollowTheme = defaults.object(forKey: SettingsKey.terminalFontFollowTheme) as? Bool ?? fontFollowTheme + darkTheme = defaults.string(forKey: SettingsKey.terminalThemeDark) ?? darkTheme + lightTheme = defaults.string(forKey: SettingsKey.terminalThemeLight) ?? lightTheme + darkCustomEnabled = + defaults.object(forKey: SettingsKey.terminalCustomConfigurationDarkEnabled) as? Bool ?? false + lightCustomEnabled = + defaults.object(forKey: SettingsKey.terminalCustomConfigurationLightEnabled) as? Bool ?? false + darkCustomConfiguration = defaults.string(forKey: SettingsKey.terminalCustomConfigurationDark) ?? "" + lightCustomConfiguration = defaults.string(forKey: SettingsKey.terminalCustomConfigurationLight) ?? "" + shellIntegration = defaults.object(forKey: SettingsKey.shellIntegration) as? Bool ?? shellIntegration + remoteWindowTitleInMenuBar = + defaults.object(forKey: SettingsKey.remoteWindowTitleInMenuBar) as? Bool + ?? remoteWindowTitleInMenuBar + } + + nonisolated func mirror(to defaults: UserDefaults) { + defaults.set(fontSize, forKey: SettingsKey.terminalFontSize) + defaults.set(fontFollowTheme, forKey: SettingsKey.terminalFontFollowTheme) + defaults.set(darkTheme, forKey: SettingsKey.terminalThemeDark) + defaults.set(lightTheme, forKey: SettingsKey.terminalThemeLight) + defaults.set(darkCustomEnabled, forKey: SettingsKey.terminalCustomConfigurationDarkEnabled) + defaults.set(lightCustomEnabled, forKey: SettingsKey.terminalCustomConfigurationLightEnabled) + defaults.set(darkCustomConfiguration, forKey: SettingsKey.terminalCustomConfigurationDark) + defaults.set(lightCustomConfiguration, forKey: SettingsKey.terminalCustomConfigurationLight) + defaults.set(shellIntegration, forKey: SettingsKey.shellIntegration) + defaults.set(remoteWindowTitleInMenuBar, forKey: SettingsKey.remoteWindowTitleInMenuBar) + } +} diff --git a/Termind/Infrastructure/Database/SkillPersistence.swift b/Termind/Infrastructure/Database/SkillPersistence.swift index 8c5e4335..8c4d6a41 100644 --- a/Termind/Infrastructure/Database/SkillPersistence.swift +++ b/Termind/Infrastructure/Database/SkillPersistence.swift @@ -63,11 +63,14 @@ final class SkillPersistence { } func save(_ skills: [AgentSkill]) throws { - try context.commit { - try context.delete(model: PersistentAgentSkill.self) - for (index, skill) in skills.enumerated() { - context.insert(PersistentAgentSkill(skill, sortIndex: index)) - } + try context.commit { try replace(skills) } + } + + /// Stages rows in the caller-owned transaction. + func replace(_ skills: [AgentSkill]) throws { + try context.delete(model: PersistentAgentSkill.self) + for (index, skill) in skills.enumerated() { + context.insert(PersistentAgentSkill(skill, sortIndex: index)) } } } diff --git a/Termind/Infrastructure/Security/AISecretStore.swift b/Termind/Infrastructure/Security/AISecretStore.swift new file mode 100644 index 00000000..2cfc19cf --- /dev/null +++ b/Termind/Infrastructure/Security/AISecretStore.swift @@ -0,0 +1,108 @@ +import Foundation +import SwiftData + +@Model +final class PersistentAISecretLocation { + @Attribute(.unique) var reference = "" + var usesICloud = false + var pendingDeletion = false + init(reference: String) { self.reference = reference } +} + +/// Migration metadata is local. The reference is shared; the secret never enters SwiftData. +@MainActor +final class AISecretStore: AISecretStoring { + private let context: ModelContext + private let keychain: any SecretStoring + var uploadsAuthorized = false + + init(context: ModelContext, keychain: any SecretStoring = KeychainSecretStore()) { + self.context = context + self.keychain = keychain + } + + func string(for reference: String) throws -> String? { + guard !reference.isEmpty else { return nil } + let location = try location(for: reference) + guard !location.pendingDeletion else { return nil } + return try keychain.string(for: reference, storage: location.usesICloud ? .iCloud : .thisDevice) + } + + func setString(_ value: String?, for reference: String) throws { + guard !reference.isEmpty else { return } + let location = try location(for: reference) + if value == nil { + location.pendingDeletion = true + try context.save() + try finishDeletion(location) + return + } + let cloud = location.usesICloud || uploadsAuthorized + try keychain.setString(value, for: reference, storage: cloud ? .iCloud : .thisDevice) + if cloud { + guard try keychain.string(for: reference, storage: .iCloud) == value else { + throw KeychainSecretError.unexpectedValue + } + } + location.usesICloud = cloud + location.pendingDeletion = false + try context.save() + if cloud { try keychain.setString(nil, for: reference, storage: .thisDevice) } + } + + /// Called only after the coordinator has checked the account and merge consent. + func migrate(_ references: Set) throws { + guard uploadsAuthorized else { return } + for row in try context.fetch(FetchDescriptor()) where row.pendingDeletion { + try finishDeletion(row) + } + for reference in references.sorted() where !reference.isEmpty { + let row = try location(for: reference) + guard !row.pendingDeletion else { continue } + if let local = try keychain.string(for: reference, storage: .thisDevice) { + // A previously synced value can be newer than a leftover local copy. + if try keychain.string(for: reference, storage: .iCloud) == nil && !row.usesICloud { + try keychain.setString(local, for: reference, storage: .iCloud) + guard try keychain.string(for: reference, storage: .iCloud) == local else { + throw KeychainSecretError.unexpectedValue + } + } + if try keychain.string(for: reference, storage: .iCloud) != nil { + row.usesICloud = true + try context.save() + try keychain.setString(nil, for: reference, storage: .thisDevice) + } + } else { + row.usesICloud = true + try context.save() + } + } + } + + func acceptRemoteReferences(_ references: Set) throws { + try context.commit { + for reference in references where !reference.isEmpty { + try location(for: reference).usesICloud = true + } + } + } + + private func finishDeletion(_ row: PersistentAISecretLocation) throws { + if row.usesICloud { try keychain.setString(nil, for: row.reference, storage: .iCloud) } + try keychain.setString(nil, for: row.reference, storage: .thisDevice) + // Retain the deletion marker so a delayed Keychain delivery cannot be used. + } + + private func location(for reference: String) throws -> PersistentAISecretLocation { + guard reference.hasPrefix("termind.ai."), reference.count > "termind.ai.".count else { + throw KeychainSecretError.invalidReference + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.reference == reference }) + descriptor.fetchLimit = 1 + if let row = try context.fetch(descriptor).first { return row } + let row = PersistentAISecretLocation(reference: reference) + context.insert(row) + return row + } +} diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 89dfda7c..eebb968b 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -1086,6 +1086,16 @@ } } }, + "API keys use iCloud Keychain, even when configuration sync is paused." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "API密钥使用iCloud钥匙串,即使配置同步已暂停也会继续同步。" + } + } + } + }, "Access" : { "comment" : "A section in the settings that shows the user's access to Termind Pro.", "isCommentAutoGenerated" : true, @@ -5934,6 +5944,7 @@ } }, "Hosts, identities, public key metadata, Known Hosts, snippets, and port forwards sync through your private iCloud database. Passwords and private keys remain protected separately by Keychain. Credentials marked “This Mac” will be missing on other Macs." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -5943,6 +5954,16 @@ } } }, + "Hosts, identities, snippets, and port forwards." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主机、身份、命令片段和端口转发。" + } + } + } + }, "How hard the model thinks" : { "localizations" : { "zh-Hans" : { @@ -5983,6 +6004,16 @@ } } }, + "iCloud Sync" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud同步" + } + } + } + }, "iCloud access is restricted on this Mac." : { "localizations" : { "zh-Hans" : { @@ -8078,6 +8109,26 @@ } } }, + "One purchase for advanced workspaces, AI, MCP, and iCloud sync." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "一次买断,解锁高级工作区、AI、MCP与iCloud同步。" + } + } + } + }, + "One purchase unlocks multi-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and iCloud sync for your Vault and settings across devices." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "一次买断,永久解锁多窗格工作区、AI助手、终端拖放上传和MCP,并跨设备同步保管库和设置。" + } + } + } + }, "One purchase. Your advanced workspace, AI, MCP, and iCloud tools—forever." : { "comment" : "A description of the benefits of Termind Pro.", "isCommentAutoGenerated" : true, @@ -11111,6 +11162,16 @@ } } }, + "Sign in to iCloud to sync settings." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录iCloud以同步设置。" + } + } + } + }, "Sign in to iCloud to sync the Vault." : { "localizations" : { "zh-Hans" : { @@ -11323,6 +11384,26 @@ } } }, + "Some MCP servers have duplicate namespaces and are paused. Edit their namespace IDs to resolve the conflict." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "部分MCP服务器的命名空间重复,已暂停使用。请编辑其命名空间ID以解决冲突。" + } + } + } + }, + "Some skills have duplicate names and are paused. Rename them to resolve the conflict." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "部分技能的名称重复,已暂停使用。请重命名以解决冲突。" + } + } + } + }, "Something else…" : { "localizations" : { "zh-Hans" : { @@ -11911,7 +11992,18 @@ } } }, + "Sync Vault and Settings across devices" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "跨设备同步保管库和设置" + } + } + } + }, "Sync Vault with iCloud" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12059,6 +12151,16 @@ } } }, + "Terminal and AI preferences." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端和AI偏好设置。" + } + } + } + }, "Terminal ready" : { "localizations" : { "zh-Hans" : { @@ -12453,6 +12555,16 @@ } } }, + "The iCloud settings record %@ is invalid." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud设置记录%@无效。" + } + } + } + }, "The identity could not be deleted." : { "localizations" : { "zh-Hans" : { @@ -13116,6 +13228,7 @@ } }, "This Mac is signed in to a different iCloud account. Your local Vault will not be uploaded until you confirm the merge." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13245,6 +13358,16 @@ } } }, + "This settings record requires sync format %lld." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设置记录需要同步格式%lld。" + } + } + } + }, "Time" : { "extractionState" : "stale", "localizations" : { diff --git a/TermindTests/ArchitectureBoundaryTests.swift b/TermindTests/ArchitectureBoundaryTests.swift index d8574b3e..04c9b2d7 100644 --- a/TermindTests/ArchitectureBoundaryTests.swift +++ b/TermindTests/ArchitectureBoundaryTests.swift @@ -10,6 +10,21 @@ struct ArchitectureBoundaryTests { .appending(path: "Termind", directoryHint: .isDirectory) } + @Test + func settingsSyncKeepsExecutionHistoryAndLocalPermissionsOutsideItsPayloads() throws { + let records = try String( + contentsOf: sourceRoot.appending(path: "Domain/Settings/SettingsSyncTypes.swift"), + encoding: .utf8) + for excluded in ["AgentPermissionMode", "AgentRun", "AgentConversation", "mcpServerPort", "mcpServerEnabled"] { + #expect(!records.contains(excluded)) + } + let terminal = try String( + contentsOf: sourceRoot.appending(path: "Infrastructure/Database/PersistentTerminalPreferences.swift"), + encoding: .utf8) + #expect(!terminal.contains("dictionaryRepresentation")) + #expect(!terminal.contains("agentPermissionMode")) + } + @Test func metricsViewsCannotOwnTransportsAndSecretsAreResolvedOffMainActor() throws { try assertNoConcreteImplementations(in: "Features/Metrics") diff --git a/TermindTests/SettingsSyncTests.swift b/TermindTests/SettingsSyncTests.swift new file mode 100644 index 00000000..6cfb2dec --- /dev/null +++ b/TermindTests/SettingsSyncTests.swift @@ -0,0 +1,460 @@ +import AgentKit +import CloudKit +import CoreData +import Foundation +import SwiftData +import Testing + +@testable import Termind + +@Suite +@MainActor +struct SettingsSyncTests { + private func fixture() throws -> (ModelContainer, SettingsSyncPersistence, UserDefaults) { + let container = try ModelContainer( + for: HostPersistence.schema, + configurations: localModelConfiguration(isStoredInMemoryOnly: true)) + let defaults = UserDefaults(suiteName: "SettingsSyncTests.\(UUID())")! + let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults) + try store.prepare() + return (container, store, defaults) + } + + private func provider(_ name: String = "Private endpoint") -> ModelProvider { + var model = AIModel(id: "private-chat") + model.contextLength = 123_456 + model.isShownInAssistant = false + model.reasoningProfile = ReasoningProfile(levels: [ + .high: ReasoningLevelMapping(isSupported: true, wireValue: "deep") + ]) + return ModelProvider( + name: name, inferenceURL: "https://example.invalid/v1/responses", + credentialRef: "termind.ai.key.\(UUID())", models: [model]) + } + + private func envelope(_ payload: SettingsRecordPayload, time: TimeInterval = 1) -> SettingsEnvelope { + SettingsEnvelope( + key: payload.key, + version: .next(after: nil, writerID: UUID(), now: Date(timeIntervalSince1970: time)), + payload: payload) + } + + @Test + func schemaV13RetainsItsInstalledEntityHashes() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("V13Hashes-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("Store.sqlite") + let schema = Schema(versionedSchema: TermindSchemaV13.self) + let container = try ModelContainer( + for: schema, + configurations: localModelConfiguration("V13", schema: schema, url: url)) + defer { withExtendedLifetime(container) {} } + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(ofType: NSSQLiteStoreType, at: url) + let hashes = try #require(metadata[NSStoreModelVersionHashesKey] as? [String: Data]) + let expected = [ + "PersistentAIConfigurationSyncEntry": "005de4241162667db1f82f46fabcabc1401dc22a3a4e9586d798787dc5e26281", + "PersistentAIConfigurationSyncState": "72ccff1781b06084d643d3ea43ad62ac85ac9840835db04aaae34b4776d74998", + "PersistentAIConfigurationSyncQuarantine": + "1ad4dabecf147f68f4419de6a0818b554932822dfb8e83a8e02560ad48b2b452", + "PersistentAIWebSearchPreferences": "01922c7bda7dd72129b8e0e8079eb39ac44718aa5421160038defcda4c6ddb37", + "PersistentAISecretLocation": "3a0b34347788b6e9da55f343572be6dc942d0b576c72039f520a21724d26bf87", + ] + for (name, expectedHash) in expected { + #expect(hashes[name]?.map { String(format: "%02x", $0) }.joined() == expectedHash) + } + } + + @Test(.enabled(if: FileManager.default.fileExists(atPath: settingsMigrationFixtureURL.path))) + func providedDatabaseCopyMigratesWithoutReset() throws { + let container = try ModelContainer( + for: HostPersistence.schema, migrationPlan: TermindMigrationPlan.self, + configurations: localModelConfiguration( + "Termind", schema: HostPersistence.schema, url: settingsMigrationFixtureURL)) + let defaults = UserDefaults(suiteName: "SettingsMigrationFixture.\(UUID())")! + defaults.set(AgentSkillLibrary.currentGeneration, forKey: SettingsKey.seededSkillGeneration) + let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults) + try store.prepare() + _ = try store.load() + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, at: settingsMigrationFixtureURL) + #expect(metadata[NSStoreModelVersionIdentifiersKey] as? [String] == ["14.0.0"]) + } + + @Test + func schemaV12MigratesProvidersSkillsAndPreferencesWithoutEnablingSync() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AISyncMigration-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("Store.sqlite") + let oldSchema = Schema(versionedSchema: TermindSchemaV12.self) + let provider = provider() + let endpoint = ProviderEndpoint(apiType: .responses, url: "https://example.invalid/v1") + let skill = AgentSkill(name: "custom", title: "Custom", summary: "Mine", body: "Do this") + var legacy: ModelContainer? = try ModelContainer( + for: oldSchema, + configurations: localModelConfiguration("Migration", schema: oldSchema, url: url)) + try AIPersistence(context: legacy!.mainContext).save( + AIConfigurationSnapshot( + providers: [provider], endpoints: [provider.id: endpoint], roles: [:])) + try SkillPersistence(context: legacy!.mainContext).save([skill]) + legacy = nil + let migrated = try ModelContainer( + for: HostPersistence.schema, migrationPlan: TermindMigrationPlan.self, + configurations: localModelConfiguration("Migration", schema: HostPersistence.schema, url: url)) + let defaults = UserDefaults(suiteName: "AISyncMigration.\(UUID())")! + defaults.set(false, forKey: SettingsKey.webSearchEnabled) + defaults.set(17.0, forKey: SettingsKey.terminalFontSize) + defaults.set("font-family = Menlo", forKey: SettingsKey.terminalCustomConfigurationDark) + defaults.set(1, forKey: SettingsKey.seededSkillGeneration) + let store = SettingsSyncPersistence(context: migrated.mainContext, defaults: defaults) + try store.prepare() + let snapshot = try store.load() + #expect(snapshot.configuration.providers.first?.id == provider.id) + #expect(snapshot.configuration.endpoints[provider.id] == endpoint) + #expect(snapshot.configuration.providers.first?.models == provider.models) + #expect(snapshot.skills == [skill]) + #expect(!snapshot.webSearch.isEnabled) + #expect(snapshot.terminal.fontSize == 17) + #expect(snapshot.terminal.darkCustomConfiguration == "font-family = Menlo") + #expect(!defaults.bool(forKey: SettingsKey.settingsSyncEnabled)) + } + + @Test + func completedV13BootstrapAddsTerminalWithoutRewritingExistingRecords() throws { + let (container, store, defaults) = try fixture() + defer { withExtendedLifetime(container) {} } + let previous = try store.journal.pendingEnvelopes().filter { $0.key.kind != .terminal } + try container.mainContext.delete( + model: PersistentAIConfigurationSyncEntry.self, + where: #Predicate { $0.kind == "terminal" }) + try container.mainContext.delete(model: PersistentTerminalPreferences.self) + try container.mainContext.save() + defaults.set(17.0, forKey: SettingsKey.terminalFontSize) + try store.prepare() + let terminal = try #require(try store.journal.pendingEnvelopes().first { $0.key.kind == .terminal }) + #expect(terminal.version.modifiedAt == VaultVersion.baselineDate) + #expect(try store.loadTerminal().fontSize == 17) + for old in previous { #expect(try store.journal.envelope(for: old.key) == old) } + } + + @Test + func terminalPreferencesSyncWithCustomTextAndMirrorExistingRuntimeKeys() throws { + let (sourceContainer, source, sourceDefaults) = try fixture() + let (destinationContainer, destination, defaults) = try fixture() + defer { withExtendedLifetime((sourceContainer, destinationContainer)) {} } + var preferences = TerminalPreferences() + preferences.fontSize = 18 + preferences.fontFollowTheme = false + preferences.darkTheme = "Catppuccin Mocha" + preferences.lightTheme = "GitHub Light" + preferences.darkCustomEnabled = true + preferences.lightCustomEnabled = true + preferences.darkCustomConfiguration = "background = #112233\nfont-family = Menlo" + preferences.lightCustomConfiguration = "background = #ffffff" + preferences.shellIntegration = false + preferences.remoteWindowTitleInMenuBar = false + try source.saveTerminal(preferences) + #expect(sourceDefaults.double(forKey: SettingsKey.terminalFontSize) == 18) + let records = try source.journal.pendingEnvelopes().filter { $0.key.kind == .terminal } + let result = try destination.applyRemote(records) + #expect(result.snapshot.terminal == preferences) + #expect( + defaults.string(forKey: SettingsKey.terminalCustomConfigurationDark) == preferences.darkCustomConfiguration) + #expect(!defaults.bool(forKey: SettingsKey.shellIntegration)) + #expect(try !destination.journal.pendingEnvelopes().contains { $0.key.kind == .terminal }) + let model = TerminalSettingsModel(persistence: destination) + var changes: [Bool] = [] + model.didChangeShellIntegration = { changes.append($0) } + #expect(model.set(true, for: \.shellIntegration)) + #expect(changes == [true]) + #expect(try destination.loadTerminal().shellIntegration) + model.applyRemoteSnapshot(preferences) + #expect(changes == [true, false]) + } + + @Test + func everyRecordRoundTripsWithoutSecretsOrLocalExecutionSettings() throws { + let (container, store, _) = try fixture() + defer { withExtendedLifetime(container) {} } + let provider = provider() + var snapshot = try store.load() + snapshot.configuration.providers = [provider] + snapshot.configuration.endpoints[provider.id] = ProviderEndpoint( + apiType: .responses, url: "https://example.invalid/v1") + let ref = AIModelRef(modelProviderID: provider.id, modelID: provider.models[0].id) + snapshot.configuration.roles[.chat] = ref + snapshot.configuration.lastSelectedModel = ref + snapshot.configuration.mcpServers = [ + MCPServer( + name: "Remote", credentialRef: "termind.ai.mcp.ref", + headers: [MCPHeader(name: "X-Tenant", value: "tenant")], tools: [MCPTool(id: "read")]) + ] + for payload in snapshot.recordPayloads.values { + let value = envelope(payload) + let data = try SettingsSyncCodec.encode(value) + #expect(try SettingsSyncCodec.decode(data) == value) + #expect(SettingsRecordKey(recordName: value.key.recordName) == value.key) + let json = String(decoding: data, as: UTF8.self) + #expect(!json.contains("permissionMode")) + #expect(!json.contains("lastConnectedAt")) + let record = try SettingsSyncCoordinator.makeRecord( + SettingsOutgoingEntry(envelope: value), + recordID: CKRecord.ID( + recordName: value.key.recordName, + zoneID: CKRecordZone.ID(zoneName: SettingsSyncCoordinator.zoneName))) + #expect(try SettingsSyncCoordinator.envelope(from: record) == value) + } + let wire = SyncedModelProvider(provider, endpoint: snapshot.configuration.endpoints[provider.id]!, sortIndex: 0) + #expect(wire.value.models == provider.models) + } + + @Test + func localEditsAreJournaledWhileDisabledAndNoOpsDoNotAdvanceVersions() throws { + let (container, store, defaults) = try fixture() + defer { withExtendedLifetime(container) {} } + #expect(!defaults.bool(forKey: SettingsKey.settingsSyncEnabled)) + var snapshot = try store.load().configuration + let provider = provider() + snapshot.providers.append(provider) + try store.saveConfiguration(snapshot) + let key = SettingsRecordKey(kind: .provider, id: provider.id.uuidString.lowercased()) + let original = try #require(try store.journal.envelope(for: key)) + try store.saveConfiguration(snapshot) + #expect(try store.journal.envelope(for: key) == original) + snapshot.providers.removeAll() + try store.saveConfiguration(snapshot) + #expect(try store.journal.envelope(for: key)?.isDeleted == true) + let merged = try store.applyRemote([original]) + #expect(merged.snapshot.configuration.providers.isEmpty) + #expect(merged.needsUpload.contains(key)) + } + + @Test + func newDeviceDefaultsDoNotReplaceCloudPreferencesAndBuiltInDeletion() throws { + let (sourceContainer, source, _) = try fixture() + defer { withExtendedLifetime(sourceContainer) {} } + let (destinationContainer, destination, _) = try fixture() + defer { withExtendedLifetime(destinationContainer) {} } + try source.saveWebSearch(AIWebSearchPreferences(isEnabled: false, prefersBuiltIn: false)) + try source.saveSkills([]) + let result = try destination.applyRemote(source.journal.pendingEnvelopes()) + #expect(result.snapshot.skills.isEmpty) + #expect(!result.snapshot.webSearch.isEnabled) + try destination.prepare() + #expect(try destination.load().skills.isEmpty) + } + + @Test + func roleArrivingBeforeProviderSurvivesRelaunchAndUnrelatedEdit() throws { + let (container, store, defaults) = try fixture() + let provider = provider() + let ref = AIModelRef(modelProviderID: provider.id, modelID: provider.models[0].id) + let role = envelope(.role(.chat, ref)) + _ = try store.applyRemote([role]) + let reopened = SettingsSyncPersistence(context: ModelContext(container), defaults: defaults) + var config = try reopened.load().configuration + #expect(config.roles[.chat] == ref) + config.mcpServers = [MCPServer(name: "Unrelated")] + try reopened.saveConfiguration(config) + let value = SyncedModelProvider(provider, endpoint: ProviderEndpoint(url: provider.inferenceURL), sortIndex: 0) + let result = try reopened.applyRemote([envelope(.provider(value), time: 2)]) + #expect(result.snapshot.configuration.roles[.chat] == ref) + let deletion = SettingsEnvelope( + key: valueKey(provider), + version: .next(after: nil, writerID: UUID(), now: Date(timeIntervalSince1970: 3)), isDeleted: true) + let deleted = try reopened.applyRemote([deletion]) + #expect(deleted.snapshot.configuration.roles[.chat] == nil) + } + + private func valueKey(_ provider: ModelProvider) -> SettingsRecordKey { + SettingsRecordKey(kind: .provider, id: provider.id.uuidString.lowercased()) + } + + @Test + func differentProvidersMergeAndLatestVersionWinsRegardlessOfBatchOrder() throws { + let (container, store, _) = try fixture() + defer { withExtendedLifetime(container) {} } + let first = provider("First") + let second = provider("Second") + let endpoint = ProviderEndpoint(url: first.inferenceURL) + let old = envelope(.provider(SyncedModelProvider(first, endpoint: endpoint, sortIndex: 0)), time: 1) + var changed = first + changed.name = "Updated" + let newer = envelope(.provider(SyncedModelProvider(changed, endpoint: endpoint, sortIndex: 0)), time: 5) + let other = envelope(.provider(SyncedModelProvider(second, endpoint: endpoint, sortIndex: 1)), time: 2) + let result = try store.applyRemote([newer, other, old]) + #expect(result.snapshot.configuration.providers.count == 2) + #expect(result.snapshot.configuration.providers.first?.name == "Updated") + } + + @Test + func invalidBatchRollsBackConfigurationAndJournal() throws { + let (container, store, _) = try fixture() + defer { withExtendedLifetime(container) {} } + let before = try store.load().webSearch + let valid = envelope(.webSearch(.init(isEnabled: false, prefersBuiltIn: false))) + var invalid = envelope(.selection(nil)) + invalid.formatVersion = 99 + #expect(throws: (any Error).self) { try store.applyRemote([valid, invalid]) } + #expect(try store.load().webSearch == before) + #expect(try store.journal.envelope(for: valid.key)?.version.modifiedAt == VaultVersion.baselineDate) + } + + @Test + func environmentResetRetainsDeletesButAccountMergeDoesNotUploadOldAccountDeletes() throws { + let (container, store, _) = try fixture() + defer { withExtendedLifetime(container) {} } + try store.saveSkills([]) + try store.journal.markAllEntriesNeedsUploadAfterEnvironmentChange() + #expect(try store.journal.pendingEnvelopes().contains(where: \.isDeleted)) + try store.journal.markAllLiveRecordsNeedsUploadForNewAccount() + #expect(try !store.journal.pendingEnvelopes().contains(where: \.isDeleted)) + } + + @Test + func legacyBuiltInsGetStableIDsWithoutDiscardingCustomizations() { + let shipped = AgentSkillLibrary.builtIns(after: 0) + var unchanged = shipped[0] + unchanged.id = UUID() + var edited = shipped[1] + edited.id = UUID() + edited.body += "\nMy edits" + let result = SettingsSyncPersistence.normalizeBuiltIns([unchanged, edited], seededGeneration: 1) + #expect(result.skills.first?.id == shipped[0].id) + #expect(result.skills.contains { $0 == edited }) + #expect(result.deleted == [shipped[1].id, shipped[2].id]) + #expect(Set(shipped.map(\.id)) == Set(AgentSkillLibrary.builtIns(after: 0).map(\.id))) + } + + @Test + func collisionsAreDeterministicallyExcludedWithoutRewritingUserRecords() { + let low = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let high = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + let first = MCPServer(id: low, name: "Shared") + let second = MCPServer(id: high, name: "Shared") + let ai = AIConfigurationModel(mcpServers: [second, first], persistsSecrets: false) + #expect(ai.usableMCPServers.map(\.id) == [low]) + #expect(ai.mcpServers.allSatisfy { $0.isEnabled }) + let library = SkillLibraryModel(skills: [ + AgentSkill(id: high, name: "shared", title: "Second", summary: "Procedure", body: "Second"), + AgentSkill(id: low, name: "shared", title: "First", summary: "Procedure", body: "First"), + ]) + #expect(library.enabledSkills.map(\.id) == [low]) + } + + @Test + func mainSwitchPausesBothCategoriesWithoutClearingSelections() throws { + let (container, store, defaults) = try fixture() + SettingsKey.migrateSyncPreferences(in: defaults) + #expect(defaults.bool(forKey: SettingsKey.iCloudSyncEnabled)) + #expect(defaults.object(forKey: SettingsKey.vaultSyncEnabled) as? Bool ?? SettingsKey.defaultVaultSyncEnabled) + #expect(!defaults.bool(forKey: SettingsKey.settingsSyncEnabled)) + defaults.set(true, forKey: SettingsKey.settingsSyncEnabled) + let vault = VaultSyncCoordinator(context: container.mainContext, defaults: defaults) + let ai = SettingsSyncCoordinator( + persistence: store, + secrets: AISecretStore(context: container.mainContext, keychain: SyncTestSecrets()), defaults: defaults) + defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled) + #expect(!vault.isEnabled && !ai.isEnabled) + #expect(defaults.bool(forKey: SettingsKey.settingsSyncEnabled)) + defaults.set(true, forKey: SettingsKey.iCloudSyncEnabled) + #expect(vault.isEnabled && ai.isEnabled) + defaults.set(false, forKey: SettingsKey.vaultSyncEnabled) + #expect(!vault.isEnabled && ai.isEnabled) + } + + @Test + func upgradingWithVaultDisabledKeepsMainSyncOffAndVaultPreselected() { + let defaults = UserDefaults(suiteName: "AISyncPreferenceMigration.\(UUID())")! + defaults.set(false, forKey: SettingsKey.vaultSyncEnabled) + SettingsKey.migrateSyncPreferences(in: defaults) + #expect(!defaults.bool(forKey: SettingsKey.iCloudSyncEnabled)) + #expect(defaults.bool(forKey: SettingsKey.vaultSyncEnabled)) + #expect(!defaults.bool(forKey: SettingsKey.settingsSyncEnabled)) + defaults.set(false, forKey: SettingsKey.vaultSyncEnabled) + SettingsKey.migrateSyncPreferences(in: defaults) + #expect(!defaults.bool(forKey: SettingsKey.vaultSyncEnabled)) + } + + @Test + func independentSwitchesUseInjectedDefaults() throws { + let (container, store, defaults) = try fixture() + defaults.set(true, forKey: SettingsKey.vaultSyncEnabled) + let vault = VaultSyncCoordinator(context: container.mainContext, defaults: defaults) + let ai = SettingsSyncCoordinator( + persistence: store, + secrets: AISecretStore(context: container.mainContext, keychain: SyncTestSecrets()), defaults: defaults) + #expect(vault.isEnabled) + #expect(!ai.isEnabled) + vault.setEnabled(false) + #expect(!ai.isEnabled) + defaults.set(true, forKey: SettingsKey.settingsSyncEnabled) + #expect(ai.isEnabled) + #expect(!vault.isEnabled) + ai.pause() + } + + @Test + func keysMigrateRetryAndRemainSyncedWhenConfigurationPauses() throws { + let (container, _, _) = try fixture() + let keychain = SyncTestSecrets() + let secrets = AISecretStore(context: container.mainContext, keychain: keychain) + let ref = "termind.ai.key.test" + try secrets.setString("original", for: ref) + #expect(try keychain.string(for: ref, storage: .iCloud) == nil) + secrets.uploadsAuthorized = true + keychain.failCloudWrites = true + #expect(throws: (any Error).self) { try secrets.migrate([ref]) } + #expect(try keychain.string(for: ref, storage: .thisDevice) == "original") + keychain.failCloudWrites = false + try secrets.migrate([ref]) + #expect(try keychain.string(for: ref, storage: .thisDevice) == nil) + secrets.uploadsAuthorized = false + try secrets.setString("new", for: ref) + #expect(try keychain.string(for: ref, storage: .iCloud) == "new") + try keychain.setString("other device", for: ref, storage: .iCloud) + #expect(try secrets.string(for: ref) == "other device") + try secrets.setString(nil, for: ref) + try keychain.setString("delayed stale item", for: ref, storage: .iCloud) + #expect(try secrets.string(for: ref) == nil) + } + + @Test + func remoteKeyCanArriveAfterConfigurationWithoutUsingStaleLocalCopy() throws { + let (container, _, _) = try fixture() + let keychain = SyncTestSecrets() + let secrets = AISecretStore(context: container.mainContext, keychain: keychain) + let ref = "termind.ai.mcp.delayed" + try keychain.setString("stale", for: ref, storage: .thisDevice) + try secrets.acceptRemoteReferences([ref]) + #expect(try secrets.string(for: ref) == nil) + try keychain.setString("arrived", for: ref, storage: .iCloud) + #expect(try secrets.string(for: ref) == "arrived") + } +} + +private nonisolated final class SyncTestSecrets: SecretStoring, @unchecked Sendable { + private let lock = NSLock() + private var values: [String: String] = [:] + var failCloudWrites = false + func string(for account: String, storage: PasswordStorage) throws -> String? { + lock.withLock { values["\(storage):\(account)"] } + } + func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { + try lock.withLock { + if storage == .iCloud, failCloudWrites { throw KeychainSecretError.unexpectedValue } + values["\(storage):\(account)"] = value + } + } + func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? { nil } + func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws {} + func removePassword(_ account: String, storage: PasswordStorage) throws { + try setString(nil, for: account, storage: storage) + } + func removeKeyCredential(_ account: String, storage: KeyStorage) throws {} +} + +private nonisolated var settingsMigrationFixtureURL: URL { + FileManager.default.temporaryDirectory.appendingPathComponent("TermindMigrationValidation/Termind.store") +}