Sync Vault and settings across devices

This commit is contained in:
Kaede Akino committed 2026-09-10 23:15:25 +08:00
1 parent 526f8bdf07
commit f0fdcf4ddb
35 files changed
+2863 -183

No files matched your search

+38
View File
@@ -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.
+36
View File
@@ -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.
+4 -1
View File
@@ -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 {
+20 -2
View File
@@ -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
+18
View File
@@ -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"
+13 -3
View File
@@ -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
)
@@ -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<UUID> {
var names = Set<String>()
var conflicts = Set<UUID>()
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
@@ -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: """
@@ -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<UUID> {
var names = Set<String>()
var conflicts = Set<UUID>()
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) {
@@ -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: Value, for keyPath: WritableKeyPath<TerminalPreferences, Value>) -> 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)
}
}
}
@@ -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
}
@@ -0,0 +1,7 @@
import Foundation
@MainActor
protocol TerminalPreferencesPersisting: AnyObject {
func loadTerminal() throws -> TerminalPreferences
func saveTerminal(_ value: TerminalPreferences) throws
}
@@ -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
}
+1 -1
View File
@@ -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 = ""
@@ -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
}
@@ -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<SettingsRecordKey> = []
static let empty = Self()
var isEmpty: Bool { keys.isEmpty }
}
@@ -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)
@@ -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) }
}
+36 -111
View File
@@ -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<Value>(_ keyPath: WritableKeyPath<TerminalPreferences, Value>) -> Binding<Value> {
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<Bool> {
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)
}
}
}
@@ -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) }
}
@@ -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)
}
}
}
@@ -14,6 +14,13 @@ nonisolated enum TerminalCustomConfigurationScheme: String, Identifiable, Hashab
}
}
var preferenceKeyPath: WritableKeyPath<TerminalPreferences, String> {
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<Void, Never>?
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) {
@@ -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<AIWebSearchPreferences, Bool>) -> Binding<Bool> {
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(
@@ -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<Void, Never>?
private var operationID = UUID()
private var isStarted = false
private var terminalError: String?
private(set) var status: VaultSyncStatus = .checkingAccount
var didApplyRemote: ((SettingsSyncSnapshot, Set<SettingsRecordKey>) -> 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<SettingsRecordKey>) {
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<SettingsRecordKey>,
needsUpload: Set<SettingsRecordKey>
)
{
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.")
}
}
}
@@ -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<SettingsRecordKey> = []
}
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<PersistentAIConfigurationSyncState>()
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<SettingsRecordKey>()
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<SettingsRecordKey>()
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<PersistentAIConfigurationSyncEntry>(
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<SettingsRecordKey>) 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<PersistentAIConfigurationSyncQuarantine>(
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<PersistentAIConfigurationSyncQuarantine>(
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<SettingsRecordKey>()
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<PersistentAIConfigurationSyncEntry>()) {
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<PersistentAIConfigurationSyncEntry>()) {
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<PersistentAIConfigurationSyncEntry>(
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<PersistentAIConfigurationSyncEntry>()).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
}
)
}
}
@@ -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<UUID>)
{
var result = installed
var deleted = Set<UUID>()
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<SettingsRecordKey>,
needsUpload: Set<SettingsRecordKey>
)
{
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<PersistentAIConfigurationSyncEntry>()).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<PersistentTerminalPreferences>()).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<PersistentAIWebSearchPreferences>()).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 {}
@@ -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<Void, Never>?
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<VaultRecordKey>) -> 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)
@@ -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<PersistentAIModel>(sortBy: [SortDescriptor(\.sortIndex)])
)
@@ -302,7 +306,7 @@ final class AIPersistence {
for row in try context.fetch(FetchDescriptor<PersistentAIRoleAssignment>()) {
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<PersistentAISelection>()).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))
}
}
}
@@ -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
@@ -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)
}
}
@@ -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))
}
}
}
@@ -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<String>) throws {
guard uploadsAuthorized else { return }
for row in try context.fetch(FetchDescriptor<PersistentAISecretLocation>()) 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<String>) 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<PersistentAISecretLocation>(
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
}
}
+123
View File
@@ -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" : {
@@ -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")
+460
View File
@@ -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")
}