Files
Termind/Frameworks/TermindPersistence/SettingsSyncStore.swift
T
Kaede Akino 82d871c4fe refactor: separate feature policies and UI workflows
Move connection policy out of ProAccess, keep terminal uploads with their
session, and share question interaction and highlight editing operations.
Consolidate CPU usage and settings snapshot handling, and remove obsolete
terminal selection, theme helpers, and hard-coded latest-schema assertions.

Fix draining connection-pool recovery and stale pane upload bindings. Keep
screenshot matrices opt-in and retain behavioral migration coverage.

Validation: 2468 macOS tests passed serially, 31 iOS tests passed, and both
SSH forwarding regressions passed against the local OpenSSH fixture.
Both app builds, Swift formatting, AppStrings, and Chinese localization
checks passed. Parallel test runs still exhibit intermittent timeouts.
2026-10-02 16:40:27 +08:00

456 lines
21 KiB
Swift

import AgentKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
public nonisolated struct SettingsSyncSnapshot: Sendable {
public var configuration: AIConfigurationSnapshot
public var skills: [AgentSkill]
public var webSearch: AIWebSearchPreferences
public var terminal = TerminalPreferences()
public var ssh = SSHPreferences()
public var sftp = SFTPPreferences()
public var workspace = WorkspacePreferences()
public var notifications = NotificationPreferences()
public var terminalHighlighting = TerminalHighlightPreferences()
public init(
configuration: AIConfigurationSnapshot, skills: [AgentSkill], webSearch: AIWebSearchPreferences,
terminal: TerminalPreferences = .init(), ssh: SSHPreferences = .init(),
sftp: SFTPPreferences = .init(), workspace: WorkspacePreferences = .init(),
notifications: NotificationPreferences = .init(), terminalHighlighting: TerminalHighlightPreferences = .init()
) {
self.configuration = configuration
self.skills = skills
self.webSearch = webSearch
self.terminal = terminal
self.ssh = ssh
self.sftp = sftp
self.workspace = workspace
self.notifications = notifications
self.terminalHighlighting = terminalHighlighting
}
public 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(.webSearch(webSearch))
payloads.append(.terminal(terminal))
payloads.append(.ssh(ssh))
payloads.append(.sftp(sftp))
payloads.append(.workspace(workspace))
payloads.append(.notifications(notifications))
payloads.append(.terminalHighlighting(terminalHighlighting))
return Dictionary(uniqueKeysWithValues: payloads.map { ($0.key, $0) })
}
}
/// Configuration and upload intent commit together; remote updates skip the local write path.
@MainActor
public final class SettingsSyncPersistence {
public let context: ModelContext
private let defaults: UserDefaults
private let ai: AIPersistence
private let skills: SkillPersistence
public let journal: SettingsSyncJournal
public var didRecordChanges: ((SettingsMutationSet) -> Void)?
public init(context: ModelContext, defaults: UserDefaults = .standard) {
self.context = context
self.defaults = defaults
ai = AIPersistence(context: context)
skills = SkillPersistence(context: context)
journal = SettingsSyncJournal(context: context)
}
public func prepare() throws {
var mutations = SettingsMutationSet.empty
try context.commit {
mutations.keys.formUnion(try journal.migrateLegacyGuardianRole().keys)
try journal.discardInactiveEntries()
let previous = try load()
mutations.keys.formUnion(try journal.bootstrapIfNeeded(previous).keys)
// Add preferences introduced after an installed store completed its bootstrap.
for payload in [
SettingsRecordPayload.terminal(previous.terminal), .ssh(previous.ssh), .sftp(previous.sftp),
.workspace(previous.workspace), .notifications(previous.notifications),
.terminalHighlighting(previous.terminalHighlighting),
] {
if try journal.envelope(for: payload.key) == nil {
let version = VaultVersion.baseline(writerID: try journal.state().installationID)
let envelope = SettingsEnvelope(key: payload.key, version: version, payload: payload)
context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: true))
mutations.keys.insert(payload.key)
}
}
}
let snapshot = try load()
mirrorSnapshot(snapshot)
didRecordChanges?(mutations)
}
public func purgeCloudCache(
scope: CloudSyncScope, assetCleanupKey: String? = nil
) throws -> (SettingsSyncSnapshot, Set<SettingsRecordKey>) {
var removed = Set<SettingsRecordKey>()
var snapshot = try load()
try context.commit {
removed = Set(
try journal.cloudRecords().compactMap { SettingsRecordKey(recordName: $0.mutation.recordName) })
let suppressed = try journal.purgeCloudCache()
snapshot = try materialize()
try applySnapshot(snapshot)
try CloudSyncSafetyStore(context: context, scope: scope).purgeScope()
let cloud = CloudSyncRecordStateStore(context: context, scope: scope)
try cloud.purgeScope()
for mutation in suppressed {
try cloud.deleted(mutation.recordName, version: mutation.version)
}
if let assetCleanupKey {
try CloudSyncAssetCleanupStore(context: context).markPending(
scope: scope, cacheKey: assetCleanupKey)
}
}
mirrorSnapshot(snapshot)
return (snapshot, removed)
}
public func load() throws -> SettingsSyncSnapshot {
SettingsSyncSnapshot(
configuration: try ai.load(preservingUnresolvedReferences: true),
skills: try skills.load(), webSearch: try preferences().value, terminal: try terminalPreferences().load(),
ssh: try sshPreferences().load(), sftp: try sftpPreferences().load(),
workspace: try workspacePreferences().load(), notifications: try notificationPreferences().load(),
terminalHighlighting: try loadTerminalHighlightPreferences())
}
public func saveConfiguration(_ configuration: AIConfigurationSnapshot) throws {
try saveLocal { try ai.replace(configuration) }
}
public func saveSkills(_ values: [AgentSkill]) throws {
try saveLocal { try skills.replace(values) }
}
public 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)
}
public func applyRemote(
_ envelopes: [SettingsEnvelope], deletions: Set<SettingsRecordKey> = [],
beforeSave: () throws -> Void = {}
) throws
-> (
snapshot: SettingsSyncSnapshot, acceptedKeys: Set<SettingsRecordKey>,
needsUpload: Set<SettingsRecordKey>
)
{
var merge = SettingsRemoteMerge()
var snapshot = try load()
let activeEnvelopes = envelopes.filter { $0.key.kind.participatesInSync }
try context.commit(beforeSave: beforeSave) {
let deleted = try journal.deletionEnvelopes(deletions)
merge = try journal.mergeDecision(for: activeEnvelopes.filter { !deletions.contains($0.key) } + deleted)
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.
let existingPackages = Dictionary(
uniqueKeysWithValues: try skills.load().compactMap { skill in
skill.package.map { (skill.id, $0) }
})
let existingProviders = Dictionary(
uniqueKeysWithValues: snapshot.configuration.providers.map { ($0.id, $0) })
let legacyProviderIDs = Set(
try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()).compactMap {
row -> UUID? in
guard let envelope = row.envelope, !envelope.isDeleted,
case .provider(let value) = envelope.payload, value.vertex == nil
else { return nil }
return value.id
})
snapshot = try materialize()
let remoteSnapshot = snapshot
for index in snapshot.configuration.providers.indices {
let id = snapshot.configuration.providers[index].id
if legacyProviderIDs.contains(id), let old = existingProviders[id] {
snapshot.configuration.providers[index].usesVertex = old.usesVertex
snapshot.configuration.providers[index].vertex = old.vertex
}
}
for index in snapshot.skills.indices where snapshot.skills[index].package == nil {
snapshot.skills[index].package = existingPackages[snapshot.skills[index].id]
}
merge.needsUpload.formUnion(try journal.recordLocalChanges(from: remoteSnapshot, to: snapshot).keys)
try applySnapshot(snapshot)
try journal.markNeedsUpload(merge.needsUpload)
}
mirrorSnapshot(snapshot)
return (snapshot, Set(merge.accepted.map(\.key)), merge.needsUpload)
}
/// Call inside the caller's transaction so preference rows and sync intent stay atomic.
private func applySnapshot(_ snapshot: SettingsSyncSnapshot) throws {
try ai.replace(snapshot.configuration)
try skills.replace(snapshot.skills)
try preferences().apply(snapshot.webSearch)
try terminalPreferences().apply(snapshot.terminal)
try sshPreferences().apply(snapshot.ssh)
try sftpPreferences().apply(snapshot.sftp)
try workspacePreferences().apply(snapshot.workspace)
try notificationPreferences().apply(snapshot.notifications)
try terminalHighlightPreferences().apply(snapshot.terminalHighlighting)
}
private func mirrorSnapshot(_ snapshot: SettingsSyncSnapshot) {
mirror(snapshot.webSearch)
snapshot.terminal.mirror(to: defaults)
snapshot.ssh.mirror(to: defaults)
snapshot.sftp.mirror(to: defaults)
}
private func materialize() throws -> SettingsSyncSnapshot {
let envelopes = try context.fetch(
FetchDescriptor<PersistentAIConfigurationSyncEntry>()
).compactMap(\.envelope)
let localSelection = try ai.load(preservingUnresolvedReferences: true).lastSelectedModel
var providers: [SyncedModelProvider] = []
var servers: [SyncedMCPServer] = []
var skillRows: [(AgentSkill, Int)] = []
var configuration = AIConfigurationSnapshot(
providers: [], roles: [:], lastSelectedModel: localSelection)
var webSearch = try preferences().value
var terminal = try terminalPreferences().load()
var ssh = try sshPreferences().load()
var sftp = try sftpPreferences().load()
var workspace = try workspacePreferences().load()
var notifications = try notificationPreferences().load()
var terminalHighlighting = try loadTerminalHighlightPreferences()
for envelope in envelopes where envelope.isDeleted {
switch envelope.key.kind {
case .webSearch: webSearch = AIWebSearchPreferences()
case .terminal: terminal = TerminalPreferences()
case .ssh: ssh = SSHPreferences()
case .sftp: sftp = SFTPPreferences()
case .workspace: workspace = WorkspacePreferences()
case .notifications: notifications = NotificationPreferences()
case .terminalHighlighting:
terminalHighlighting = TerminalHighlightPreferences()
default: break
}
}
for envelope in envelopes where !envelope.isDeleted && envelope.key.kind.participatesInSync {
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: break
case .webSearch(let value): webSearch = value
case .terminal(let value): terminal = value
case .ssh(let value): ssh = value
case .sftp(let value): sftp = value
case .workspace(let value): workspace = value
case .notifications(let value): notifications = value
case .terminalHighlighting(let value):
terminalHighlighting = 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,
configuration.providers.first(where: { $0.id == ref.modelProviderID })?.model(ref.modelID) == nil
{
configuration.lastSelectedModel = nil
}
return SettingsSyncSnapshot(
configuration: configuration, skills: skillRows.map(\.0), webSearch: webSearch, terminal: terminal,
ssh: ssh, sftp: sftp, workspace: workspace, notifications: notifications,
terminalHighlighting: terminalHighlighting)
}
private func terminalPreferences() throws -> PersistentTerminalPreferences {
if let row = try context.fetch(FetchDescriptor<PersistentTerminalPreferences>()).first {
try TerminalFontPreferences.migrateLegacySettings(from: row.data, defaults: defaults)
return row
}
try TerminalFontPreferences.migrateLegacySettings(from: nil, defaults: defaults)
let row = try PersistentTerminalPreferences(TerminalPreferences(defaults: defaults))
context.insert(row)
return row
}
public func loadTerminal() throws -> TerminalPreferences { try terminalPreferences().load() }
public func saveTerminal(_ value: TerminalPreferences) throws {
try saveLocal { try terminalPreferences().apply(value) }
value.mirror(to: defaults)
}
private func sshPreferences() throws -> PersistentSSHPreferences {
if let row = try context.fetch(FetchDescriptor<PersistentSSHPreferences>()).first { return row }
let row = try PersistentSSHPreferences(SSHPreferences(defaults: defaults))
context.insert(row)
return row
}
public func loadSSH() throws -> SSHPreferences { try sshPreferences().load() }
public func saveSSH(_ value: SSHPreferences) throws {
let value = value.normalized
try saveLocal { try sshPreferences().apply(value) }
value.mirror(to: defaults)
}
private func sftpPreferences() throws -> PersistentSFTPPreferences {
if let row = try context.fetch(FetchDescriptor<PersistentSFTPPreferences>()).first { return row }
let row = try PersistentSFTPPreferences(SFTPPreferences(defaults: defaults))
context.insert(row)
return row
}
public func loadSFTP() throws -> SFTPPreferences { try sftpPreferences().load() }
public func saveSFTP(_ value: SFTPPreferences) throws {
let value = value.normalized
try saveLocal { try sftpPreferences().apply(value) }
value.mirror(to: defaults)
}
private func workspacePreferences() throws -> PersistentWorkspacePreferences {
if let row = try context.fetch(FetchDescriptor<PersistentWorkspacePreferences>()).first { return row }
let row = try PersistentWorkspacePreferences(WorkspacePreferences())
context.insert(row)
return row
}
public func loadWorkspacePreferences() throws -> WorkspacePreferences {
try workspacePreferences().load()
}
public func saveWorkspacePreferences(_ value: WorkspacePreferences) throws {
try saveLocal { try workspacePreferences().apply(value) }
}
private func notificationPreferences() throws -> PersistentNotificationPreferences {
if let row = try context.fetch(FetchDescriptor<PersistentNotificationPreferences>()).first { return row }
let row = try PersistentNotificationPreferences(NotificationPreferences())
context.insert(row)
return row
}
public func loadNotificationPreferences() throws -> NotificationPreferences {
try notificationPreferences().load()
}
public func saveNotificationPreferences(_ value: NotificationPreferences) throws {
guard value.isValid else { throw SettingsSyncPersistenceError.invalidEnvelope("v1/notifications/default") }
try saveLocal { try notificationPreferences().apply(value) }
}
private func terminalHighlightPreferences() throws -> PersistentTerminalHighlightPreferences {
if let row = try context.fetch(FetchDescriptor<PersistentTerminalHighlightPreferences>()).first { return row }
let row = try PersistentTerminalHighlightPreferences(.init())
context.insert(row)
return row
}
public func loadTerminalHighlightPreferences() throws -> TerminalHighlightPreferences {
try terminalHighlightPreferences().load()
}
public func saveTerminalHighlightPreferences(_ value: TerminalHighlightPreferences) throws {
guard value.isValid else {
throw SettingsSyncPersistenceError.invalidEnvelope("v1/terminalHighlighting/default")
}
try saveLocal { try terminalHighlightPreferences().apply(value) }
}
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
public final class SyncedAIConfigurationPersistence: AIConfigurationPersisting {
private let store: SettingsSyncPersistence
public init(_ store: SettingsSyncPersistence) { self.store = store }
public func load() throws -> AIConfigurationSnapshot { try store.load().configuration }
public func save(_ snapshot: AIConfigurationSnapshot) throws { try store.saveConfiguration(snapshot) }
}
@MainActor
public final class SyncedSkillLibraryPersistence: SkillLibraryPersisting {
private let store: SettingsSyncPersistence
public init(_ store: SettingsSyncPersistence) { self.store = store }
public func load() throws -> [AgentSkill] { try store.load().skills }
public func save(_ skills: [AgentSkill]) throws { try store.saveSkills(skills) }
}
extension SettingsSyncPersistence: AIWebSearchPersisting {
public func loadWebSearch() throws -> AIWebSearchPreferences { try load().webSearch }
}
extension SettingsSyncPersistence: TerminalPreferencesPersisting {}
extension SettingsSyncPersistence: SSHPreferencesPersisting {}
extension SettingsSyncPersistence: SFTPPreferencesPersisting {}
extension SettingsSyncPersistence: WorkspacePreferencesPersisting {}
extension SettingsSyncPersistence: NotificationPreferencesPersisting {}
extension SettingsSyncPersistence: TerminalHighlightPreferencesPersisting {}