Sync Metrics configuration across devices
This commit is contained in:
1 parent
78a023bb52
commit
ae968735d5
30 files changed
+1498
-113
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user