Sync Metrics configuration across devices

This commit is contained in:
Kaede Akino committed 2026-09-12 14:06:56 +08:00
1 parent 78a023bb52
commit ae968735d5
30 files changed
+1498 -113

No files matched your search

+146
View File
@@ -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 {}
}
@@ -26,6 +26,7 @@ struct PersistenceCompatibilityTests {
#expect(providers.map(\.name) == ["Legacy fixture provider"])
}
#expect(try container.mainContext.fetchCount(FetchDescriptor<PersistentTerminalPreferences>()) == 0)
#expect(try container.mainContext.fetchCount(FetchDescriptor<PersistentMetricsSyncEntry>()) == 0)
}
@Test
@@ -53,6 +54,6 @@ struct PersistenceCompatibilityTests {
try first.mainContext.save()
#expect(try second.mainContext.fetchCount(FetchDescriptor<PersistentHostGroup>()) == 0)
#expect(Set(first.schema.entities.map(\.name)) == Set(TermindPersistence.schema.entities.map(\.name)))
#expect(TermindMigrationPlan.schemas.count == 14)
#expect(TermindMigrationPlan.schemas.count == 15)
}
}
+44 -14
View File
@@ -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" : {
@@ -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<UUID>()
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<String>?, interfaces: Set<String>?) {
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<UUID>()
return selections.filter { seen.insert($0.id).inserted }
}
private func cancel(_ id: UUID) {
generations[id] = nil
@@ -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
}
@@ -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"
@@ -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<MetricsRecordKey> = []
public static let empty = Self()
public var isEmpty: Bool { keys.isEmpty }
public init(keys: Set<MetricsRecordKey> = []) { self.keys = keys }
}
@@ -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.
@@ -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<Void, Never>?
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<MetricsRecordKey>) -> 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<MetricsRecordKey>) {
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.")
}
}
}
@@ -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
),
]
}
}
@@ -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)
}
}
@@ -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<MetricsRecordKey> = []
}
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<PersistentMetricsSyncState>()
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<MetricsRecordKey>()
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<MetricsRecordKey>()
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<PersistentMetricsSyncEntry>(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<MetricsRecordKey>) 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<PersistentMetricsSyncQuarantine>(
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<PersistentMetricsSyncQuarantine>(
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<PersistentMetricsSyncEntry>()) {
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<PersistentMetricsSyncEntry>()) {
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<PersistentMetricsSyncEntry>(
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<PersistentMetricsSyncEntry>()).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<MetricsRecordKey>, needsUpload: Set<MetricsRecordKey>)
{
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<PersistentMetricsSyncEntry>()) {
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<UUID>()
return values.filter { seen.insert($0.id).inserted }
}
}
@@ -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")
+19 -12
View File
@@ -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
+10 -3
View File
@@ -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.
+14 -1
View File
@@ -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()
}
}
@@ -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") }
}
}
+1 -1
View File
@@ -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 {
+12 -3
View File
@@ -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) {
@@ -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")
}
+4 -1
View File
@@ -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 {
+6
View File
@@ -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
}
+9 -1
View File
@@ -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 {
+8
View File
@@ -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,
+1 -1
View File
@@ -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)
@@ -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) }
}
+1 -1
View File
@@ -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 {
+11 -5
View File
@@ -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)
}
}
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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