diff --git a/FrameworkTests/MetricsSyncTests.swift b/FrameworkTests/MetricsSyncTests.swift new file mode 100644 index 00000000..8d0e19a6 --- /dev/null +++ b/FrameworkTests/MetricsSyncTests.swift @@ -0,0 +1,146 @@ +import CloudKit +import Foundation +import SwiftData +import TermindApplication +import TermindDomain +import TermindInfrastructure +import TermindPersistence +import Testing + +@Suite @MainActor +struct MetricsSyncTests { + @Test + func legacySelectionsMigrateOnceWithCompleteConfigurationAndOrder() throws { + let defaults = UserDefaults(suiteName: "MetricsSyncTests.\(UUID())")! + let first = MetricsSelection(id: UUID(), enabled: false, disks: ["sda"], interfaces: []) + let second = MetricsSelection(id: UUID(), interfaces: ["eth0"]) + defaults.set(try JSONEncoder().encode([first]), forKey: "metrics.servers") + defaults.set(try JSONEncoder().encode([first, second]), forKey: "mobile.metrics.v1") + let container = try TermindPersistence.makeContainer(inMemory: true) + let store = MetricsSyncPersistence(context: container.mainContext, defaults: defaults) + + try store.prepare() + + #expect(try store.load() == [first, second]) + #expect(try store.journal.pendingEnvelopes().count == 2) + defaults.set(try JSONEncoder().encode([] as [MetricsSelection]), forKey: "metrics.servers") + try store.prepare() + #expect(try store.load() == [first, second]) + } + + @Test + func recordsRoundTripAndRemoteChangesMergePerHost() throws { + let (sourceContainer, source) = try fixture() + let (destinationContainer, destination) = try fixture() + defer { withExtendedLifetime((sourceContainer, destinationContainer)) {} } + let first = MetricsSelection(id: UUID(), enabled: false, disks: ["nvme0n1"]) + let second = MetricsSelection(id: UUID(), interfaces: ["en0"]) + try source.save([first, second]) + + let outgoing = try source.journal.pendingEnvelopes() + let applied = try destination.applyRemote(outgoing) + #expect(applied.snapshot.selections == [first, second]) + + let envelope = try #require(outgoing.first) + let data = try MetricsSyncCodec.encode(envelope) + #expect(try MetricsSyncCodec.decode(data) == envelope) + let record = try MetricsSyncCoordinator.makeRecord( + MetricsOutgoingEntry(envelope: envelope), + recordID: CKRecord.ID( + recordName: envelope.key.recordName, + zoneID: CKRecordZone.ID(zoneName: MetricsSyncCoordinator.zoneName))) + #expect(try MetricsSyncCoordinator.envelope(from: record) == envelope) + + try source.save([second, first]) + let reordered = try destination.applyRemote(source.journal.pendingEnvelopes()) + #expect(reordered.snapshot.selections == [second, first]) + + try source.save([second]) + let deleted = try destination.applyRemote(source.journal.pendingEnvelopes()) + #expect(deleted.snapshot.selections == [second]) + } + + @Test + func missingHostsStayConfiguredAndMaterializeLater() { + let host = TermindDomain.Host(name: "Later", host: "example.invalid") + let preferences = TestMetricsPreferences(values: [MetricsSelection(id: host.id, enabled: false)]) + let library = HostLibraryModel() + let model = ServerMetricsModel( + hosts: library, preferences: preferences, + makeSampler: { TestMetricsSampler() }, + discover: { _, _, _ in throw CancellationError() }) + + model.start() + model.suspend() + #expect(model.servers.isEmpty) + #expect(model.selections.map(\.id) == [host.id]) + + library.upsert(host) + model.reconcile() + #expect(model.servers.map(\.id) == [host.id]) + #expect(!model.servers[0].selection.enabled) + } + + @Test + func failedPersistenceDoesNotChangeRuntimeConfiguration() { + let host = TermindDomain.Host(name: "Stable", host: "example.invalid") + let preferences = TestMetricsPreferences(values: [MetricsSelection(id: host.id)]) + let library = HostLibraryModel() + library.upsert(host) + let model = ServerMetricsModel( + hosts: library, preferences: preferences, + makeSampler: { TestMetricsSampler() }, + discover: { _, _, _ in throw CancellationError() }) + model.start() + model.suspend() + preferences.failSaves = true + + model.setEnabled(false, for: model.servers[0]) + + #expect(model.servers[0].selection.enabled) + #expect(model.storageError == TestMetricsError.failed.localizedDescription) + } + + @Test + func metricsCategoryDefaultsOnWithoutChangingTheMasterPreference() { + let defaults = UserDefaults(suiteName: "MetricsSyncDefaults.\(UUID())")! + defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled) + SettingsKey.migrateSyncPreferences(in: defaults) + #expect(SettingsKey.defaultMetricsSyncEnabled) + #expect(!defaults.bool(forKey: SettingsKey.iCloudSyncEnabled)) + #expect(defaults.object(forKey: SettingsKey.metricsSyncEnabled) == nil) + } + + private func fixture() throws -> (ModelContainer, MetricsSyncPersistence) { + let container = try TermindPersistence.makeContainer(inMemory: true) + let defaults = UserDefaults(suiteName: "MetricsSyncFixture.\(UUID())")! + let store = MetricsSyncPersistence(context: container.mainContext, defaults: defaults) + try store.prepare() + return (container, store) + } +} + +private enum TestMetricsError: LocalizedError { + case failed + var errorDescription: String? { "Metrics persistence failed." } +} + +@MainActor +private final class TestMetricsPreferences: MetricsConfigurationStoring { + var values: [MetricsSelection] + var failSaves = false + + init(values: [MetricsSelection]) { self.values = values } + func load() -> [MetricsSelection] { values } + func save(_ selections: [MetricsSelection]) throws { + if failSaves { throw TestMetricsError.failed } + values = selections + } +} + +nonisolated private struct TestMetricsSampler: MetricsSampling { + func connect(_ configuration: SSHConnectionConfiguration) async throws {} + func sample() async throws -> MetricsSample { throw CancellationError() } + func gpu() async throws -> NvidiaSnapshot? { nil } + func disconnect() async {} +} diff --git a/FrameworkTests/PersistenceCompatibilityTests.swift b/FrameworkTests/PersistenceCompatibilityTests.swift index a99d52a6..36f90b45 100644 --- a/FrameworkTests/PersistenceCompatibilityTests.swift +++ b/FrameworkTests/PersistenceCompatibilityTests.swift @@ -26,6 +26,7 @@ struct PersistenceCompatibilityTests { #expect(providers.map(\.name) == ["Legacy fixture provider"]) } #expect(try container.mainContext.fetchCount(FetchDescriptor()) == 0) + #expect(try container.mainContext.fetchCount(FetchDescriptor()) == 0) } @Test @@ -53,6 +54,6 @@ struct PersistenceCompatibilityTests { try first.mainContext.save() #expect(try second.mainContext.fetchCount(FetchDescriptor()) == 0) #expect(Set(first.schema.entities.map(\.name)) == Set(TermindPersistence.schema.entities.map(\.name))) - #expect(TermindMigrationPlan.schemas.count == 14) + #expect(TermindMigrationPlan.schemas.count == 15) } } diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index f641eb8d..e9421021 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -1790,16 +1790,6 @@ } } }, - "API keys use iCloud Keychain, even when configuration sync is paused." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "API密钥使用iCloud钥匙串,即使配置同步已暂停也会继续同步。" - } - } - } - }, "API Type" : { "extractionState" : "stale", "localizations" : { @@ -8189,6 +8179,16 @@ } } }, + "Monitored servers, pause state, device selections, and card order." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监控的服务器、暂停状态、设备选择和卡片顺序。" + } + } + } + }, "Microphone Unavailable" : { "extractionState" : "stale", "localizations" : { @@ -9490,12 +9490,12 @@ } } }, - "One purchase unlocks multi-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and iCloud sync for your Vault and settings across devices." : { + "One purchase unlocks multi-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and iCloud sync for your Vault, Metrics, and settings across devices." : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "一次买断,永久解锁多窗格工作区、AI助手、终端拖放上传和MCP,并跨设备同步保管库和设置。" + "value" : "一次买断,永久解锁多窗格工作区、AI助手、终端拖放上传和MCP,并跨设备同步保管库、监控和设置。" } } } @@ -12933,6 +12933,16 @@ } } }, + "Sign in to iCloud to sync Metrics." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录iCloud以同步监控。" + } + } + } + }, "Sign in to iCloud to sync settings." : { "extractionState" : "stale", "localizations" : { @@ -13898,12 +13908,12 @@ } } }, - "Sync Vault and Settings across devices" : { + "Sync Vault, Metrics, and Settings across devices" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "跨设备同步保管库和设置" + "value" : "跨设备同步保管库、监控和设置" } } } @@ -14509,6 +14519,16 @@ } } }, + "The iCloud Metrics record %@ is invalid." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud监控记录%@无效。" + } + } + } + }, "The iCloud settings record %@ is invalid." : { "extractionState" : "stale", "localizations" : { @@ -15454,6 +15474,16 @@ } } }, + "This Metrics record requires sync format %lld." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此监控记录需要同步格式%lld。" + } + } + } + }, "This settings record requires sync format %lld." : { "extractionState" : "stale", "localizations" : { diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index e265c521..64a14975 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -48,8 +48,11 @@ public final class ServerMetricsModel: MetricsSnapshotReading { public static let freeCardLimit = 3 public private(set) var servers: [ServerMetric] = [] public private(set) var sleeping = false + public private(set) var storageError: String? + public var selections: [MetricsSelection] { configuredSelections } private var started = false private var stopped = false + private var configuredSelections: [MetricsSelection] = [] private let hosts: any RemoteSessionHostStore private let preferences: any MetricsConfigurationStoring private let makeSampler: @Sendable () -> any MetricsSampling @@ -111,7 +114,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { ) } - public var remainingCardSlots: Int? { cardLimit().map { max(0, $0 - servers.count) } } + public var remainingCardSlots: Int? { cardLimit().map { max(0, $0 - configuredSelections.count) } } #if DEBUG /// Frozen product fixtures never load preferences or start an SSH worker. @@ -123,6 +126,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { cardLimit: { nil } ) servers = rows + configuredSelections = rows.map(\.selection) started = true stopped = true } @@ -142,12 +146,13 @@ public final class ServerMetricsModel: MetricsSnapshotReading { public func start() { guard !started else { return } started = true - var seen = Set() - servers = preferences.load().filter { seen.insert($0.id).inserted }.compactMap { selection in - guard let host = hosts.host(selection.id) else { return nil } - return ServerMetric(selection: selection, name: host.name) + do { + configuredSelections = Self.deduplicated(try preferences.load()) + storageError = nil + } catch { + storageError = error.localizedDescription } - for server in servers { launch(server) } + applyConfiguration(configuredSelections) // Keep the timer independent, but make the executor hop explicit at the // boundary before touching Metrics or its MainActor host store. maintenance = Task.detached { [weak self, clock] in @@ -165,55 +170,38 @@ public final class ServerMetricsModel: MetricsSnapshotReading { /// Admission and insertion are one MainActor operation, shared by all windows. @discardableResult public func add(_ hosts: [Host]) -> Bool { - var seen = Set(servers.map(\.id)) + var seen = Set(configuredSelections.map(\.id)) let additions = hosts.filter { seen.insert($0.id).inserted } guard requestAddition(count: additions.count) else { return false } - guard !additions.isEmpty else { return true } - let rows = additions.map { ServerMetric(selection: MetricsSelection(id: $0.id), name: $0.name) } - servers.append(contentsOf: rows) - save() - for server in rows { launch(server) } + let next = configuredSelections + additions.map { MetricsSelection(id: $0.id) } + guard persist(next) else { return false } + applyConfiguration(next) return true } public func move(_ ids: [UUID], before next: UUID?) { let wanted = Set(ids) - let moving = servers.filter { wanted.contains($0.id) } + let moving = configuredSelections.filter { wanted.contains($0.id) } guard !moving.isEmpty, next.map({ !wanted.contains($0) }) ?? true else { return } - var ordered = servers.filter { !wanted.contains($0.id) } + var ordered = configuredSelections.filter { !wanted.contains($0.id) } let index = next.flatMap { id in ordered.firstIndex { $0.id == id } } ?? ordered.count ordered.insert(contentsOf: moving, at: index) - guard ordered.map(\.id) != servers.map(\.id) else { return } - servers = ordered - save() + guard ordered.map(\.id) != configuredSelections.map(\.id), persist(ordered) else { return } + applyConfiguration(ordered) } public func remove(_ id: UUID) { - cancel(id) - submissions[id] = nil - temporaryKeys[id] = nil - stamps[id] = nil - detailObservers = detailObservers.filter { $0.value != id } - servers.removeAll { $0.id == id } - save() + let next = configuredSelections.filter { $0.id != id } + guard next.count != configuredSelections.count, persist(next) else { return } + applyConfiguration(next) } public func setEnabled(_ enabled: Bool, for server: ServerMetric) { - server.selection.enabled = enabled - cancel(server.id) - server.isConnecting = false - server.authenticationHost = nil - server.hostKey = nil - server.needsAttention = false - if !enabled { - submissions[server.id] = nil - temporaryKeys[server.id] = nil - server.journey.hide() - } - server.authenticationPrompt = nil - server.hostKeyPrompt = nil - save() - launch(server) + guard let index = configuredSelections.firstIndex(where: { $0.id == server.id }) else { return } + var next = configuredSelections + next[index].enabled = enabled + guard next != configuredSelections, persist(next) else { return } + applyConfiguration(next) } public func retry(_ server: ServerMetric) { @@ -229,12 +217,19 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } public func selectDevices(_ server: ServerMetric, disks: Set?, interfaces: Set?) { - server.selection.disks = disks - server.selection.interfaces = interfaces - server.history = [] - server.point = nil - save() - retry(server) + guard let index = configuredSelections.firstIndex(where: { $0.id == server.id }) else { return } + var next = configuredSelections + next[index].disks = disks + next[index].interfaces = interfaces + guard next != configuredSelections, persist(next) else { return } + applyConfiguration(next) + } + + public func applyRemoteSnapshot(_ snapshot: MetricsSyncSnapshot) { + let next = Self.deduplicated(snapshot.selections) + configuredSelections = next + storageError = nil + if started { applyConfiguration(next) } } public func submitAuthentication( @@ -346,11 +341,9 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } public func reconcile() { + applyConfiguration(configuredSelections) for server in servers { - guard let host = hosts.host(server.id) else { - remove(server.id) - continue - } + guard let host = hosts.host(server.id) else { continue } server.name = host.name server.history.removeAll { $0.date < clock.now().addingTimeInterval(-300) } let stamp = connectionStamp(host) @@ -380,7 +373,74 @@ public final class ServerMetricsModel: MetricsSnapshotReading { return result } - private func save() { preferences.save(servers.map(\.selection)) } + private func persist(_ selections: [MetricsSelection]) -> Bool { + do { + try preferences.save(Self.deduplicated(selections)) + storageError = nil + return true + } catch { + storageError = error.localizedDescription + return false + } + } + + private func applyConfiguration(_ selections: [MetricsSelection]) { + let normalized = Self.deduplicated(selections) + configuredSelections = normalized + let previous = Dictionary(uniqueKeysWithValues: servers.map { ($0.id, $0) }) + let configuredIDs = Set(normalized.map(\.id)) + var nextServers: [ServerMetric] = [] + + for selection in normalized { + guard let host = hosts.host(selection.id) else { continue } + if let server = previous[selection.id] { + let enabledChanged = server.selection.enabled != selection.enabled + let devicesChanged = + server.selection.disks != selection.disks + || server.selection.interfaces != selection.interfaces + server.selection = selection + server.name = host.name + if enabledChanged || devicesChanged { + cancel(server.id) + server.isConnecting = false + server.authenticationHost = nil + server.hostKey = nil + server.authenticationPrompt = nil + server.hostKeyPrompt = nil + server.needsAttention = false + if devicesChanged { + server.history = [] + server.point = nil + } + if !selection.enabled { + submissions[server.id] = nil + temporaryKeys[server.id] = nil + server.journey.hide() + } + } + nextServers.append(server) + } else { + nextServers.append(ServerMetric(selection: selection, name: host.name)) + } + } + + for server in servers where !nextServers.contains(where: { $0.id == server.id }) { + cancel(server.id) + if !configuredIDs.contains(server.id) { + submissions[server.id] = nil + temporaryKeys[server.id] = nil + stamps[server.id] = nil + detailObservers = detailObservers.filter { $0.value != server.id } + } + } + servers = nextServers + for server in servers { launch(server) } + } + + private static func deduplicated(_ selections: [MetricsSelection]) -> [MetricsSelection] { + var seen = Set() + return selections.filter { seen.insert($0.id).inserted } + } private func cancel(_ id: UUID) { generations[id] = nil diff --git a/Frameworks/TermindApplication/MetricsConfigurationStoring.swift b/Frameworks/TermindApplication/MetricsConfigurationStoring.swift index ae113573..bdf4057b 100644 --- a/Frameworks/TermindApplication/MetricsConfigurationStoring.swift +++ b/Frameworks/TermindApplication/MetricsConfigurationStoring.swift @@ -2,6 +2,6 @@ import TermindDomain @MainActor public protocol MetricsConfigurationStoring { - func load() -> [MetricsSelection] - func save(_ selections: [MetricsSelection]) + func load() throws -> [MetricsSelection] + func save(_ selections: [MetricsSelection]) throws } diff --git a/Frameworks/TermindApplication/SettingsKey.swift b/Frameworks/TermindApplication/SettingsKey.swift index 244004d7..ae7b0140 100644 --- a/Frameworks/TermindApplication/SettingsKey.swift +++ b/Frameworks/TermindApplication/SettingsKey.swift @@ -3,6 +3,7 @@ import Foundation public nonisolated enum SettingsKey { public static let appLanguage = "app.language" public static let defaultICloudSyncEnabled = true + public static let defaultMetricsSyncEnabled = true public static let defaultSettingsSyncEnabled = false public static let defaultTerminalThemeDark = "Termind Dark" public static let defaultTerminalThemeLight = "Termind Light" @@ -10,6 +11,8 @@ public nonisolated enum SettingsKey { public static let defaultWebSearchBuiltIn = true public static let defaultWebSearchEnabled = true public static let iCloudSyncEnabled = "icloud.sync.enabled" + public static let metricsSyncEnabled = "metrics.sync.enabled" + public static let metricsSyncEnvironment = "metrics.sync.environment" /// Device-local forwarding intent. It is deliberately excluded from Settings and Vault sync. public static let portForwardingEnabledRuleIDs = "portForwarding.enabledRuleIDs" public static let remoteWindowTitleInMenuBar = "terminal.remoteWindowTitleInMenuBar" diff --git a/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift b/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift new file mode 100644 index 00000000..bec47fcd --- /dev/null +++ b/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift @@ -0,0 +1,95 @@ +import Foundation + +public nonisolated struct SyncedMetricsSelection: Codable, Hashable, Sendable { + public var selection: MetricsSelection + public var sortIndex: Int + + public init(selection: MetricsSelection, sortIndex: Int) { + self.selection = selection + self.sortIndex = sortIndex + } +} + +public nonisolated enum MetricsRecordKind: String, Codable, CaseIterable, Sendable { + case selection +} + +public nonisolated struct MetricsRecordKey: Codable, Hashable, Sendable { + public var kind: MetricsRecordKind + public var id: UUID + public var recordName: String { "v1/\(kind.rawValue)/\(id.uuidString.lowercased())" } + + public init(kind: MetricsRecordKind = .selection, id: UUID) { + self.kind = kind + self.id = id + } + + public init?(recordName: String) { + let parts = recordName.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 3, parts[0] == "v1", + let kind = MetricsRecordKind(rawValue: String(parts[1])), + let id = UUID(uuidString: String(parts[2])), + id.uuidString.lowercased() == parts[2] + else { return nil } + self.init(kind: kind, id: id) + } +} + +public nonisolated enum MetricsRecordPayload: Codable, Hashable, Sendable { + case selection(SyncedMetricsSelection) + + public var key: MetricsRecordKey { + switch self { + case .selection(let value): MetricsRecordKey(id: value.selection.id) + } + } +} + +public nonisolated struct MetricsEnvelope: Codable, Hashable, Sendable { + public static let currentFormatVersion = 1 + public var formatVersion = Self.currentFormatVersion + public var key: MetricsRecordKey + public var version: VaultVersion + public var isDeleted = false + public var payload: MetricsRecordPayload? + + public init( + formatVersion: Int = MetricsEnvelope.currentFormatVersion, + key: MetricsRecordKey, version: VaultVersion, isDeleted: Bool = false, + payload: MetricsRecordPayload? = nil + ) { + self.formatVersion = formatVersion + self.key = key + self.version = version + self.isDeleted = isDeleted + self.payload = payload + } + + public var isValid: Bool { + formatVersion == Self.currentFormatVersion + && MetricsRecordKey(recordName: key.recordName) == key + && (isDeleted ? payload == nil : payload?.key == key) + } +} + +public nonisolated struct MetricsSyncSnapshot: Equatable, Sendable { + public var selections: [MetricsSelection] + + public init(selections: [MetricsSelection] = []) { self.selections = selections } + + public var recordPayloads: [MetricsRecordKey: MetricsRecordPayload] { + Dictionary( + uniqueKeysWithValues: selections.enumerated().map { index, selection in + let payload = MetricsRecordPayload.selection( + SyncedMetricsSelection(selection: selection, sortIndex: index)) + return (payload.key, payload) + }) + } +} + +public nonisolated struct MetricsMutationSet: Equatable, Sendable { + public var keys: Set = [] + public static let empty = Self() + public var isEmpty: Bool { keys.isEmpty } + public init(keys: Set = []) { self.keys = keys } +} diff --git a/Frameworks/TermindDomain/Metrics/ServerMetrics.swift b/Frameworks/TermindDomain/Metrics/ServerMetrics.swift index 29bb0e93..5c6e5ea6 100644 --- a/Frameworks/TermindDomain/Metrics/ServerMetrics.swift +++ b/Frameworks/TermindDomain/Metrics/ServerMetrics.swift @@ -1,6 +1,6 @@ import Foundation -public nonisolated struct MetricsSelection: Codable, Equatable, Identifiable, Sendable { +public nonisolated struct MetricsSelection: Codable, Hashable, Identifiable, Sendable { public var id: UUID public var enabled = true /// nil selects all default devices; an empty set deliberately selects none. diff --git a/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift new file mode 100644 index 00000000..a1f7218a --- /dev/null +++ b/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift @@ -0,0 +1,529 @@ +import CloudKit +import CryptoKit +import Foundation +import SwiftData +import TermindApplication +import TermindDomain +import TermindPersistence + +/// Metrics use their own private zone so their default-on category can be paused +/// independently from Vault and Settings. +@MainActor +@Observable +public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sendable { + public nonisolated static let containerIdentifier = "iCloud.com.akinokaede.termind" + public nonisolated static let zoneName = "TermindMetricsV1" + public nonisolated static let recordType = "MetricsItem" + public nonisolated static let subscriptionID = "termind-metrics-v1-changes" + public nonisolated static let inlinePayloadLimit = 700_000 + + private let context: ModelContext + private let persistence: MetricsSyncPersistence + private let journal: MetricsSyncJournal + private let container: CKContainer + private let defaults: UserDefaults + private let environmentIdentifier: String + private let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) + private var engine: CKSyncEngine? + private var operation: Task? + private var operationID = UUID() + private var isReadyToSend = false + private var isStarted = false + private var terminalError: String? + + public private(set) var status: VaultSyncStatus = .checkingAccount + public var didApplyRemote: ((MetricsSyncSnapshot, Set) -> Void)? + + public var isEnabled: Bool { + guard + defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool + ?? SettingsKey.defaultICloudSyncEnabled + else { return false } + return defaults.object(forKey: SettingsKey.metricsSyncEnabled) as? Bool + ?? SettingsKey.defaultMetricsSyncEnabled + } + + public init( + persistence: MetricsSyncPersistence, + container: CKContainer = CKContainer(identifier: MetricsSyncCoordinator.containerIdentifier), + defaults: UserDefaults = .standard, + environmentIdentifier: String = MetricsSyncCoordinator.runtimeEnvironmentIdentifier + ) { + context = persistence.context + self.persistence = persistence + journal = persistence.journal + self.container = container + self.defaults = defaults + self.environmentIdentifier = environmentIdentifier + super.init() + persistence.didRecordChanges = { [weak self] mutations in self?.noteLocalChanges(mutations) } + } + + public nonisolated static var runtimeEnvironmentIdentifier: String { + Bundle.main.object(forInfoDictionaryKey: "TermindCloudKitEnvironment") as? String ?? "Unknown" + } + + public func start() { + guard isEnabled else { + pause() + return + } + guard !isStarted else { return } + isStarted = true + run { [weak self] in try await self?.prepareAndSynchronize() } + } + + public func setEnabled(_ enabled: Bool) { + defaults.set(enabled, forKey: SettingsKey.metricsSyncEnabled) + if enabled { start() } else { pause() } + } + + public func pause() { + operationID = UUID() + isReadyToSend = false + operation?.cancel() + operation = nil + isStarted = false + let engine = self.engine + self.engine = nil + status = .paused + if let engine { Task { await engine.cancelOperations() } } + } + + public func retry() { + guard isEnabled else { return } + terminalError = nil + if engine == nil { + isStarted = false + start() + } else { + run { [weak self] in try await self?.synchronizeNow() } + } + } + + public func noteLocalChanges(_ mutations: MetricsMutationSet) { + guard !mutations.isEmpty else { return } + addPendingChanges(mutations.keys) + guard isEnabled, engine != nil else { return } + run { [weak self] in try await self?.sendPendingChanges() } + } + + public 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 + + addPendingChanges(Set(try journal.pendingEnvelopes().map(\.key))) + try context.save() + _ = try await container.privateCloudDatabase.modifyRecordZones( + saving: [CKRecordZone(zoneID: zoneID)], deleting: []) + 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 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 } + isReadyToSend = true + try await sendPendingChanges() + try updateUpToDateStatus() + } + + private func sendPendingChanges() async throws { + guard let engine, isEnabled, isReadyToSend else { return } + let pending = try journal.pendingEnvelopes() + guard !pending.isEmpty else { return } + addPendingChanges(Set(pending.map(\.key))) + status = .syncing(pendingCount: pending.count) + try await engine.sendChanges() + } + + private func addPendingChanges(_ keys: Set) { + guard let engine else { return } + engine.state.add( + pendingRecordZoneChanges: keys.map { + .saveRecord(CKRecord.ID(recordName: $0.recordName, zoneID: zoneID)) + }) + } + + private func updateUpToDateStatus() throws { + if let reason = try journal.firstQuarantineReason() { + status = .failed(reason) + 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) + } + } + + public nonisolated func nextFetchChangesOptions( + _ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine + ) async -> CKSyncEngine.FetchChangesOptions { + .init(scope: .zoneIDs([CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName)])) + } + + public nonisolated func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + await handle(event, engine: syncEngine) + } + + public nonisolated func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + await makeChangeBatch(context, engine: syncEngine) + } + + private func makeChangeBatch( + _ context: CKSyncEngine.SendChangesContext, engine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + guard self.engine === engine, isReadyToSend, isEnabled else { 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) + } + } + + private func recordToSave(_ recordID: CKRecord.ID) -> CKRecord? { + guard recordID.zoneID == zoneID, let key = MetricsRecordKey(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 + } + } + + 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 Metrics.")) + 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 + 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: [MetricsRecordKey: CKRecord] = [:] + var envelopes: [MetricsEnvelope] = [] + 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 persistence.applyRemote(envelopes) + for (key, record) in recordsByKey { + try journal.updateSystemFields(Self.systemFields(of: record), for: key) + } + try journal.markNeedsUpload(result.needsUpload) + let physicallyDeleted = Set( + changes.deletions.compactMap { + $0.recordID.zoneID == zoneID ? MetricsRecordKey(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 persistence.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) + } + 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() + engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))]) + addPendingChanges(Set(try journal.pendingEnvelopes().map(\.key))) + try context.save() + } + + private func prepareForCurrentEnvironment() throws { + guard defaults.string(forKey: SettingsKey.metricsSyncEnvironment) != environmentIdentifier else { return } + try journal.markAllEntriesNeedsUploadAfterEnvironmentChange() + try context.save() + defaults.set(environmentIdentifier, forKey: SettingsKey.metricsSyncEnvironment) + terminalError = nil + } + + public nonisolated static func makeRecord( + _ outgoing: MetricsOutgoingEntry, 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 MetricsSyncCodec.encode(envelope) + record["formatVersion"] = NSNumber(value: envelope.formatVersion) + record["kind"] = envelope.key.kind.rawValue as NSString + record["entityID"] = envelope.key.id.uuidString.lowercased() 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"] = hash(payload) 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 + } + + public nonisolated static func envelope(from record: CKRecord) throws -> MetricsEnvelope { + let data = try payloadData(from: record) + guard record["payloadHash"] as? String == hash(data) else { + throw MetricsSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + let envelope = try MetricsSyncCodec.decode(data) + guard envelope.key.recordName == record.recordID.recordName, envelope.isValid else { + throw MetricsSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + return envelope + } + + public 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 MetricsSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + + public 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: MetricsRecordKey) throws -> URL { + let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appending(path: "Termind/MetricsSyncAssets", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appending(path: key.recordName.replacingOccurrences(of: "/", with: "-")) + } + + private nonisolated static func hash(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + 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 Metrics.")) + } + 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 Metrics.") + case .restricted: String(localized: "iCloud access is restricted on this Mac.") + case .couldNotDetermine: String(localized: "The iCloud account could not be checked.") + case .temporarilyUnavailable: String(localized: "iCloud is temporarily unavailable.") + case .available: "" + @unknown default: String(localized: "iCloud is unavailable.") + } + } +} diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index 0661a5b2..fbd1b474 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -616,6 +616,16 @@ public enum TermindSchemaV14: VersionedSchema { } } +public enum TermindSchemaV15: VersionedSchema { + public static let versionIdentifier = Schema.Version(15, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV14.models + [ + PersistentMetricsSyncEntry.self, PersistentMetricsSyncState.self, + PersistentMetricsSyncQuarantine.self, + ] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -623,7 +633,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV4.self, TermindSchemaV5.self, TermindSchemaV6.self, TermindSchemaV7.self, TermindSchemaV8.self, TermindSchemaV9.self, TermindSchemaV10.self, TermindSchemaV11.self, TermindSchemaV12.self, TermindSchemaV13.self, - TermindSchemaV14.self, + TermindSchemaV14.self, TermindSchemaV15.self, ] } @@ -677,6 +687,9 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight( fromVersion: TermindSchemaV13.self, toVersion: TermindSchemaV14.self ), + MigrationStage.lightweight( + fromVersion: TermindSchemaV14.self, toVersion: TermindSchemaV15.self + ), ] } } diff --git a/Frameworks/TermindPersistence/MetricsSyncJournal.swift b/Frameworks/TermindPersistence/MetricsSyncJournal.swift new file mode 100644 index 00000000..07a3c457 --- /dev/null +++ b/Frameworks/TermindPersistence/MetricsSyncJournal.swift @@ -0,0 +1,83 @@ +import Foundation +import SwiftData +import TermindDomain + +@Model +public final class PersistentMetricsSyncEntry { + @Attribute(.unique) public var recordName = "" + public var kind = MetricsRecordKind.selection.rawValue + public var entityID = "" + public var envelopeData = Data() + public var systemFieldsData: Data? + public var needsUpload = false + + public init(envelope: MetricsEnvelope, needsUpload: Bool) throws { + try apply(envelope, needsUpload: needsUpload) + } + + public func apply(_ envelope: MetricsEnvelope, needsUpload: Bool) throws { + recordName = envelope.key.recordName + kind = envelope.key.kind.rawValue + entityID = envelope.key.id.uuidString.lowercased() + envelopeData = try MetricsSyncCodec.encode(envelope) + self.needsUpload = needsUpload + } + + public var envelope: MetricsEnvelope? { try? MetricsSyncCodec.decode(envelopeData) } +} + +@Model +public final class PersistentMetricsSyncState { + @Attribute(.unique) public var key = "metrics-sync" + public var installationID = UUID() + public var engineStateData: Data? + public var boundUserRecordName: String? + public var pendingUserRecordName: String? + public var bootstrapCompleted = false + public var lastSuccessfulSync: Date? + + public init() {} +} + +@Model +public final class PersistentMetricsSyncQuarantine { + @Attribute(.unique) public var recordName = "" + public var payloadData = Data() + public var reason = "" + public var receivedAt = Date() + + public init(recordName: String, payloadData: Data, reason: String) { + self.recordName = recordName + self.payloadData = payloadData + self.reason = reason + } +} + +public nonisolated enum MetricsSyncPersistenceError: LocalizedError { + case invalidEnvelope(String) + case unsupportedFormat(Int) + + public var errorDescription: String? { + switch self { + case .invalidEnvelope(let recordName): + String(localized: "The iCloud Metrics record \(recordName) is invalid.") + case .unsupportedFormat(let version): + String(localized: "This Metrics record requires sync format \(version).") + } + } +} + +public nonisolated enum MetricsSyncCodec { + public static func encode(_ envelope: MetricsEnvelope) throws -> Data { + guard envelope.isValid else { + throw MetricsSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(envelope) + } + + public static func decode(_ data: Data) throws -> MetricsEnvelope { + try JSONDecoder().decode(MetricsEnvelope.self, from: data) + } +} diff --git a/Frameworks/TermindPersistence/MetricsSyncStore.swift b/Frameworks/TermindPersistence/MetricsSyncStore.swift new file mode 100644 index 00000000..befa9f25 --- /dev/null +++ b/Frameworks/TermindPersistence/MetricsSyncStore.swift @@ -0,0 +1,342 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain + +public nonisolated struct MetricsRemoteMerge: Sendable { + public var accepted: [MetricsEnvelope] = [] + public var needsUpload: Set = [] +} + +public nonisolated struct MetricsOutgoingEntry: Sendable { + public var envelope: MetricsEnvelope + public var systemFieldsData: Data? + + public init(envelope: MetricsEnvelope, systemFieldsData: Data? = nil) { + self.envelope = envelope + self.systemFieldsData = systemFieldsData + } +} + +@MainActor +public final class MetricsSyncJournal { + private let context: ModelContext + + public init(context: ModelContext) { self.context = context } + + public func state() throws -> PersistentMetricsSyncState { + var descriptor = FetchDescriptor() + descriptor.fetchLimit = 1 + if let existing = try context.fetch(descriptor).first { return existing } + let state = PersistentMetricsSyncState() + context.insert(state) + return state + } + + @discardableResult + public func bootstrapIfNeeded(_ snapshot: MetricsSyncSnapshot) throws -> MetricsMutationSet { + let state = try state() + guard !state.bootstrapCompleted else { return .empty } + var entries = try entriesByName() + var changed = Set() + for payload in snapshot.recordPayloads.values { + let key = payload.key + guard entries[key.recordName] == nil else { continue } + let envelope = MetricsEnvelope( + key: key, version: .baseline(writerID: state.installationID), payload: payload) + try store(envelope, needsUpload: true, in: &entries) + changed.insert(key) + } + state.bootstrapCompleted = true + return MetricsMutationSet(keys: changed) + } + + public func recordLocalChanges( + from oldSnapshot: MetricsSyncSnapshot, to newSnapshot: MetricsSyncSnapshot, + now: Date = .now + ) throws -> MetricsMutationSet { + let state = try state() + let old = oldSnapshot.recordPayloads + let new = newSnapshot.recordPayloads + var entries = try entriesByName() + var changed = Set() + + for (key, payload) in new where old[key] != payload { + let row = entries[key.recordName] + let envelope = MetricsEnvelope( + 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 = MetricsEnvelope( + 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 MetricsMutationSet(keys: changed) + } + + public func pendingEnvelopes() throws -> [MetricsEnvelope] { + try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.needsUpload }) + ).compactMap(\.envelope) + } + + public func outgoingEntry(for key: MetricsRecordKey) throws -> MetricsOutgoingEntry? { + guard let row = try entry(recordName: key.recordName), let envelope = row.envelope else { return nil } + return MetricsOutgoingEntry(envelope: envelope, systemFieldsData: row.systemFieldsData) + } + + public func envelope(for key: MetricsRecordKey) throws -> MetricsEnvelope? { + try entry(recordName: key.recordName)?.envelope + } + + public func mergeDecision(for remote: [MetricsEnvelope]) throws -> MetricsRemoteMerge { + var result = MetricsRemoteMerge() + let entries = try entriesByName() + var candidates: [MetricsRecordKey: MetricsEnvelope] = [:] + for envelope in remote { + guard envelope.isValid else { + throw MetricsSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + if let previous = candidates[envelope.key] { + if envelope.version < previous.version { continue } + let previousData = try MetricsSyncCodec.encode(previous) + let incomingData = try MetricsSyncCodec.encode(envelope) + if envelope.version == previous.version, + !previousData.lexicographicallyPrecedes(incomingData) + { + continue + } + } + candidates[envelope.key] = envelope + } + + for envelope in candidates.values { + guard envelope.formatVersion <= MetricsEnvelope.currentFormatVersion else { + throw MetricsSyncPersistenceError.unsupportedFormat(envelope.formatVersion) + } + guard envelope.isValid else { + throw MetricsSyncPersistenceError.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 MetricsSyncCodec.encode(local) + let remoteData = try MetricsSyncCodec.encode(envelope) + if localData.lexicographicallyPrecedes(remoteData) { + result.accepted.append(envelope) + } else { + result.needsUpload.insert(local.key) + } + } + } + return result + } + + public func acceptRemote(_ envelopes: [MetricsEnvelope]) throws { + var entries = try entriesByName() + for envelope in envelopes { try store(envelope, needsUpload: false, in: &entries) } + } + + public func markNeedsUpload(_ keys: Set) throws { + let entries = try entriesByName() + for key in keys { entries[key.recordName]?.needsUpload = true } + } + + public func markUploaded(_ key: MetricsRecordKey, version: VaultVersion) throws { + guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else { return } + row.needsUpload = false + } + + public func updateSystemFields(_ data: Data?, for key: MetricsRecordKey) throws { + try entry(recordName: key.recordName)?.systemFieldsData = data + } + + public func quarantine(recordName: String, payloadData: Data, reason: String) throws { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.recordName == recordName }) + descriptor.fetchLimit = 1 + if let row = try context.fetch(descriptor).first { + row.payloadData = payloadData + row.reason = reason + row.receivedAt = .now + } else { + context.insert( + PersistentMetricsSyncQuarantine( + recordName: recordName, payloadData: payloadData, reason: reason)) + } + } + + public func clearQuarantine(recordName: String) throws { + try context.delete( + model: PersistentMetricsSyncQuarantine.self, + where: #Predicate { $0.recordName == recordName }) + } + + public func firstQuarantineReason() throws -> String? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.receivedAt, order: .reverse)]) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first?.reason + } + + public func markAllLiveRecordsNeedsUploadForNewAccount() throws { + for row in try context.fetch(FetchDescriptor()) { + guard let envelope = row.envelope else { continue } + if envelope.isDeleted { + context.delete(row) + } else { + row.needsUpload = true + row.systemFieldsData = nil + } + } + let state = try state() + state.engineStateData = nil + state.lastSuccessfulSync = nil + } + + public func markAllEntriesNeedsUploadAfterZoneReset() throws { + for row in try context.fetch(FetchDescriptor()) { + row.needsUpload = true + row.systemFieldsData = nil + } + let state = try state() + state.engineStateData = nil + state.lastSuccessfulSync = nil + } + + public func markAllEntriesNeedsUploadAfterEnvironmentChange() throws { + try markAllEntriesNeedsUploadAfterZoneReset() + } + + private func entry(recordName: String) throws -> PersistentMetricsSyncEntry? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.recordName == recordName }) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + private func store( + _ envelope: MetricsEnvelope, needsUpload: Bool, + in entries: inout [String: PersistentMetricsSyncEntry] + ) throws { + if let row = entries[envelope.key.recordName] { + try row.apply(envelope, needsUpload: needsUpload) + } else { + let row = try PersistentMetricsSyncEntry(envelope: envelope, needsUpload: needsUpload) + context.insert(row) + entries[envelope.key.recordName] = row + } + } + + private func entriesByName() throws -> [String: PersistentMetricsSyncEntry] { + Dictionary( + try context.fetch(FetchDescriptor()).map { ($0.recordName, $0) }, + uniquingKeysWith: { first, duplicate in + if let firstVersion = first.envelope?.version, + let duplicateVersion = duplicate.envelope?.version, + firstVersion < duplicateVersion + { + return duplicate + } + return first + }) + } +} + +@MainActor +public final class MetricsSyncPersistence: MetricsConfigurationStoring { + public let context: ModelContext + public let journal: MetricsSyncJournal + private let defaults: UserDefaults + public var didRecordChanges: ((MetricsMutationSet) -> Void)? + + public init(context: ModelContext, defaults: UserDefaults = .standard) { + self.context = context + self.defaults = defaults + journal = MetricsSyncJournal(context: context) + } + + public func prepare() throws { + var mutations = MetricsMutationSet.empty + try context.commit { + let state = try journal.state() + guard !state.bootstrapCompleted else { return } + mutations = try journal.bootstrapIfNeeded(MetricsSyncSnapshot(selections: legacySelections())) + } + didRecordChanges?(mutations) + } + + public func load() throws -> [MetricsSelection] { try materialize().selections } + + public func save(_ selections: [MetricsSelection]) throws { + var mutations = MetricsMutationSet.empty + try context.commit { + let previous = try materialize() + try journal.bootstrapIfNeeded(previous) + mutations = try journal.recordLocalChanges( + from: previous, to: MetricsSyncSnapshot(selections: Self.deduplicated(selections))) + } + didRecordChanges?(mutations) + } + + public func applyRemote(_ envelopes: [MetricsEnvelope]) throws + -> (snapshot: MetricsSyncSnapshot, acceptedKeys: Set, needsUpload: Set) + { + var merge = MetricsRemoteMerge() + var snapshot = try materialize() + 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) + snapshot = try materialize() + try journal.markNeedsUpload(merge.needsUpload) + } + return (snapshot, Set(merge.accepted.map(\.key)), merge.needsUpload) + } + + private func materialize() throws -> MetricsSyncSnapshot { + var rows: [SyncedMetricsSelection] = [] + for row in try context.fetch(FetchDescriptor()) { + guard let envelope = row.envelope, !envelope.isDeleted else { continue } + if case .selection(let value) = envelope.payload { rows.append(value) } + } + rows.sort { + ($0.sortIndex, $0.selection.id.uuidString) < ($1.sortIndex, $1.selection.id.uuidString) + } + return MetricsSyncSnapshot(selections: Self.deduplicated(rows.map(\.selection))) + } + + private func legacySelections() -> [MetricsSelection] { + let keys = ["metrics.servers", "mobile.metrics.v1"] + return Self.deduplicated( + keys.flatMap { key -> [MetricsSelection] in + guard let data = defaults.data(forKey: key) else { return [] } + return (try? JSONDecoder().decode([MetricsSelection].self, from: data)) ?? [] + }) + } + + private static func deduplicated(_ values: [MetricsSelection]) -> [MetricsSelection] { + var seen = Set() + return values.filter { seen.insert($0.id).inserted } + } +} diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 8e2cdf9b..fcad6820 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -5,7 +5,7 @@ import SwiftData /// choose storage and own the container lifetime, never the model set. @MainActor public enum TermindPersistence { - public static let schema = Schema(versionedSchema: TermindSchemaV14.self) + public static let schema = Schema(versionedSchema: TermindSchemaV15.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 4543e350..69ca2e84 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -42,7 +42,7 @@ a namespace: the module and responsibility-specific name express ownership. would otherwise make lookup ambiguous. Presentation of session-context chips belongs to the desktop feature extension, not the domain value. -All SwiftData models and V1–V14 schemas have one definition in Persistence, +All SwiftData models and V1–V15 schemas have one definition in Persistence, including sync journals' records and secret-location metadata. Platform adapters can still access public persistent records during incremental extraction, but feature views cannot access SwiftData or construct transports. Neither moving a @@ -152,7 +152,7 @@ infrastructure, persistence, or presentation responsibility. Repository data is persistent truth. Runtime connection state is kept in a connection registry and is never persisted onto domain records. Port forwarding's Enabled choice is a device-local `UserDefaults` intent rather than runtime state: -it is not included in Vault/Settings sync, while macOS restores it at launch and +it is not included in Vault/Metrics/Settings sync, while macOS restores it at launch and iOS restores it whenever the app becomes active after background suspension. SSH command history is created only after an authenticated shell-integration @@ -440,8 +440,8 @@ Linux procfs and optional `nvidia-smi -q -x` are read by Infrastructure with bou output and command deadlines. GPU failure does not erase base metrics. Servers without NVIDIA hardware omit the GPU section entirely. Missing values and invalid counter deltas remain unknown; remote uptime determines -rates. History is an in-memory five-minute window; only host/device selections -persist in local preferences. GPU identity uses NVIDIA UUID, and XML external +rates. History is an in-memory five-minute window; ordered host selections, +pause state and device selections persist through the Metrics store. GPU identity uses NVIDIA UUID, and XML external entities are disabled. There is no WidgetKit extension, App Group, remote agent, or dependency on AgentKit for monitoring. @@ -488,7 +488,8 @@ or process environment variables are collected. Metrics uses ManagedCardPage/CardTile for the same surfaces, hover, native selection, marquee, drag ghosts, and reordering contract as Hosts. Its local monitor order is persisted independently of Vault host order and moving rows -does not reconnect them. Metrics opts into single-click activation; drag gestures retain the shared +does not reconnect them. The order syncs across devices; adaptive columns, process sorting, +search, detail navigation and menu-bar visibility remain local. Metrics opts into single-click activation; drag gestures retain the shared reordering behavior and Shift-click extends selection without opening details. Add Server is a window-toolbar creation action backed by the shared host chooser; Show in Menu Bar lives only in Settings. The page does not duplicate its title @@ -573,12 +574,12 @@ to disabled controllers, and test-host launches never enable live requests. ## 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. +The iCloud page has one master preference and separate Vault/Metrics/Settings selections. +Vault and Metrics are selected by default; Settings is opt-in. Disabling the master preserves +all selections. Upgrading with sync disabled keeps the master disabled. +Pro access gates all three CloudKit coordinators, whose zones, subscriptions, account +bindings and journals are independent. Metrics uses `TermindMetricsV1`, Settings uses +`TermindAIConfigurationV1`, and the existing `TermindVaultV1` protocol is unchanged. Shared `TerminalSettingsModel` and `CommandHistoryModel` live in Application, with repository protocols there and their concrete SwiftData implementations in Persistence. @@ -615,6 +616,12 @@ 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. + +Metrics configuration uses one versioned record per host. V15 adds its independent +journal, account state and quarantine, and imports the old desktop and mobile defaults +at baseline versions. Records whose Vault host is absent remain persisted but hidden +until the host appears. Live samples, history, prompts, credentials and platform UI +preferences never enter the Metrics zone. The built-in MCP listener token never enters this path. 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. @@ -715,7 +722,7 @@ is absent from restoration descriptors, so reconnect, foreground restoration and links can never replay it. Temporary execution targets do not mutate the saved Snippet. Mobile Settings exposes full terminal themes, font controls, custom text, shell integration, -Vault/Settings sync, providers, model discovery and role assignments. The macOS window-title +Vault/Metrics/Settings sync, providers, model discovery and role assignments. The macOS window-title preference remains in synchronized records but has no mobile control. Search belongs to each workspace and uses Ghostty's search actions; history insertion uses the paste path without appending Enter. Only authenticated integration events produce history rows; interrupted diff --git a/Memory/SETTINGS_SYNC_RELEASE.md b/Memory/SETTINGS_SYNC_RELEASE.md index f41620f4..b8c81954 100644 --- a/Memory/SETTINGS_SYNC_RELEASE.md +++ b/Memory/SETTINGS_SYNC_RELEASE.md @@ -1,6 +1,7 @@ # Settings sync release verification -Settings sync ships disabled by default under the Settings checkbox. Vault keeps +Settings sync ships disabled by default under the Settings checkbox. Vault and Metrics are +selected by default. 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. @@ -10,7 +11,7 @@ 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: +Before shipping the app and privacy policy 1.5 together: - In `iCloud.com.akinokaede.termind`, create/validate the Development `AIConfigurationItem` record type, then deploy its schema to Production. Use the same @@ -31,9 +32,15 @@ Before shipping the app and privacy policy 1.4 together: state, port, exposed tools and bearer token remain independent. An older synchronized listener token must be verified locally before its iCloud Keychain copy is removed. - Match the privacy policy effective date to the actual release date before - publishing the website. The prepared date is September 10, 2026. + publishing the website. The prepared date is September 12, 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. + +Metrics adds private zone `TermindMetricsV1`, record type `MetricsItem`, and subscription +`termind-metrics-v1-changes`. Before release, create and deploy that schema with the same +envelope field types as Vault and Settings, then verify ordered selections, pause state and +disk/network filters between signed macOS and iOS devices. Confirm a Metrics record received +before its Vault host remains hidden and appears after the host arrives. diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 3bd36a6a..8c3f1407 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -52,6 +52,7 @@ final class MobileAppModel { let connectionHistory: ConnectionHistoryModel let vaultSync: VaultSyncCoordinator? let settingsSync: SettingsSyncCoordinator? + let metricsSync: MetricsSyncCoordinator? let skills: SkillLibraryModel let ai: AIConfigurationModel let commandGenerator: CommandGeneratorController @@ -120,6 +121,8 @@ final class MobileAppModel { SettingsKey.migrateSyncPreferences(in: settingsDefaults) let settingsPersistence = SettingsSyncPersistence(context: ModelContext(container), defaults: settingsDefaults) try settingsPersistence.prepare() + let metricsPersistence = MetricsSyncPersistence(context: ModelContext(container), defaults: settingsDefaults) + try metricsPersistence.prepare() let aiSecrets = AISecretStore(context: settingsPersistence.context, keychain: secrets) terminalSettings = TerminalSettingsModel(persistence: settingsPersistence) commandHistory = CommandHistoryModel( @@ -132,6 +135,10 @@ final class MobileAppModel { memoryOnly ? nil : SettingsSyncCoordinator(persistence: settingsPersistence, secrets: aiSecrets, defaults: settingsDefaults) + metricsSync = + memoryOnly + ? nil + : MetricsSyncCoordinator(persistence: metricsPersistence, defaults: settingsDefaults) vaultSync = memoryOnly ? nil : VaultSyncCoordinator(context: vaultContext, defaults: settingsDefaults) ai = AIConfigurationModel( persistence: SyncedAIConfigurationPersistence(settingsPersistence), secrets: secrets, @@ -139,7 +146,7 @@ final class MobileAppModel { commandGenerator = CommandGeneratorController(ai: ai) skills = SkillLibraryModel(persistence: SyncedSkillLibraryPersistence(settingsPersistence)) library = HostLibraryModel(persistence: persistence, secrets: secrets) - metrics = MobileMetricsModel(defaults: memoryOnly ? nil : defaults, hosts: library) + metrics = MobileMetricsModel(preferences: metricsPersistence, hosts: library) assistant = MobileAssistantModel( configuration: ai, repository: SwiftDataAgentRunRepository(context: ModelContext(container)), @@ -185,6 +192,9 @@ final class MobileAppModel { self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal) self?.skills.applyRemoteSnapshot(snapshot.skills) } + metricsSync?.didApplyRemote = { [weak self] snapshot, _ in + self?.metrics.core.applyRemoteSnapshot(snapshot) + } if let data = self.defaults?.data(forKey: "mobile.workspaces.v1") { let descriptors = try JSONDecoder().decode([MobileWorkspaceDescriptor].self, from: data) workspaces = descriptors.filter { library.host($0.hostID) != nil }.map { @@ -290,6 +300,7 @@ final class MobileAppModel { let isBackground = scenePhases.values.allSatisfy { $0 == .background } if isBackground && !wasBackground { vaultSync?.pause() + metricsSync?.pause() settingsSync?.pause() metrics.pause() outbox.stop() @@ -322,9 +333,11 @@ final class MobileAppModel { let foreground = scenePhases.values.contains(.active) if foreground && pro.canUse(.vaultSync) { vaultSync?.start() + metricsSync?.start() settingsSync?.start() } else { vaultSync?.pause() + metricsSync?.pause() settingsSync?.pause() } } diff --git a/TFI/Application/Metrics/MobileMetricsModel.swift b/TFI/Application/Metrics/MobileMetricsModel.swift index d4951ae0..9a639b3b 100644 --- a/TFI/Application/Metrics/MobileMetricsModel.swift +++ b/TFI/Application/Metrics/MobileMetricsModel.swift @@ -12,13 +12,13 @@ final class MobileMetricsModel { private let hosts: any RemoteSessionHostStore private let limit = MobileMetricsLimit() var servers: [ServerMetric] { core.servers } - var selections: [MetricsSelection] { servers.map(\.selection) } + var selections: [MetricsSelection] { core.selections } - init(defaults: UserDefaults?, hosts: any RemoteSessionHostStore) { + init(preferences: any MetricsConfigurationStoring, hosts: any RemoteSessionHostStore) { self.hosts = hosts let limit = limit core = ServerMetricsModel( - hosts: hosts, preferences: MobileMetricsPreferences(defaults: defaults), + hosts: hosts, preferences: preferences, makeSampler: { SSHMetricsSampler(session: SSHExecSession()) }, discover: { host, proxy, jumps in try await SSHSessionActor.discoverHostKey( @@ -26,6 +26,10 @@ final class MobileMetricsModel { }, cardLimit: { limit.maximum }) } + convenience init(defaults: UserDefaults?, hosts: any RemoteSessionHostStore) { + self.init(preferences: MobileMetricsPreferences(defaults: defaults), hosts: hosts) + } + func add(_ id: UUID, pro: Bool) -> Bool { limit.maximum = pro ? nil : ServerMetricsModel.freeCardLimit guard let host = hosts.host(id) else { return false } @@ -44,13 +48,18 @@ final class MobileMetricsModel { @MainActor private final class MobileMetricsPreferences: MetricsConfigurationStoring { private let defaults: UserDefaults? + private var memory: [MetricsSelection] = [] + init(defaults: UserDefaults?) { self.defaults = defaults } + func load() -> [MetricsSelection] { defaults?.data(forKey: "mobile.metrics.v1").flatMap { try? JSONDecoder().decode([MetricsSelection].self, from: $0) - } ?? [] + } ?? memory } + func save(_ values: [MetricsSelection]) { + memory = values if let data = try? JSONEncoder().encode(values) { defaults?.set(data, forKey: "mobile.metrics.v1") } } } diff --git a/TFI/Features/Commerce/MobileProView.swift b/TFI/Features/Commerce/MobileProView.swift index a8d4af1f..ea431365 100644 --- a/TFI/Features/Commerce/MobileProView.swift +++ b/TFI/Features/Commerce/MobileProView.swift @@ -80,7 +80,7 @@ struct MobileProView: View { Divider().padding(.leading, 52) feature("Connect MCP tools and run the built-in MCP Server", symbol: "server.rack") Divider().padding(.leading, 52) - feature("Sync Vault and Settings across devices", symbol: "icloud") + feature("Sync Vault, Metrics, and Settings across devices", symbol: "icloud") } .background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) .overlay { diff --git a/TFI/Features/Metrics/MobileMetricsView.swift b/TFI/Features/Metrics/MobileMetricsView.swift index 67be274a..915c331d 100644 --- a/TFI/Features/Metrics/MobileMetricsView.swift +++ b/TFI/Features/Metrics/MobileMetricsView.swift @@ -57,7 +57,11 @@ struct MobileMetricsView: View { select: { host in selecting = false if !app.metrics.add(host.id, pro: app.pro.hasProAccess) { - app.pro.presentPaywall(for: .unlimitedMetrics) + if let error = app.metrics.core.storageError { + app.error = error + } else { + app.pro.presentPaywall(for: .unlimitedMetrics) + } } }) }.mobileSheetPresentation(compact: true) @@ -186,8 +190,13 @@ private struct MobileMetricsProcessesView: View { if server.processesFailed { Text("Process metrics unavailable").foregroundStyle(.secondary) } ForEach(rows) { process in HStack { - Text(String(process.pid)).font(.caption.monospaced()).foregroundStyle(.secondary).frame( - width: 48, alignment: .leading) + Text(String(process.pid)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: 64, alignment: .leading) + .layoutPriority(1) Text(process.name).lineLimit(1) Spacer(minLength: 4) VStack(alignment: .trailing) { diff --git a/TFI/Features/Settings/MobileSyncSettingsView.swift b/TFI/Features/Settings/MobileSyncSettingsView.swift index 642d3a53..6dc0b68b 100644 --- a/TFI/Features/Settings/MobileSyncSettingsView.swift +++ b/TFI/Features/Settings/MobileSyncSettingsView.swift @@ -4,9 +4,10 @@ import TermindDomain struct MobileSyncSettingsView: View { let app: MobileAppModel - @AppStorage(SettingsKey.iCloudSyncEnabled) private var enabled = true - @AppStorage(SettingsKey.vaultSyncEnabled) private var vault = true - @AppStorage(SettingsKey.settingsSyncEnabled) private var settings = false + @AppStorage(SettingsKey.iCloudSyncEnabled) private var enabled = SettingsKey.defaultICloudSyncEnabled + @AppStorage(SettingsKey.vaultSyncEnabled) private var vault = SettingsKey.defaultVaultSyncEnabled + @AppStorage(SettingsKey.metricsSyncEnabled) private var metrics = SettingsKey.defaultMetricsSyncEnabled + @AppStorage(SettingsKey.settingsSyncEnabled) private var settings = SettingsKey.defaultSettingsSyncEnabled var body: some View { Form { @@ -25,6 +26,16 @@ struct MobileSyncSettingsView: View { Text("Hosts, identities, snippets, known hosts, and port forwards.").font(.caption).foregroundStyle( .secondary) }.disabled(!enabled || !app.pro.canUse(.vaultSync)) + Section { + Toggle("Metrics", isOn: selection(metrics, SettingsKey.metricsSyncEnabled)) + if metrics { + status( + app.metricsSync?.status, retry: { app.metricsSync?.retry() }, + merge: { app.metricsSync?.confirmAccountSwitchMerge() }) + } + Text("Monitored servers, pause state, device selections, and card order.") + .font(.caption).foregroundStyle(.secondary) + }.disabled(!enabled || !app.pro.canUse(.vaultSync)) Section { Toggle("Settings", isOn: selection(settings, SettingsKey.settingsSyncEnabled)) if settings { @@ -34,10 +45,6 @@ struct MobileSyncSettingsView: View { } Text("Terminal and AI preferences.").font(.caption).foregroundStyle(.secondary) }.disabled(!enabled || !app.pro.canUse(.vaultSync)) - if settings { - Text("API keys use iCloud Keychain, even when configuration sync is paused.") - .font(.caption).foregroundStyle(.secondary) - } }.navigationTitle("Sync") } diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index c3c27e31..4ba48326 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -109,7 +109,7 @@ struct MobileNavigationParityTests { #expect(MobileMetricsDetailPage.showsBothColumns(width: 1024)) } - @Test func metricsProcessListRemovesItsAutomaticTopMargin() throws { + @Test func metricsProcessListRemovesItsTopMarginAndKeepsLongPIDsReadable() throws { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() @@ -118,6 +118,9 @@ struct MobileNavigationParityTests { let processes = try #require(source.components(separatedBy: "struct MobileMetricsProcessesView").last) #expect(processes.contains(".contentMargins(.top, 0, for: .scrollContent)")) + #expect(processes.contains(".font(.caption.monospacedDigit())")) + #expect(processes.contains(".fixedSize(horizontal: true, vertical: false)")) + #expect(processes.contains(".frame(minWidth: 64, alignment: .leading)")) } @Test func connectionFailuresStayInJourneyAndCancellationLeavesTheDetail() throws { diff --git a/TFM/App/AppDependencies.swift b/TFM/App/AppDependencies.swift index 2b6c87c6..25528b07 100644 --- a/TFM/App/AppDependencies.swift +++ b/TFM/App/AppDependencies.swift @@ -15,7 +15,9 @@ struct AppDependencies { let serialPortService: any SerialPortServing let agentRunRepository: (any AgentRunPersisting)? let settingsSync: SettingsSyncCoordinator? + let metricsSync: MetricsSyncCoordinator? let vaultSync: VaultSyncCoordinator? + let metricsPreferences: (any MetricsConfigurationStoring)? let forwardingDefaults: UserDefaults? let mcpServerSecrets: any SecretStoring @@ -26,6 +28,8 @@ struct AppDependencies { agentRunRepository: (any AgentRunPersisting)? = nil, vaultSync: VaultSyncCoordinator? = nil, settingsSync: SettingsSyncCoordinator? = nil, + metricsSync: MetricsSyncCoordinator? = nil, + metricsPreferences: (any MetricsConfigurationStoring)? = nil, forwardingDefaults: UserDefaults? = nil, mcpServerSecrets: any SecretStoring = InMemorySecretStore() ) { @@ -36,6 +40,8 @@ struct AppDependencies { self.agentRunRepository = agentRunRepository self.vaultSync = vaultSync self.settingsSync = settingsSync + self.metricsSync = metricsSync + self.metricsPreferences = metricsPreferences self.forwardingDefaults = forwardingDefaults self.mcpServerSecrets = mcpServerSecrets } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 451a65a7..6e5e4d97 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -80,6 +80,7 @@ final class AppModel { let serialPorts: any SerialPortServing let hostRepository: any HostRepository let settingsSync: SettingsSyncCoordinator? + let metricsSync: MetricsSyncCoordinator? let vaultSync: VaultSyncCoordinator? let forwarding: PortForwardingController let sftpTransfers: SFTPTransferCoordinator @@ -143,7 +144,7 @@ final class AppModel { self.serverMetrics = suppliedMetrics ?? ServerMetricsModel( - hosts: hosts, preferences: MetricsPreferences(), + hosts: hosts, preferences: dependencies.metricsPreferences ?? MetricsPreferences(), makeSampler: { connectionCoordinator.makeMetricsSampler() }, discover: { try await connectionCoordinator.discoverMetricsHostKey(host: $0, proxy: $1, jumps: $2) }, cardLimit: { proAccess.canUse(.unlimitedMetrics) ? nil : ServerMetricsModel.freeCardLimit }, @@ -186,6 +187,7 @@ final class AppModel { hostRepository = dependencies.hostRepository vaultSync = dependencies.vaultSync settingsSync = dependencies.settingsSync + metricsSync = dependencies.metricsSync forwarding = PortForwardingController(hosts: hosts, defaults: dependencies.forwardingDefaults) sftpTransfers = SFTPTransferCoordinator( store: hosts, @@ -285,8 +287,12 @@ final class AppModel { self?.skills.applyRemoteSnapshot(snapshot.skills) self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal) } + metricsSync?.didApplyRemote = { [weak serverMetrics] snapshot, _ in + serverMetrics?.applyRemoteSnapshot(snapshot) + } if proAccess.canUse(.vaultSync) { vaultSync?.start() + metricsSync?.start() settingsSync?.start() } } @@ -294,10 +300,12 @@ final class AppModel { func reconcileProAccess() { if proAccess.canUse(.vaultSync) { vaultSync?.start() + metricsSync?.start() settingsSync?.start() mcpServer.resumeForProAccess() } else { vaultSync?.pause() + metricsSync?.pause() settingsSync?.pause() } if !proAccess.canUse(.mcpServer), mcpServer.isEnabled { diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index db77331e..121da6d8 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -59,11 +59,18 @@ struct TermindApp: App { SettingsKey.migrateSyncPreferences() let settingsPersistence = SettingsSyncPersistence(context: container.mainContext) try settingsPersistence.prepare() + let metricsDefaults = + isRunningTests ? UserDefaults(suiteName: "termind.metrics.tests.\(UUID().uuidString)")! : .standard + let metricsPersistence = MetricsSyncPersistence( + context: container.mainContext, defaults: metricsDefaults) + try metricsPersistence.prepare() let aiSecrets = AISecretStore(context: container.mainContext, keychain: secrets) let settingsSync: SettingsSyncCoordinator? = isRunningTests ? nil : SettingsSyncCoordinator(persistence: settingsPersistence, secrets: aiSecrets) + let metricsSync: MetricsSyncCoordinator? = + isRunningTests ? nil : MetricsSyncCoordinator(persistence: metricsPersistence) let hosts = HostLibraryModel( modelContext: container.mainContext, secrets: secrets ) @@ -106,6 +113,7 @@ struct TermindApp: App { context: container.mainContext ), vaultSync: vaultSync, settingsSync: settingsSync, + metricsSync: metricsSync, metricsPreferences: metricsPersistence, forwardingDefaults: .standard, mcpServerSecrets: secrets ), proAccess: proAccess, diff --git a/TFM/Features/Commerce/ProPaywallView.swift b/TFM/Features/Commerce/ProPaywallView.swift index 3554d2e0..e7c7a81c 100644 --- a/TFM/Features/Commerce/ProPaywallView.swift +++ b/TFM/Features/Commerce/ProPaywallView.swift @@ -41,7 +41,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 Vault and Settings across devices", "icloud") + feature("Sync Vault, Metrics, and Settings across devices", "icloud") } .padding(.vertical, 28) diff --git a/TFM/Features/HostChooser/HostChooserView.swift b/TFM/Features/HostChooser/HostChooserView.swift index 11aa6abf..d6209e5b 100644 --- a/TFM/Features/HostChooser/HostChooserView.swift +++ b/TFM/Features/HostChooser/HostChooserView.swift @@ -208,7 +208,7 @@ struct HostChooserView: View { private var results: [Host] { let matches = app.hostLibrary.searchHosts(query) guard purpose == .metrics else { return matches } - let monitored = Set(app.serverMetrics.servers.map(\.id)) + let monitored = Set(app.serverMetrics.selections.map(\.id)) return matches.filter { !monitored.contains($0.id) } } diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index 381137f9..a1fdbc51 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -465,7 +465,7 @@ private struct ProSettings: View { Section("Termind Pro") { Text( - "One purchase unlocks multi-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and iCloud sync for your Vault and settings across devices." + "One purchase unlocks multi-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and iCloud sync for your Vault, Metrics, and settings across devices." ) .foregroundStyle(.secondary) if app.proAccess.status.canPurchase { diff --git a/TFM/Features/Settings/SyncSettings.swift b/TFM/Features/Settings/SyncSettings.swift index dfc349dc..dd562f07 100644 --- a/TFM/Features/Settings/SyncSettings.swift +++ b/TFM/Features/Settings/SyncSettings.swift @@ -6,6 +6,7 @@ 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.metricsSyncEnabled) private var includesMetrics = SettingsKey.defaultMetricsSyncEnabled @AppStorage(SettingsKey.settingsSyncEnabled) private var includesSettings = SettingsKey.defaultSettingsSyncEnabled private var isActive: Bool { isEnabled && app.proAccess.canUse(.vaultSync) } @@ -35,6 +36,16 @@ struct SyncSettings: View { }), status: isActive ? app.vaultSync?.status : nil, retry: { app.vaultSync?.retry() }, merge: { app.vaultSync?.confirmAccountSwitchMerge() }) + SyncSelectionRow( + title: "Metrics", detail: "Monitored servers, pause state, device selections, and card order.", + isSelected: Binding( + get: { includesMetrics }, + set: { value in + includesMetrics = value + app.metricsSync?.setEnabled(value) + }), status: isActive ? app.metricsSync?.status : nil, + retry: { app.metricsSync?.retry() }, + merge: { app.metricsSync?.confirmAccountSwitchMerge() }) SyncSelectionRow( title: "Settings", detail: "Terminal and AI preferences.", isSelected: Binding( @@ -47,11 +58,6 @@ struct SyncSettings: View { merge: { app.settingsSync?.confirmAccountSwitchMerge() }) } .disabled(!isActive) - - if includesSettings { - Text("API keys use iCloud Keychain, even when configuration sync is paused.") - .font(.caption).foregroundStyle(.secondary) - } } } } diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 11534a82..61c04842 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -576,7 +576,7 @@ struct ArchitectureBoundaryTests { encoding: .utf8) #expect(!terminal.contains("remoteWindowTitleInMenuBar")) let cloud = try swiftFiles(in: "../Frameworks/TermindInfrastructure/Cloud") - #expect(cloud.count == 2) + #expect(cloud.count == 3) } @Test diff --git a/TFMTests/SettingsSyncTests.swift b/TFMTests/SettingsSyncTests.swift index 194dff33..b019e661 100644 --- a/TFMTests/SettingsSyncTests.swift +++ b/TFMTests/SettingsSyncTests.swift @@ -84,7 +84,7 @@ struct SettingsSyncTests { _ = try store.load() let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( ofType: NSSQLiteStoreType, at: settingsMigrationFixtureURL) - #expect(metadata[NSStoreModelVersionIdentifiersKey] as? [String] == ["14.0.0"]) + #expect(metadata[NSStoreModelVersionIdentifiersKey] as? [String] == ["15.0.0"]) } @Test