diff --git a/FrameworkTests/CloudSyncSafetyPersistenceTests.swift b/FrameworkTests/CloudSyncSafetyPersistenceTests.swift index 689a0afc..80507701 100644 --- a/FrameworkTests/CloudSyncSafetyPersistenceTests.swift +++ b/FrameworkTests/CloudSyncSafetyPersistenceTests.swift @@ -116,7 +116,7 @@ struct CloudSyncSafetyPersistenceTests { #expect(try context.fetch(FetchDescriptor()).map(\.name) == ["Existing data"]) #expect(!TermindSchemaV30.models.contains { $0 == PersistentCloudSyncQuarantine.self }) #expect(TermindSchemaV31.models.contains { $0 == PersistentCloudSyncQuarantine.self }) - #expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV31.versionIdentifier) + #expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV32.versionIdentifier) } private func writeInterruptedFetch( diff --git a/FrameworkTests/PersistenceCompatibilityTests.swift b/FrameworkTests/PersistenceCompatibilityTests.swift index 5c7cc0dd..7c8323dc 100644 --- a/FrameworkTests/PersistenceCompatibilityTests.swift +++ b/FrameworkTests/PersistenceCompatibilityTests.swift @@ -75,6 +75,7 @@ struct PersistenceCompatibilityTests { #expect(!TermindMigrationPlan.schemas.contains { $0.versionIdentifier == Schema.Version(29, 0, 0) }) #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV30.versionIdentifier }) #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV31.versionIdentifier }) + #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV32.versionIdentifier }) #expect(TermindSchemaV22.models.contains { $0 == TermindSchemaV22HostModels.PersistentHost.self }) #expect(!TermindSchemaV22.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) #expect(TermindSchemaV25.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) @@ -87,11 +88,67 @@ struct PersistenceCompatibilityTests { #expect( !TermindSchemaV30.models.contains { $0 == TermindSchemaV29KnownHostModels.PersistentKnownHost.self }) #expect(TermindSchemaV30.models.contains { $0 == TermindSchemaV30KnownHostModels.PersistentKnownHost.self }) + #expect( + TermindSchemaV31.models.contains { + $0 == TermindSchemaV29HostModels.PersistentHost.self + }) + #expect( + TermindSchemaV32.models.contains { + $0 == TermindSchemaV32HostModels.PersistentHost.self + }) + #expect( + !TermindSchemaV32.models.contains { + $0 == TermindSchemaV29HostModels.PersistentHost.self + }) #expect(first.schema.entities.contains { $0.name == "PersistentModelProviderVertexConfiguration" }) #expect(first.schema.entities.contains { $0.name == "PersistentSSHPreferences" }) #expect(first.schema.entities.contains { $0.name == "PersistentSFTPPreferences" }) } + @Test + func version31StoreDropsHostUsageAndAddsConnectionLifecycleMetadata() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("Termind.store") + let oldSchema = Schema(versionedSchema: TermindSchemaV31.self) + let oldConfiguration = ModelConfiguration( + "Termind", schema: oldSchema, url: url, cloudKitDatabase: .none + ) + var oldContainer: ModelContainer? = try ModelContainer( + for: oldSchema, configurations: [oldConfiguration] + ) + let oldHost = TermindSchemaV29HostModels.PersistentHost( + Host(name: "Before V32", host: "server.invalid")) + oldHost.lastUsedAt = Date(timeIntervalSince1970: 100) + let oldHistory = TermindSchemaV31ConnectionHistoryModels.PersistentConnectionHistoryEntry( + ConnectionHistoryEntry( + hostID: oldHost.id, kind: .ssh, + connectedAt: Date(timeIntervalSince1970: 90))) + oldContainer?.mainContext.insert(oldHost) + oldContainer?.mainContext.insert(oldHistory) + try oldContainer?.mainContext.save() + oldContainer = nil + + let migrated = try TermindPersistence.makeContainer(url: url) + let host = try #require( + migrated.mainContext.fetch(FetchDescriptor()).first?.value) + let history = try #require( + migrated.mainContext.fetch(FetchDescriptor()).first?.value) + let state = try #require( + migrated.mainContext.fetch(FetchDescriptor()).first) + let sourceDevice = try #require( + migrated.mainContext.fetch( + FetchDescriptor() + ).first?.value) + #expect(host.name == "Before V32") + #expect(history.hostID == host.id) + #expect(history.sourceDeviceID == state.installationID) + #expect(sourceDevice.id == state.installationID) + #expect(sourceDevice.displayName == ProcessInfo.processInfo.hostName) + #expect(history.endedAt == nil) + } + @Test func version30StoreMigratesToVersion31WithoutLosingData() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift index 15fe4aa1..ee0621c2 100644 --- a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift @@ -7,18 +7,22 @@ import struct TermindDomain.Host @MainActor @Observable public final class ConnectionHistoryModel { - public static let limit = 1_000 + public static let limit = 300 + public static let presentationLimit = 100 private let repository: any ConnectionHistoryRepository + public let sourceDeviceID: UUID private var cachedEntries: [ConnectionHistoryEntry]? public private(set) var revision: UInt64 = 0 public private(set) var storageError: String? public init( entries: [ConnectionHistoryEntry] = [], - repository: (any ConnectionHistoryRepository)? = nil + repository: (any ConnectionHistoryRepository)? = nil, + sourceDeviceID: UUID = UUID() ) { self.repository = repository ?? InMemoryConnectionHistoryRepository(entries: entries) + self.sourceDeviceID = sourceDeviceID } public func entries() -> [ConnectionHistoryEntry] { @@ -39,16 +43,31 @@ public final class ConnectionHistoryModel { entries().filter { $0.hostID == hostID } } + public func isLocal(_ entry: ConnectionHistoryEntry) -> Bool { + entry.sourceDeviceID == sourceDeviceID + } + + public func localEntries() -> [ConnectionHistoryEntry] { + entries().filter(isLocal) + } + public func mostRecentDate(for hostID: Host.ID) -> Date? { entries().first { $0.hostID == hostID }?.connectedAt } + public func mostRecentLocalDate(for hostID: Host.ID) -> Date? { + localEntries().first { $0.hostID == hostID }?.connectedAt + } + @discardableResult public func record( kind: ConnectionHistoryEntry.Kind, for hostID: Host.ID, at date: Date = Date() ) -> ConnectionHistoryEntry? { - let entry = ConnectionHistoryEntry(hostID: hostID, kind: kind, connectedAt: date) + let entry = ConnectionHistoryEntry( + hostID: hostID, kind: kind, connectedAt: date, + sourceDeviceID: sourceDeviceID + ) do { try repository.insert(entry, limit: Self.limit) cachedEntries = nil @@ -61,6 +80,23 @@ public final class ConnectionHistoryModel { } } + public func end(_ id: ConnectionHistoryEntry.ID, at date: Date = Date()) { + do { + try repository.end(id, at: date) + cachedEntries = nil + storageError = nil + revision &+= 1 + } catch { storageError = error.localizedDescription } + } + + /// The sync coordinator persists remote events through its own transaction. + /// Drop the presentation cache so the next read observes those committed rows. + public func reload() { + cachedEntries = nil + storageError = nil + revision &+= 1 + } + public func delete(_ id: ConnectionHistoryEntry.ID) { do { try repository.delete(id) diff --git a/Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift b/Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift new file mode 100644 index 00000000..412c0763 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift @@ -0,0 +1,74 @@ +import Foundation +import Observation +import TermindDomain + +@MainActor +@Observable +public final class DeviceDirectoryModel { + private let repository: any DeviceDirectoryRepository + public let currentDeviceID: UUID + public private(set) var currentDeviceName: String + private var cachedDevices: [UUID: DeviceRecord]? + public private(set) var revision: UInt64 = 0 + public private(set) var storageError: String? + + public init( + repository: (any DeviceDirectoryRepository)? = nil, + currentDeviceID: UUID = UUID(), + currentDeviceName: String = ProcessInfo.processInfo.hostName + ) { + self.repository = repository ?? InMemoryDeviceDirectoryRepository() + self.currentDeviceID = currentDeviceID + self.currentDeviceName = currentDeviceName + registerCurrentDevice() + } + + public func devices() -> [DeviceRecord] { + _ = revision + if let cachedDevices { return Array(cachedDevices.values) } + do { + let values = try repository.devices() + cachedDevices = Dictionary(uniqueKeysWithValues: values.map { ($0.id, $0) }) + storageError = nil + return values + } catch { + storageError = error.localizedDescription + return [] + } + } + + public func name(for deviceID: UUID?) -> String? { + guard let deviceID else { return nil } + if cachedDevices == nil { _ = devices() } + return cachedDevices?[deviceID]?.displayName + } + + public func reload() { + cachedDevices = nil + storageError = nil + revision &+= 1 + } + + public func updateCurrentDeviceName(_ name: String) { + do { + try repository.upsert(DeviceRecord(id: currentDeviceID, displayName: name)) + currentDeviceName = name + cachedDevices = nil + storageError = nil + revision &+= 1 + } catch { + storageError = error.localizedDescription + } + } + + private func registerCurrentDevice() { + do { + try repository.upsert( + DeviceRecord(id: currentDeviceID, displayName: currentDeviceName)) + cachedDevices = nil + storageError = nil + } catch { + storageError = error.localizedDescription + } + } +} diff --git a/Frameworks/TermindApplication/Repositories/ConnectionHistoryRepository.swift b/Frameworks/TermindApplication/Repositories/ConnectionHistoryRepository.swift index e2dc3037..5f87f28e 100644 --- a/Frameworks/TermindApplication/Repositories/ConnectionHistoryRepository.swift +++ b/Frameworks/TermindApplication/Repositories/ConnectionHistoryRepository.swift @@ -7,6 +7,7 @@ import struct TermindDomain.Host public protocol ConnectionHistoryRepository: AnyObject { func entries() throws -> [ConnectionHistoryEntry] func insert(_ entry: ConnectionHistoryEntry, limit: Int) throws + func end(_ id: ConnectionHistoryEntry.ID, at date: Date) throws func delete(_ id: ConnectionHistoryEntry.ID) throws func clear(for hostID: Host.ID) throws func clearAll() throws @@ -19,7 +20,7 @@ public final class InMemoryConnectionHistoryRepository: ConnectionHistoryReposit public init(entries: [ConnectionHistoryEntry] = []) { values = entries } public func entries() throws -> [ConnectionHistoryEntry] { - values.sorted { $0.connectedAt > $1.connectedAt } + values.sorted(by: ConnectionHistoryEntry.isNewer) } public func insert(_ entry: ConnectionHistoryEntry, limit: Int) throws { @@ -28,6 +29,11 @@ public final class InMemoryConnectionHistoryRepository: ConnectionHistoryReposit values.removeAll { !retained.contains($0.id) } } + public func end(_ id: ConnectionHistoryEntry.ID, at date: Date) throws { + guard let index = values.firstIndex(where: { $0.id == id }), values[index].endedAt == nil else { return } + values[index].endedAt = max(date, values[index].connectedAt) + } + public func delete(_ id: ConnectionHistoryEntry.ID) throws { values.removeAll { $0.id == id } } @@ -38,3 +44,10 @@ public final class InMemoryConnectionHistoryRepository: ConnectionHistoryReposit public func clearAll() throws { values.removeAll() } } + +extension ConnectionHistoryEntry { + public nonisolated static func isNewer(_ lhs: Self, _ rhs: Self) -> Bool { + if lhs.connectedAt != rhs.connectedAt { return lhs.connectedAt > rhs.connectedAt } + return lhs.id.uuidString < rhs.id.uuidString + } +} diff --git a/Frameworks/TermindApplication/Repositories/DeviceDirectoryRepository.swift b/Frameworks/TermindApplication/Repositories/DeviceDirectoryRepository.swift new file mode 100644 index 00000000..eea16282 --- /dev/null +++ b/Frameworks/TermindApplication/Repositories/DeviceDirectoryRepository.swift @@ -0,0 +1,21 @@ +import Foundation +import TermindDomain + +@MainActor +public protocol DeviceDirectoryRepository: AnyObject { + func devices() throws -> [DeviceRecord] + func upsert(_ device: DeviceRecord) throws +} + +@MainActor +public final class InMemoryDeviceDirectoryRepository: DeviceDirectoryRepository { + private var values: [UUID: DeviceRecord] + + public init(devices: [DeviceRecord] = []) { + values = Dictionary(uniqueKeysWithValues: devices.map { ($0.id, $0) }) + } + + public func devices() throws -> [DeviceRecord] { Array(values.values) } + + public func upsert(_ device: DeviceRecord) throws { values[device.id] = device } +} diff --git a/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift b/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift index 31924419..4c1b710d 100644 --- a/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift +++ b/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift @@ -4,7 +4,9 @@ import Foundation /// /// Every attempt is retained as its own event so the History sidebar can show /// repeated visits to the same destination. -public nonisolated struct ConnectionHistoryEntry: Identifiable, Hashable, Sendable { +public nonisolated struct ConnectionHistoryEntry: Identifiable, Codable, Hashable, Sendable { + /// Saved remote-host protocols only. Local Serial sessions have no Host.ID, + /// and TFI does not expose Serial workspaces, so they never enter this timeline. public nonisolated enum Kind: String, Codable, CaseIterable, Hashable, Sendable { case ssh case telnet @@ -31,11 +33,21 @@ public nonisolated struct ConnectionHistoryEntry: Identifiable, Hashable, Sendab public var hostID: Host.ID public var kind: Kind public var connectedAt: Date = Date() - public init(id: UUID = UUID(), hostID: Host.ID, kind: Kind, connectedAt: Date = Date()) { + /// Stable installation identity used to resolve the synchronized source-device record. + public var sourceDeviceID: UUID? + /// Nil while the workspace or pane opened by this event remains in use. + public var endedAt: Date? + + public init( + id: UUID = UUID(), hostID: Host.ID, kind: Kind, connectedAt: Date = Date(), + sourceDeviceID: UUID? = nil, endedAt: Date? = nil + ) { self.id = id self.hostID = hostID self.kind = kind self.connectedAt = connectedAt + self.sourceDeviceID = sourceDeviceID + self.endedAt = endedAt } } diff --git a/Frameworks/TermindDomain/DeviceRecord.swift b/Frameworks/TermindDomain/DeviceRecord.swift new file mode 100644 index 00000000..a836c55f --- /dev/null +++ b/Frameworks/TermindDomain/DeviceRecord.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A synchronized display record for one stable Termind installation. +/// +/// Features store only the installation ID and resolve its current display name +/// through the shared device directory. +public nonisolated struct DeviceRecord: Identifiable, Codable, Hashable, Sendable { + public var id: UUID + public var displayName: String + + public init(id: UUID, displayName: String) { + self.id = id + self.displayName = displayName + } +} diff --git a/Frameworks/TermindDomain/Vault/VaultSyncTypes.swift b/Frameworks/TermindDomain/Vault/VaultSyncTypes.swift index b20da399..85362dc6 100644 --- a/Frameworks/TermindDomain/Vault/VaultSyncTypes.swift +++ b/Frameworks/TermindDomain/Vault/VaultSyncTypes.swift @@ -10,6 +10,8 @@ public nonisolated enum VaultRecordKind: String, Codable, CaseIterable, Sendable case snippetPackage case portForwarding case workspaceTemplate + case connectionHistory + case device } public nonisolated struct VaultRecordKey: Codable, Hashable, Sendable { @@ -81,6 +83,8 @@ public nonisolated enum VaultRecordPayload: Codable, Hashable, Sendable { case snippetPackage(SnippetPackage) case portForwarding(PortForwardingRule) case workspaceTemplate(WorkspaceTemplate) + case connectionHistory(ConnectionHistoryEntry) + case device(DeviceRecord) public var key: VaultRecordKey { switch self { @@ -93,6 +97,8 @@ public nonisolated enum VaultRecordPayload: Codable, Hashable, Sendable { case .snippetPackage(let value): VaultRecordKey(kind: .snippetPackage, id: value.id) case .portForwarding(let value): VaultRecordKey(kind: .portForwarding, id: value.id) case .workspaceTemplate(let value): VaultRecordKey(kind: .workspaceTemplate, id: value.id) + case .connectionHistory(let value): VaultRecordKey(kind: .connectionHistory, id: value.id) + case .device(let value): VaultRecordKey(kind: .device, id: value.id) } } } diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index df2b5c8e..d439e6d4 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -555,7 +555,7 @@ public enum TermindSchemaV6: VersionedSchema { public static let versionIdentifier = Schema.Version(6, 0, 0) public static var models: [any PersistentModel.Type] { - TermindSchemaV5.models + [PersistentConnectionHistoryEntry.self] + TermindSchemaV5.models + [TermindSchemaV31ConnectionHistoryModels.PersistentConnectionHistoryEntry.self] } } @@ -773,6 +773,20 @@ public enum TermindSchemaV31: VersionedSchema { } } +public enum TermindSchemaV32: VersionedSchema { + public static let versionIdentifier = Schema.Version(32, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV31.models.filter { + $0 != TermindSchemaV29HostModels.PersistentHost.self + && $0 != TermindSchemaV31ConnectionHistoryModels.PersistentConnectionHistoryEntry.self + } + [ + TermindSchemaV32HostModels.PersistentHost.self, + TermindSchemaV32ConnectionHistoryModels.PersistentConnectionHistoryEntry.self, + TermindSchemaV32DeviceModels.PersistentDeviceRecord.self, + ] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -784,6 +798,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV18.self, TermindSchemaV19.self, TermindSchemaV20.self, TermindSchemaV21.self, TermindSchemaV22.self, TermindSchemaV23.self, TermindSchemaV24.self, TermindSchemaV25.self, TermindSchemaV26.self, TermindSchemaV27.self, TermindSchemaV30.self, TermindSchemaV31.self, + TermindSchemaV32.self, ] } @@ -854,11 +869,33 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV26.self, toVersion: TermindSchemaV27.self), MigrationStage.lightweight(fromVersion: TermindSchemaV27.self, toVersion: TermindSchemaV30.self), MigrationStage.lightweight(fromVersion: TermindSchemaV30.self, toVersion: TermindSchemaV31.self), + MigrationStage.custom( + fromVersion: TermindSchemaV31.self, toVersion: TermindSchemaV32.self, + willMigrate: nil, + didMigrate: { context in + let state: PersistentVaultSyncState + if let existing = try context.fetch(FetchDescriptor()).first { + state = existing + } else { + state = PersistentVaultSyncState() + context.insert(state) + } + for entry in try context.fetch(FetchDescriptor()) { + entry.sourceDeviceID = state.installationID + } + context.insert( + PersistentDeviceRecord( + DeviceRecord( + id: state.installationID, + displayName: ProcessInfo.processInfo.hostName))) + try context.save() + } + ), ] } } -public typealias PersistentHost = TermindSchemaV29HostModels.PersistentHost +public typealias PersistentHost = TermindSchemaV32HostModels.PersistentHost public typealias PersistentIdentity = TermindSchemaV25IdentityModels.PersistentIdentity public typealias PersistentSSHKey = TermindSchemaV7Models.PersistentSSHKey public typealias PersistentKnownHost = TermindSchemaV30KnownHostModels.PersistentKnownHost diff --git a/Frameworks/TermindPersistence/SwiftDataConnectionHistoryRepository.swift b/Frameworks/TermindPersistence/SwiftDataConnectionHistoryRepository.swift index dcc3ec99..2e6cd72e 100644 --- a/Frameworks/TermindPersistence/SwiftDataConnectionHistoryRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataConnectionHistoryRepository.swift @@ -5,68 +5,99 @@ import TermindDomain import struct TermindDomain.Host -@Model -public final class PersistentConnectionHistoryEntry { - @Attribute(.unique) public var id: UUID = UUID() - public var hostID: UUID = UUID() - public var kind = ConnectionHistoryEntry.Kind.ssh.rawValue - public var connectedAt = Date(timeIntervalSince1970: 0) +/// The connection-history shape shipped through V31. +public enum TermindSchemaV31ConnectionHistoryModels { + @Model + public final class PersistentConnectionHistoryEntry { + @Attribute(.unique) public var id: UUID = UUID() + public var hostID: UUID = UUID() + public var kind = ConnectionHistoryEntry.Kind.ssh.rawValue + public var connectedAt = Date(timeIntervalSince1970: 0) - public init(_ value: ConnectionHistoryEntry) { - id = value.id - hostID = value.hostID - kind = value.kind.rawValue - connectedAt = value.connectedAt - } - - public var value: ConnectionHistoryEntry { - ConnectionHistoryEntry( - id: id, hostID: hostID, - kind: ConnectionHistoryEntry.Kind(rawValue: kind) ?? .ssh, - connectedAt: connectedAt - ) + public init(_ value: ConnectionHistoryEntry) { + id = value.id + hostID = value.hostID + kind = value.kind.rawValue + connectedAt = value.connectedAt + } } } +/// V32 adds synchronized origin and lifecycle metadata without changing the historical entity name. +public enum TermindSchemaV32ConnectionHistoryModels { + @Model + public final class PersistentConnectionHistoryEntry { + @Attribute(.unique) public var id: UUID = UUID() + public var hostID: UUID = UUID() + public var kind = ConnectionHistoryEntry.Kind.ssh.rawValue + public var connectedAt = Date(timeIntervalSince1970: 0) + public var sourceDeviceID: UUID? + public var endedAt: Date? + + public init(_ value: ConnectionHistoryEntry) { + id = value.id + apply(value) + } + + public func apply(_ value: ConnectionHistoryEntry) { + hostID = value.hostID + kind = value.kind.rawValue + connectedAt = value.connectedAt + sourceDeviceID = value.sourceDeviceID + endedAt = value.endedAt + } + + public var value: ConnectionHistoryEntry { + ConnectionHistoryEntry( + id: id, hostID: hostID, + kind: ConnectionHistoryEntry.Kind(rawValue: kind) ?? .ssh, + connectedAt: connectedAt, sourceDeviceID: sourceDeviceID, + endedAt: endedAt + ) + } + } + +} + +public typealias PersistentConnectionHistoryEntry = + TermindSchemaV32ConnectionHistoryModels.PersistentConnectionHistoryEntry @MainActor public final class SwiftDataConnectionHistoryRepository: ConnectionHistoryRepository { private let context: ModelContext + public var didRecordVaultChanges: ((VaultMutationSet) -> Void)? public init(context: ModelContext) { self.context = context } public func entries() throws -> [ConnectionHistoryEntry] { - try context.fetch( - FetchDescriptor( - sortBy: [SortDescriptor(\.connectedAt, order: .reverse)] - ) - ).map(\.value) + try context.fetch(FetchDescriptor()) + .map(\.value) + .sorted(by: ConnectionHistoryEntry.isNewer) } public func insert(_ entry: ConnectionHistoryEntry, limit: Int) throws { - try context.commit { + try commitMutation { context.insert(PersistentConnectionHistoryEntry(entry)) + let rows = try context.fetch(FetchDescriptor()) + .sorted { ConnectionHistoryEntry.isNewer($0.value, $1.value) } + for row in rows.dropFirst(limit) { context.delete(row) } } - try context.commit { - var overflow = FetchDescriptor( - sortBy: [SortDescriptor(\.connectedAt, order: .reverse)] - ) - overflow.fetchOffset = limit - for row in try context.fetch(overflow) { context.delete(row) } + } + + public func end(_ id: ConnectionHistoryEntry.ID, at date: Date) throws { + try commitMutation { + guard let row = try entry(id), row.endedAt == nil else { return } + row.endedAt = max(date, row.connectedAt) } } public func delete(_ id: ConnectionHistoryEntry.ID) throws { - try context.commit { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - if let row = try context.fetch(descriptor).first { context.delete(row) } + try commitMutation { + if let row = try entry(id) { context.delete(row) } } } public func clear(for hostID: Host.ID) throws { - try context.commit { + try commitMutation { try context.delete( model: PersistentConnectionHistoryEntry.self, where: #Predicate { $0.hostID == hostID } @@ -75,8 +106,26 @@ public final class SwiftDataConnectionHistoryRepository: ConnectionHistoryReposi } public func clearAll() throws { - try context.commit { + try commitMutation { try context.delete(model: PersistentConnectionHistoryEntry.self) } } + + private func entry(_ id: UUID) throws -> PersistentConnectionHistoryEntry? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + private func commitMutation(_ operation: () throws -> Void) throws { + var mutations = VaultMutationSet.empty + try context.commit { + try operation() + mutations = try VaultSyncJournal(context: context).recordCurrentConnectionHistory( + try entries()) + } + if !mutations.isEmpty { didRecordVaultChanges?(mutations) } + } } diff --git a/Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift b/Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift new file mode 100644 index 00000000..6e37db41 --- /dev/null +++ b/Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift @@ -0,0 +1,53 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain + +public enum TermindSchemaV32DeviceModels { + @Model + public final class PersistentDeviceRecord { + @Attribute(.unique) public var id = UUID() + public var displayName = "" + + public init(_ value: DeviceRecord) { + id = value.id + displayName = value.displayName + } + + public var value: DeviceRecord { + DeviceRecord(id: id, displayName: displayName) + } + } +} + +public typealias PersistentDeviceRecord = TermindSchemaV32DeviceModels.PersistentDeviceRecord + +@MainActor +public final class SwiftDataDeviceDirectoryRepository: DeviceDirectoryRepository { + private let context: ModelContext + public var didRecordVaultChanges: ((VaultMutationSet) -> Void)? + + public init(context: ModelContext) { self.context = context } + + public func devices() throws -> [DeviceRecord] { + try context.fetch(FetchDescriptor()).map(\.value) + } + + public func upsert(_ device: DeviceRecord) throws { + var mutations = VaultMutationSet.empty + try context.commit { + let id = device.id + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id }) + descriptor.fetchLimit = 1 + if let existing = try context.fetch(descriptor).first { + guard existing.displayName != device.displayName else { return } + existing.displayName = device.displayName + } else { + context.insert(PersistentDeviceRecord(device)) + } + mutations = try VaultSyncJournal(context: context).recordCurrentDevices(try devices()) + } + if !mutations.isEmpty { didRecordVaultChanges?(mutations) } + } +} diff --git a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift index 44fd60c4..22d21c88 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift @@ -92,6 +92,11 @@ extension VaultSnapshot { case .snippetPackage(let value): upsert(value, in: &snippetPackages) case .portForwarding(let value): upsert(value, in: &portForwardings) case .workspaceTemplate(let value): upsert(value, in: &workspaceTemplates) + case .connectionHistory: + // Connection history is persisted independently by HostPersistence + // so HostLibrary snapshots cannot accidentally replace it. + break + case .device: break } } @@ -106,6 +111,8 @@ extension VaultSnapshot { case .snippetPackage: snippetPackages.removeAll { $0.id == key.id } case .portForwarding: portForwardings.removeAll { $0.id == key.id } case .workspaceTemplate: workspaceTemplates.removeAll { $0.id == key.id } + case .connectionHistory: break + case .device: break } } @@ -396,6 +403,71 @@ public final class VaultSyncJournal { return VaultMutationSet(keys: changed) } + /// Reconciles the independently owned connection timeline with the Vault + /// journal. Calling this on every launch also bootstraps histories created by + /// clients whose original Vault bootstrap predates this record kind. + public func recordCurrentConnectionHistory( + _ values: [ConnectionHistoryEntry], + now: Date = .now + ) throws -> VaultMutationSet { + let state = try state() + let current = Dictionary( + uniqueKeysWithValues: values.map { + (VaultRecordKey(kind: .connectionHistory, id: $0.id), VaultRecordPayload.connectionHistory($0)) + }) + var entries = try entriesByName() + var changed = Set() + + for (key, payload) in current { + let row = entries[key.recordName] + guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else { continue } + let envelope = VaultEnvelope( + key: key, + version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), + payload: payload + ) + try store(envelope, needsUpload: true, in: &entries) + changed.insert(key) + } + + for row in entries.values { + guard let envelope = row.envelope, envelope.key.kind == .connectionHistory, + !envelope.isDeleted, current[envelope.key] == nil + else { continue } + let tombstone = VaultEnvelope( + key: envelope.key, + version: .next(after: envelope.version, writerID: state.installationID, now: now), + isDeleted: true + ) + row.apply(tombstone, needsUpload: true) + changed.insert(envelope.key) + } + return VaultMutationSet(keys: changed) + } + + /// Reconciles the shared installation directory without deleting records + /// for devices that are merely absent from one client's local store. + public func recordCurrentDevices( + _ values: [DeviceRecord], now: Date = .now + ) throws -> VaultMutationSet { + let state = try state() + var entries = try entriesByName() + var changed = Set() + for device in values { + let key = VaultRecordKey(kind: .device, id: device.id) + let payload = VaultRecordPayload.device(device) + let row = entries[key.recordName] + guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else { continue } + let envelope = VaultEnvelope( + 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) + } + return VaultMutationSet(keys: changed) + } + public func markAllLiveRecordsNeedsUploadForNewAccount() throws { for row in try context.fetch(FetchDescriptor()) { guard let envelope = row.envelope else { continue } diff --git a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift index fa63d604..988c3efd 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift @@ -15,7 +15,10 @@ public final class HostPersistence { public func load() throws -> VaultSnapshot { let snapshot = try fetchSnapshot() - _ = try VaultSyncJournal(context: context).bootstrapIfNeeded(snapshot) + let journal = VaultSyncJournal(context: context) + _ = try journal.bootstrapIfNeeded(snapshot) + _ = try journal.recordCurrentConnectionHistory(fetchConnectionHistory()) + _ = try journal.recordCurrentDevices(fetchDevices()) if context.hasChanges { try context.save() } return snapshot } @@ -80,16 +83,31 @@ public final class HostPersistence { let journal = VaultSyncJournal(context: context) let decision = try journal.mergeDecision(for: envelopes) var snapshot = try fetchSnapshot() - for envelope in decision.accepted { try snapshot.apply(envelope) } + for envelope in decision.accepted { + if envelope.key.kind == .connectionHistory { + try applyConnectionHistory(envelope) + } else if envelope.key.kind == .device { + try applyDevice(envelope) + } else { + try snapshot.apply(envelope) + } + } + try pruneConnectionHistory() try persist(snapshot) try journal.acceptRemote(decision.accepted) - try journal.markNeedsUpload(decision.needsUpload) + let retentionMutations = try journal.recordCurrentConnectionHistory( + fetchConnectionHistory()) + let deviceMutations = try journal.recordCurrentDevices(fetchDevices()) + let needsUpload = decision.needsUpload + .union(retentionMutations.keys) + .union(deviceMutations.keys) + try journal.markNeedsUpload(needsUpload) try beforeSave() try context.save() return VaultRemoteApplyResult( snapshot: snapshot, acceptedKeys: Set(decision.accepted.map(\.key)), - needsUpload: decision.needsUpload + needsUpload: needsUpload ) } catch { context.rollback() @@ -101,7 +119,10 @@ public final class HostPersistence { let snapshot = try fetchSnapshot() let journal = VaultSyncJournal(context: context) _ = try journal.bootstrapIfNeeded(snapshot) - return try journal.recordCurrentSnapshot(snapshot) + let vault = try journal.recordCurrentSnapshot(snapshot) + let history = try journal.recordCurrentConnectionHistory(fetchConnectionHistory()) + let devices = try journal.recordCurrentDevices(fetchDevices()) + return VaultMutationSet(keys: vault.keys.union(history.keys).union(devices.keys)) } public func modificationDates() throws -> [VaultRecordKey: Date] { @@ -156,6 +177,63 @@ public final class HostPersistence { ) } + private func fetchConnectionHistory() throws -> [ConnectionHistoryEntry] { + try context.fetch(FetchDescriptor()) + .map(\.value) + .sorted(by: ConnectionHistoryEntry.isNewer) + } + + private func applyConnectionHistory(_ envelope: VaultEnvelope) throws { + let id = envelope.key.id + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + let existing = try context.fetch(descriptor).first + if envelope.isDeleted { + if let existing { context.delete(existing) } + return + } + guard case .connectionHistory(let value) = envelope.payload else { + throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + if let existing { + existing.apply(value) + } else { + context.insert(PersistentConnectionHistoryEntry(value)) + } + } + + private func fetchDevices() throws -> [DeviceRecord] { + try context.fetch(FetchDescriptor()).map(\.value) + } + + private func applyDevice(_ envelope: VaultEnvelope) throws { + let id = envelope.key.id + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id }) + descriptor.fetchLimit = 1 + let existing = try context.fetch(descriptor).first + if envelope.isDeleted { + if let existing { context.delete(existing) } + return + } + guard case .device(let value) = envelope.payload else { + throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + if let existing { + existing.displayName = value.displayName + } else { + context.insert(PersistentDeviceRecord(value)) + } + } + + private func pruneConnectionHistory() throws { + let rows = try context.fetch(FetchDescriptor()) + .sorted { ConnectionHistoryEntry.isNewer($0.value, $1.value) } + for row in rows.dropFirst(ConnectionHistoryModel.limit) { context.delete(row) } + } + private func reconcile( _ rows: [Model], with values: [Value], rowID: (Model) -> UUID, valueID: (Value) -> UUID, diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 9b450e92..237b626d 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -6,7 +6,7 @@ import SwiftData @MainActor public enum TermindPersistence { // Local schema migrations do not change the independently versioned CloudKit envelopes. - public static let schema = Schema(versionedSchema: TermindSchemaV31.self) + public static let schema = Schema(versionedSchema: TermindSchemaV32.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Frameworks/TermindPersistence/TermindSchemaV29HostModels.swift b/Frameworks/TermindPersistence/TermindSchemaV29HostModels.swift index 457630db..0c5b4560 100644 --- a/Frameworks/TermindPersistence/TermindSchemaV29HostModels.swift +++ b/Frameworks/TermindPersistence/TermindSchemaV29HostModels.swift @@ -128,3 +128,125 @@ public enum TermindSchemaV29HostModels { } } } + +/// V32 removes the obsolete Host usage column. ConnectionHistoryEntry is the +/// sole owner of connection recency and duration. +public enum TermindSchemaV32HostModels { + @Model + public final class PersistentHost { + @Attribute(.unique) public var id: UUID = UUID() + public var name = "" + public var address = "" + public var sshEnabled = true + public var port = 22 + public var identityID: UUID? + public var charset = RemoteCharset.utf8.rawValue + public var telnetEnabled = false + public var telnetPort = 23 + public var telnetIdentityID: UUID? + public var telnetCharset = RemoteCharset.utf8.rawValue + public var telnetProxyProtocol: String? + public var telnetProxyHostname = "" + public var telnetProxyPort = 0 + public var telnetProxyUsername = "" + public var telnetProxyIdentityID: UUID? + public var jumpHostID: UUID? + public var proxyProtocol: String? + public var proxyHostname = "" + public var proxyPort = 0 + public var proxyUsername = "" + public var proxyIdentityID: UUID? + public var sftpSessionInitialPath = "" + public var groupID: UUID? + public var tags: [String] = [] + public var colorTag = TagColor.none.rawValue + public var shellIntegrationMode = HostShellIntegrationMode.followGlobalSettings.rawValue + public var startupSnippetID: UUID? + public var environmentVariableNames: [String] = [] + public var environmentVariableValues: [String] = [] + public var portForwardingMaxConnections: Int? = Host.defaultPortForwardingMaxConnections + public var detectedDistributionID: String? + public var detectedDistributionLikeIDs: [String] = [] + public var sortIndex = 0.0 + + public init(_ value: Host) { + id = value.id + apply(value) + } + + public func apply(_ value: Host) { + name = value.name + address = value.host + sshEnabled = value.sshEnabled + port = value.sshPort + identityID = value.sshIdentityID + charset = value.sshCharset.rawValue + telnetEnabled = value.telnetEnabled + telnetPort = value.telnetPort + telnetIdentityID = value.telnetIdentityID + telnetCharset = value.telnetCharset.rawValue + telnetProxyProtocol = value.telnetProxy?.kind.rawValue + telnetProxyHostname = value.telnetProxy?.hostname ?? "" + telnetProxyPort = value.telnetProxy?.port ?? 0 + telnetProxyUsername = value.telnetProxy?.username ?? "" + telnetProxyIdentityID = value.telnetProxy?.identityID + jumpHostID = value.jumpHostID + proxyProtocol = value.proxy?.kind.rawValue + proxyHostname = value.proxy?.hostname ?? "" + proxyPort = value.proxy?.port ?? 0 + proxyUsername = value.proxy?.username ?? "" + proxyIdentityID = value.proxy?.identityID + sftpSessionInitialPath = value.sftpChannelInitialPath + groupID = value.groupID + tags = value.tags + colorTag = value.colorTag.rawValue + shellIntegrationMode = value.shellIntegrationMode.rawValue + startupSnippetID = value.startupSnippetID + environmentVariableNames = value.environmentVariables.map(\.variable) + environmentVariableValues = value.environmentVariables.map(\.value) + portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections( + value.portForwardingMaxConnections) + detectedDistributionID = value.detectedDistribution?.id + detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? [] + sortIndex = value.sortIndex + } + + public var value: Host { + var host = Host( + id: id, name: name, host: address, port: port, identityID: identityID, + charset: RemoteCharset(rawValue: charset) ?? .utf8, + sshEnabled: sshEnabled, telnetEnabled: telnetEnabled, telnetPort: telnetPort, + telnetIdentityID: telnetIdentityID, + telnetCharset: RemoteCharset(rawValue: telnetCharset) ?? .utf8, + groupID: groupID, tags: tags, + colorTag: TagColor(rawValue: colorTag) ?? .none, + detectedDistribution: detectedDistributionID.flatMap { + DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs) + }, + sortIndex: sortIndex, + shellIntegrationMode: HostShellIntegrationMode(rawValue: shellIntegrationMode) + ?? .followGlobalSettings, + startupSnippetID: startupSnippetID, + environmentVariables: zip(environmentVariableNames, environmentVariableValues).map { + HostEnvironmentVariable(variable: $0, value: $1) + }, + portForwardingMaxConnections: portForwardingMaxConnections + ?? Host.defaultPortForwardingMaxConnections + ) + host.jumpHostID = jumpHostID + host.sftpChannelInitialPath = sftpSessionInitialPath + if let telnetProxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: telnetProxyProtocol) { + host.telnetProxy = HostProxy( + kind: kind, hostname: telnetProxyHostname, port: telnetProxyPort, + username: telnetProxyUsername, identityID: telnetProxyIdentityID) + } + if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) { + host.proxy = HostProxy( + kind: kind, hostname: proxyHostname, port: proxyPort, + username: proxyUsername, identityID: proxyIdentityID + ) + } + return host + } + } +} diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 3cd56767..a4b63d86 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -1,3 +1,4 @@ +import DeviceKit import Foundation import Observation import SwiftData @@ -7,6 +8,7 @@ import TermindDomain import TermindInfrastructure import TermindPersistence import TermindRemoteInfrastructure +import UIKit import struct TermindDomain.Host @@ -53,6 +55,7 @@ final class MobileAppModel { let commandHistory: CommandHistoryModel let sftpHistory: SFTPHistoryModel let connectionHistory: ConnectionHistoryModel + let devices: DeviceDirectoryModel let vaultSync: VaultSyncCoordinator? let settingsSync: SettingsSyncCoordinator? let metricsSync: MetricsSyncCoordinator? @@ -67,6 +70,7 @@ final class MobileAppModel { let logging: any ApplicationLogManaging let liveActivities: MobileLiveActivityController @ObservationIgnored private var workspaceActivitySessions: [WorkspaceActivityAttributes.Session] = [] + @ObservationIgnored private var activeConnectionHistoryIDs: [MobileWorkspace.ID: ConnectionHistoryEntry.ID] = [:] private let defaults: UserDefaults? private let settingsDefaults: UserDefaults private let snippetTimestampFallback: Date @@ -159,8 +163,16 @@ final class MobileAppModel { repository: SwiftDataCommandHistoryRepository(context: ModelContext(container))) sftpHistory = SFTPHistoryModel( repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container))) + let connectionHistoryRepository = SwiftDataConnectionHistoryRepository(context: vaultContext) + let deviceDirectoryRepository = SwiftDataDeviceDirectoryRepository(context: vaultContext) + let currentDeviceID = try VaultSyncJournal(context: vaultContext).state().installationID + devices = DeviceDirectoryModel( + repository: deviceDirectoryRepository, + currentDeviceID: currentDeviceID, + currentDeviceName: Device.current.safeDescription) connectionHistory = ConnectionHistoryModel( - repository: SwiftDataConnectionHistoryRepository(context: ModelContext(container))) + repository: connectionHistoryRepository, + sourceDeviceID: currentDeviceID) settingsSync = memoryOnly ? nil @@ -170,6 +182,12 @@ final class MobileAppModel { ? nil : MetricsSyncCoordinator(persistence: metricsPersistence, defaults: settingsDefaults) vaultSync = memoryOnly ? nil : VaultSyncCoordinator(context: vaultContext, defaults: settingsDefaults) + connectionHistoryRepository.didRecordVaultChanges = { [weak vaultSync] in + vaultSync?.noteLocalChanges($0) + } + deviceDirectoryRepository.didRecordVaultChanges = { [weak vaultSync] in + vaultSync?.noteLocalChanges($0) + } ai = AIConfigurationModel( persistence: SyncedAIConfigurationPersistence(settingsPersistence), secrets: secrets, permissionDefaults: settingsDefaults, aiSecrets: aiSecrets, @@ -240,6 +258,7 @@ final class MobileAppModel { closeWorkspaces(for: payload.hostID) commandHistory.clear(for: payload.hostID) sftpHistory.clear(for: payload.hostID) + connectionHistory.clear(for: payload.hostID) library.projectHostDeletion(payload.hostID) }, cancelTransfers: { [weak self] payload in @@ -368,6 +387,7 @@ final class MobileAppModel { func close(_ id: UUID) { let wasSaved = savedWorkspaceDescriptors.contains(where: { $0.id == id }) guard wasSaved || workspaces.contains(where: { $0.id == id }) else { return } + endConnectionHistory(for: id) if let workspace = workspaces.first(where: { $0.id == id }) { workspace.didChangeState = nil workspace.didRequestClose = nil @@ -394,6 +414,7 @@ final class MobileAppModel { saveWorkspaces() workspaceActivitySessions.removeAll { $0.id != id } for workspace in closing { + endConnectionHistory(for: workspace.id) workspace.didChangeState = nil workspace.didRequestClose = nil workspace.disconnect() @@ -409,6 +430,7 @@ final class MobileAppModel { saveWorkspaces() workspaceActivitySessions.removeAll() for workspace in closing { + endConnectionHistory(for: workspace.id) workspace.didChangeState = nil workspace.didRequestClose = nil workspace.disconnect() @@ -510,6 +532,7 @@ final class MobileAppModel { } liveActivities.update(workspaceActivitySessions) } else if phase == .active { + devices.updateCurrentDeviceName(Device.current.safeDescription) sftpEdits.setMonitoring(true) Task { await sftpEdits.recheck() } Task { [weak self] in await self?.pro.refresh() } @@ -563,11 +586,16 @@ final class MobileAppModel { closeWorkspaces(for: change.id) commandHistory.clear(for: change.id) sftpHistory.clear(for: change.id) + connectionHistory.clear(for: change.id) metrics.remove(change.id) } } forwardings.stop(plan.forwardingRuleIDs) library.replacePersistedSnapshot(snapshot) + if keys.contains(where: { $0.kind == .connectionHistory }) { + connectionHistory.reload() + } + if keys.contains(where: { $0.kind == .device }) { devices.reload() } forwardings.reconcileConfiguration() liveActivities.update(workspaceActivitySessions) } @@ -594,7 +622,6 @@ final class MobileAppModel { descriptor: descriptor, hosts: library, transfers: transfers, sftpEdits: sftpEdits, terminalSettings: terminalSettings, commandHistory: commandHistory, sftpHistory: sftpHistory, connectionServices: workspaceConnectionServices) - connectionHistory.record(kind: descriptor.kind, for: descriptor.hostID) addWorkspaces([workspace]) return workspace } @@ -622,7 +649,6 @@ final class MobileAppModel { private func makeWorkspace( host: Host, kind: ConnectionHistoryEntry.Kind, startup: MobileWorkspaceStartup = .none ) -> MobileWorkspace { - connectionHistory.record(kind: kind, for: host.id) let initialCommand: String? let recordsInitialCommandInHistory: Bool let initialSFTPPath: String? @@ -676,7 +702,10 @@ final class MobileAppModel { private func addWorkspaces(_ added: [MobileWorkspace], persists: Bool = true) { guard !added.isEmpty else { return } - for workspace in added { observeActivityState(of: workspace) } + for workspace in added { + observeActivityState(of: workspace) + startConnectionHistory(for: workspace) + } workspaces.append(contentsOf: added) if persists { let savedIDs = Set(savedWorkspaceDescriptors.map(\.id)) @@ -688,6 +717,20 @@ final class MobileAppModel { for workspace in added { workspace.connect() } } + private func startConnectionHistory(for workspace: MobileWorkspace) { + guard activeConnectionHistoryIDs[workspace.id] == nil, + library.host(workspace.descriptor.hostID) != nil, + let entry = connectionHistory.record( + kind: workspace.descriptor.kind, for: workspace.descriptor.hostID) + else { return } + activeConnectionHistoryIDs[workspace.id] = entry.id + } + + private func endConnectionHistory(for workspaceID: MobileWorkspace.ID) { + guard let entryID = activeConnectionHistoryIDs.removeValue(forKey: workspaceID) else { return } + connectionHistory.end(entryID) + } + private func observeActivityState(of workspace: MobileWorkspace) { workspace.didRequestClose = { [weak self] id in self?.close(id) diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 9fb1c925..d3c96960 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -91,8 +91,10 @@ final class MobileSceneModel { case .connect: openConnectionPicker() return true - case .history(let hostID, let kind): - guard app.library.host(hostID) != nil else { + case .history(let hostID): + guard let host = app.library.host(hostID), + let kind = MobileConnectionHistory.preferredKind(for: host) + else { selectedDestination = .workspaces workspaceID = nil isWorkspaceUnavailable = true diff --git a/TFI/Application/Workspaces/MobileConnectionHistory.swift b/TFI/Application/Workspaces/MobileConnectionHistory.swift index b0a58fa7..f07827a5 100644 --- a/TFI/Application/Workspaces/MobileConnectionHistory.swift +++ b/TFI/Application/Workspaces/MobileConnectionHistory.swift @@ -5,6 +5,7 @@ struct MobileConnectionHistoryRow: Identifiable { let entry: ConnectionHistoryEntry let host: Host let loginAddress: String + let sourceDeviceName: String? var id: ConnectionHistoryEntry.ID { entry.id } var canOpen: Bool { @@ -12,6 +13,16 @@ struct MobileConnectionHistoryRow: Identifiable { } } +struct MobileRecentHostRow: Identifiable { + let entry: ConnectionHistoryEntry + let host: Host + let loginAddress: String + let preferredKind: ConnectionHistoryEntry.Kind? + + var id: Host.ID { host.id } + var canOpen: Bool { preferredKind != nil } +} + enum MobileConnectionHistoryActionPolicy { static let menuKinds: [ConnectionHistoryEntry.Kind] = [.ssh, .telnet, .sftp] @@ -29,32 +40,77 @@ enum MobileConnectionHistoryActionPolicy { @MainActor enum MobileConnectionHistory { - private struct Key: Hashable { - let hostID: Host.ID - let kind: ConnectionHistoryEntry.Kind - } - - static func rows( + static func recentHostRows( entries: [ConnectionHistoryEntry], library: HostLibraryModel, limit: Int - ) -> [MobileConnectionHistoryRow] { + ) -> [MobileRecentHostRow] { guard limit > 0 else { return [] } - var seen: Set = [] - var rows: [MobileConnectionHistoryRow] = [] + var seen = Set() + var rows: [MobileRecentHostRow] = [] for entry in entries { guard let host = library.host(entry.hostID) else { continue } - guard seen.insert(Key(hostID: entry.hostID, kind: entry.kind)).inserted else { continue } + guard seen.insert(host.id).inserted else { continue } + let preferredKind = preferredKind(for: host) rows.append( - MobileConnectionHistoryRow( + MobileRecentHostRow( entry: entry, host: host, - loginAddress: library.loginAddress( - for: host, protocol: entry.kind == .telnet ? .telnet : .ssh) + loginAddress: library.preferredTerminalLoginAddress(for: host), + preferredKind: preferredKind )) if rows.count == limit { break } } return rows } + + static func eventRows( + history: ConnectionHistoryModel, + devices: DeviceDirectoryModel, + library: HostLibraryModel, + query: String = "", + limit: Int + ) -> [MobileConnectionHistoryRow] { + guard limit > 0 else { return [] } + var rows: [MobileConnectionHistoryRow] = [] + for entry in history.entries() { + guard let host = library.host(entry.hostID) else { continue } + let loginAddress = library.loginAddress( + for: host, protocol: entry.kind == .telnet ? .telnet : .ssh) + let sourceDeviceName = devices.name(for: entry.sourceDeviceID) + guard + query.isEmpty + || matches( + entry, host: host, loginAddress: loginAddress, + sourceDeviceName: sourceDeviceName, query: query) + else { + continue + } + rows.append( + MobileConnectionHistoryRow( + entry: entry, host: host, loginAddress: loginAddress, + sourceDeviceName: sourceDeviceName)) + if rows.count == limit { break } + } + return rows + } + + static func preferredKind(for host: Host) -> ConnectionHistoryEntry.Kind? { + switch host.preferredTerminalProtocol { + case .ssh: .ssh + case .telnet: .telnet + case nil: nil + } + } + + private static func matches( + _ entry: ConnectionHistoryEntry, host: Host, loginAddress: String, + sourceDeviceName: String?, query: String + ) -> Bool { + host.matches(query) + || loginAddress.localizedCaseInsensitiveContains(query) + || entry.kind.label.localizedCaseInsensitiveContains(query) + || sourceDeviceName?.localizedCaseInsensitiveContains(query) == true + } } diff --git a/TFI/Features/HostChooser/MobileConnectionPicker.swift b/TFI/Features/HostChooser/MobileConnectionPicker.swift index 1b060e2b..ea49c17f 100644 --- a/TFI/Features/HostChooser/MobileConnectionPicker.swift +++ b/TFI/Features/HostChooser/MobileConnectionPicker.swift @@ -81,7 +81,7 @@ struct MobileHostPickerRow: View { var body: some View { HStack(spacing: 12) { - HostSystemIcon(asset: distributionEntry?.asset, size: 32) + HostSystemIcon(asset: distributionEntry?.asset, size: 36) .tint(tint) VStack(alignment: .leading, spacing: 2) { Text(host.name).foregroundStyle(.primary).lineLimit(1) diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 53128797..a8a49d99 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -22,6 +22,157 @@ nonisolated enum MobileHostMenuPolicy { ] } +struct MobileConnectionHistoryView: View { + let app: MobileAppModel + let open: (Host.ID, ConnectionHistoryEntry.Kind) -> Void + @Environment(\.locale) private var locale + @State private var query: String + @State private var clearingHistory = false + + init( + app: MobileAppModel, initialQuery: String = "", + open: @escaping (Host.ID, ConnectionHistoryEntry.Kind) -> Void + ) { + self.app = app + self.open = open + _query = State(initialValue: initialQuery) + } + + private var rows: [MobileConnectionHistoryRow] { + _ = app.connectionHistory.revision + return MobileConnectionHistory.eventRows( + history: app.connectionHistory, devices: app.devices, library: app.library, + query: query, limit: ConnectionHistoryModel.presentationLimit) + } + + var body: some View { + List { + if rows.isEmpty { + Group { + if query.isEmpty { + ContentUnavailableView("No History", systemImage: "clock.arrow.circlepath") + } else { + ContentUnavailableView.search(text: query) + } + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else { + ForEach(rows) { row in + historyButton(row) + .contextMenu { + ForEach(MobileConnectionHistoryActionPolicy.menuKinds, id: \.self) { kind in + connectionButton(kind, host: row.host) + } + Divider() + Button("Delete", systemImage: "trash", role: .destructive) { + app.connectionHistory.delete(row.entry.id) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button("Delete", systemImage: "trash", role: .destructive) { + app.connectionHistory.delete(row.entry.id) + } + ForEach(MobileConnectionHistoryActionPolicy.swipeKinds, id: \.self) { kind in + connectionButton(kind, host: row.host) + .tint(swipeTint(for: kind)) + } + } + } + } + } + .searchable(text: $query.singleLineText, prompt: "Search History") + .navigationTitle("History") + .toolbar { + MobileToolbarOverflow { + Button("Clear History", systemImage: "trash", role: .destructive) { + clearingHistory = true + } + .disabled(app.connectionHistory.entries().isEmpty) + } + } + .confirmationDialog( + "Clear connection history?", isPresented: $clearingHistory, + titleVisibility: .visible + ) { + Button("Clear History", role: .destructive) { + app.connectionHistory.clearAll() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This permanently removes all connection history without deleting any Hosts.") + } + } + + @ViewBuilder + private func historyButton(_ row: MobileConnectionHistoryRow) -> some View { + if row.canOpen { + Button { + open(row.host.id, row.entry.kind) + } label: { + historyLabel(row) + } + .buttonStyle(.plain) + } else { + historyLabel(row) + .opacity(0.55) + } + } + + private func historyLabel(_ row: MobileConnectionHistoryRow) -> some View { + HStack(spacing: 12) { + Image(systemName: row.entry.kind.symbol) + .foregroundStyle(.tint) + .frame(width: 36, height: 36) + .accessibilityLabel(Text(row.entry.kind.label)) + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .center, spacing: 12) { + Text(row.host.name).foregroundStyle(.primary).lineLimit(1) + Spacer(minLength: 12) + Text(row.sourceDeviceName ?? "—") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + HStack(alignment: .center, spacing: 12) { + Text(row.loginAddress) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 12) + Text(row.entry.connectedAt, format: .relative(presentation: .numeric).locale(locale)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + .accessibilityElement(children: .combine) + } + + private func connectionButton( + _ kind: ConnectionHistoryEntry.Kind, host: Host + ) -> some View { + Button(kind.label, systemImage: kind.symbol) { + guard MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host) else { return } + open(host.id, kind) + } + .disabled(!MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host)) + } + + private func swipeTint(for kind: ConnectionHistoryEntry.Kind) -> Color { + switch kind { + case .ssh: .blue + case .telnet: .purple + case .sftp: .orange + } + } +} + private struct MobileVaultLinkGroup: View { @ViewBuilder var content: Content @@ -93,6 +244,7 @@ struct MobileVaultView: View { case portForwarding(useQuery: Bool) case snippets(useQuery: Bool) case knownHosts(useQuery: Bool) + case history(useQuery: Bool) case metrics } @@ -149,6 +301,7 @@ struct MobileVaultView: View { private var matchesKeychainPage: Bool { pageTitle("Keychain").localizedCaseInsensitiveContains(query) } private var matchesSnippetsPage: Bool { pageTitle("Snippets").localizedCaseInsensitiveContains(query) } private var matchesKnownHostsPage: Bool { pageTitle("Known Hosts").localizedCaseInsensitiveContains(query) } + private var matchesHistoryPage: Bool { pageTitle("History").localizedCaseInsensitiveContains(query) } private var hostSections: [HostLibrarySection] { HostLibrarySectionLayout.sections(library: app.library) } private var keychainSearchLinks: [SearchLink] { @@ -220,6 +373,26 @@ struct MobileVaultView: View { return result } + private var historySearchLinks: [SearchLink] { + var result: [SearchLink] = [] + if matchesHistoryPage { + result.append( + SearchLink( + id: "history-page", title: pageTitle("History"), detail: nil, + symbol: "clock.arrow.circlepath", destination: .history(useQuery: false))) + } + result += MobileConnectionHistory.eventRows( + history: app.connectionHistory, devices: app.devices, library: app.library, + query: query, limit: ConnectionHistoryModel.presentationLimit + ).map { row in + SearchLink( + id: "history-\(row.entry.id)", title: row.host.name, + detail: row.sourceDeviceName, + symbol: row.entry.kind.symbol, destination: .history(useQuery: true)) + } + return result + } + private var metricSearchLinks: [SearchLink] { guard openMetrics != nil else { return [] } var result: [SearchLink] = [] @@ -239,7 +412,8 @@ struct MobileVaultView: View { private var hasSearchResults: Bool { !matchingHosts.isEmpty || !keychainSearchLinks.isEmpty || !snippetSearchLinks.isEmpty - || !forwardingSearchLinks.isEmpty || !knownHostSearchLinks.isEmpty || !metricSearchLinks.isEmpty + || !forwardingSearchLinks.isEmpty || !knownHostSearchLinks.isEmpty + || !historySearchLinks.isEmpty || !metricSearchLinks.isEmpty } var body: some View { @@ -353,6 +527,13 @@ struct MobileVaultView: View { } } } + if !historySearchLinks.isEmpty { + MobileCardSection(title: "History") { + MobileVaultLinkGroup { + searchLinkRows(historySearchLinks) + } + } + } if !metricSearchLinks.isEmpty { MobileCardSection(title: "Metrics") { MobileVaultLinkGroup { @@ -384,6 +565,11 @@ struct MobileVaultView: View { MobileKnownHostsView(app: app) .mobileReviewPage("vault.knownHosts", scene: reviewScene) } + MobileVaultLinkDivider() + vaultLink("History", symbol: "clock.arrow.circlepath") { + MobileConnectionHistoryView(app: app, open: open) + .mobileReviewPage("vault.history", scene: reviewScene) + } } } @@ -442,6 +628,11 @@ struct MobileVaultView: View { case .knownHosts(let useQuery): MobileKnownHostsView(app: app, initialQuery: useQuery ? query : "") .mobileReviewPage("vault.knownHosts", scene: reviewScene) + case .history(let useQuery): + MobileConnectionHistoryView( + app: app, initialQuery: useQuery ? query : "", open: open + ) + .mobileReviewPage("vault.history", scene: reviewScene) case .metrics: EmptyView() } diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index 0c7636d1..14aabd18 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -1,11 +1,18 @@ import SwiftUI import TermindDomain +import TermindPresentation import UIKit +private enum MobileWorkspaceListMetrics { + static let leadingIconSize: CGFloat = 36 + static let rowSpacing: CGFloat = 12 +} + /// The registered Workspaces destination. struct MobileWorkspaceListView: View { let app: MobileAppModel @Bindable var scene: MobileSceneModel + @Environment(\.colorScheme) private var colorScheme @Environment(\.locale) private var locale @State private var clearingHistory = false @@ -30,13 +37,14 @@ struct MobileWorkspaceListView: View { Button { scene.selectWorkspace(descriptor.id) } label: { - HStack(spacing: 12) { - Image(systemName: descriptor.kind.symbol) - .foregroundStyle(.tint) - .frame(width: 28, height: 28) - .accessibilityLabel(Text(descriptor.kind.label)) + HStack(spacing: MobileWorkspaceListMetrics.rowSpacing) { + hostIcon(host) VStack(alignment: .leading, spacing: 2) { - Text(descriptor.title).foregroundStyle(.primary).lineLimit(1) + HStack(alignment: .center, spacing: 12) { + Text(descriptor.title).foregroundStyle(.primary).lineLimit(1) + Spacer(minLength: 12) + protocolLabel(descriptor.kind) + } Text( app.library.loginAddress( for: host, @@ -47,7 +55,7 @@ struct MobileWorkspaceListView: View { .lineLimit(1) .truncationMode(.middle) } - Spacer(minLength: 0) + .frame(maxWidth: .infinity, alignment: .leading) } .contentShape(.rect) .accessibilityElement(children: .combine) @@ -146,21 +154,23 @@ struct MobileWorkspaceListView: View { Button { scene.selectWorkspace(workspace.id) } label: { - HStack(spacing: 12) { + HStack(spacing: MobileWorkspaceListMetrics.rowSpacing) { ZStack(alignment: .bottomTrailing) { - Image(systemName: workspace.descriptor.kind.symbol) - .foregroundStyle(.tint) - .frame(width: 28, height: 28) + hostIcon(app.library.host(workspace.descriptor.hostID)) Circle() .fill(statusTint(for: workspace)) .frame(width: 8, height: 8) .overlay { Circle().stroke(Color(uiColor: .systemBackground), lineWidth: 1.5) } } VStack(alignment: .leading, spacing: 2) { - Text(workspace.descriptor.title).foregroundStyle(.primary).lineLimit(1) + HStack(alignment: .center, spacing: 12) { + Text(workspace.descriptor.title).foregroundStyle(.primary).lineLimit(1) + Spacer(minLength: 12) + protocolLabel(workspace.descriptor.kind) + } Text(workspace.status).font(.caption).foregroundStyle(.secondary).lineLimit(1) } - Spacer(minLength: 0) + .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) @@ -183,10 +193,10 @@ struct MobileWorkspaceListView: View { } } - var historyRows: [MobileConnectionHistoryRow] { + var historyRows: [MobileRecentHostRow] { _ = app.connectionHistory.revision - return MobileConnectionHistory.rows( - entries: app.connectionHistory.entries(), + return MobileConnectionHistory.recentHostRows( + entries: app.connectionHistory.localEntries(), library: app.library, limit: Self.historyLimit ) @@ -198,76 +208,93 @@ struct MobileWorkspaceListView: View { historyButton(row) .contextMenu { ForEach(MobileConnectionHistoryActionPolicy.menuKinds, id: \.self) { kind in - historyConnectionButton(kind, for: row) + historyConnectionButton(kind, host: row.host) } Divider() Button("Delete", systemImage: "trash", role: .destructive) { - app.connectionHistory.delete(row.entry.id) + app.connectionHistory.clear(for: row.host.id) } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button("Delete", systemImage: "trash", role: .destructive) { - app.connectionHistory.delete(row.entry.id) + app.connectionHistory.clear(for: row.host.id) } ForEach(MobileConnectionHistoryActionPolicy.swipeKinds, id: \.self) { kind in - historyConnectionButton(kind, for: row) + historyConnectionButton(kind, host: row.host) .tint(swipeTint(for: kind)) } } } } - private func historyButton(_ row: MobileConnectionHistoryRow) -> some View { - Button { - guard row.canOpen else { return } - scene.open(hostID: row.host.id, kind: row.entry.kind) - } label: { - HStack(spacing: 12) { - Image(systemName: row.entry.kind.symbol) - .foregroundStyle(.tint) - .frame(width: 28, height: 28) - .accessibilityLabel(Text(row.entry.kind.label)) - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .center, spacing: 12) { - Text(row.host.name).foregroundStyle(.primary).lineLimit(1) - Spacer(minLength: 12) - Text(row.entry.kind.label) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .accessibilityHidden(true) - } - HStack(alignment: .center, spacing: 12) { - Text(row.loginAddress) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 12) - Text(row.entry.connectedAt, format: .relative(presentation: .numeric).locale(locale)) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } + @ViewBuilder + private func historyButton(_ row: MobileRecentHostRow) -> some View { + if let preferredKind = row.preferredKind { + Button { + scene.open(hostID: row.host.id, kind: preferredKind) + } label: { + historyLabel(row) + } + .buttonStyle(.plain) + } else { + historyLabel(row) + .opacity(0.55) + } + } + + private func historyLabel(_ row: MobileRecentHostRow) -> some View { + HStack(spacing: MobileWorkspaceListMetrics.rowSpacing) { + hostIcon(row.host) + VStack(alignment: .leading, spacing: 2) { + Text(row.host.name).foregroundStyle(.primary).lineLimit(1) + HStack(alignment: .center, spacing: 12) { + Text(row.loginAddress) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 12) + Text(row.entry.connectedAt, format: .relative(presentation: .numeric).locale(locale)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) } - .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - .accessibilityElement(children: .combine) } - .buttonStyle(.plain) - .disabled(!row.canOpen) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + .accessibilityElement(children: .combine) + } + + private func hostIcon(_ host: Host?) -> some View { + let distribution = host.flatMap { LinuxDistributionIconCatalog.entry(for: $0.detectedDistribution) } + let tint = + host?.colorTag.color + ?? distribution.flatMap { + LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme) + } + ?? .accentColor + return HostSystemIcon(asset: distribution?.asset, size: MobileWorkspaceListMetrics.leadingIconSize) + .tint(tint) + } + + private func protocolLabel(_ kind: ConnectionHistoryEntry.Kind) -> some View { + Text(kind.label) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .accessibilityHidden(true) } private func historyConnectionButton( - _ kind: ConnectionHistoryEntry.Kind, for row: MobileConnectionHistoryRow + _ kind: ConnectionHistoryEntry.Kind, host: Host ) -> some View { Button(kind.label, systemImage: kind.symbol) { - guard MobileConnectionHistoryActionPolicy.isAvailable(kind, for: row.host) else { return } - scene.open(hostID: row.host.id, kind: kind) + guard MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host) else { return } + scene.open(hostID: host.id, kind: kind) } - .disabled(!MobileConnectionHistoryActionPolicy.isAvailable(kind, for: row.host)) + .disabled(!MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host)) } private func swipeTint(for kind: ConnectionHistoryEntry.Kind) -> Color { diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index a7c0ecc9..b7d3505c 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -43,6 +43,7 @@ struct MobileWorkspaceView: View { @State private var workspaceIsVisible = false @State private var uploadDropTask: Task? @Environment(\.scenePhase) private var scenePhase + @Environment(\.colorScheme) private var colorScheme @Environment(\.mobileToolbarLayout) private var toolbarLayout @TerminalFontSettings private var font @State private var downloadTarget: RemoteFile? @@ -697,9 +698,43 @@ struct MobileWorkspaceView: View { ) } + @ViewBuilder private func workspaceTitleMenuLabel(for candidate: MobileWorkspace) -> some View { - let symbol = candidate.id == workspace.id ? "checkmark" : candidate.descriptor.kind.symbol - return Label(candidate.descriptor.title, systemImage: symbol) + if candidate.id == workspace.id { + Label(candidate.descriptor.title, systemImage: "checkmark") + } else { + Label { + Text(candidate.descriptor.title) + } icon: { + workspaceMenuIcon(for: candidate) + } + } + } + + private func workspaceMenuIcon(for candidate: MobileWorkspace) -> some View { + let host = library.host(candidate.descriptor.hostID) + let distribution = host.flatMap { LinuxDistributionIconCatalog.entry(for: $0.detectedDistribution) } + let tint = + host?.colorTag.color + ?? distribution.flatMap { + LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme) + } + ?? .accentColor + return ZStack(alignment: .bottomTrailing) { + HostSystemIcon(asset: distribution?.asset, size: 20, showsBackground: false) + .tint(tint) + Circle() + .fill(workspaceMenuStatusTint(for: candidate)) + .frame(width: 6, height: 6) + .overlay { Circle().stroke(Color(uiColor: .systemBackground), lineWidth: 1) } + } + .frame(width: 20, height: 20) + } + + private func workspaceMenuStatusTint(for candidate: MobileWorkspace) -> Color { + if candidate.connected { return .green } + if candidate.busy || candidate.reconnectState != nil { return .orange } + return .secondary } @ViewBuilder diff --git a/TFI/Platform/MobileHomeScreenQuickActions.swift b/TFI/Platform/MobileHomeScreenQuickActions.swift index 55190bc7..af29c347 100644 --- a/TFI/Platform/MobileHomeScreenQuickActions.swift +++ b/TFI/Platform/MobileHomeScreenQuickActions.swift @@ -1,11 +1,12 @@ import Combine import Foundation +import TermindApplication import TermindDomain import UIKit nonisolated enum MobileHomeScreenQuickAction: Equatable, Sendable { case connect - case history(hostID: Host.ID, kind: ConnectionHistoryEntry.Kind) + case history(hostID: Host.ID) } struct MobileHomeScreenQuickActionDescriptor: Equatable { @@ -18,10 +19,9 @@ struct MobileHomeScreenQuickActionDescriptor: Equatable { switch action { case .connect: userInfo = nil - case .history(let hostID, let kind): + case .history(let hostID): userInfo = [ - MobileHomeScreenQuickActions.hostIDKey: hostID.uuidString as NSString, - MobileHomeScreenQuickActions.kindKey: kind.rawValue as NSString, + MobileHomeScreenQuickActions.hostIDKey: hostID.uuidString as NSString ] } return UIApplicationShortcutItem( @@ -40,6 +40,7 @@ enum MobileHomeScreenQuickActions { static let connectType = "com.akinokaede.termind.quickAction.connect" static let historyType = "com.akinokaede.termind.quickAction.history" static let hostIDKey = "hostID" + /// Retained only so shortcuts emitted by an earlier build still decode. static let kindKey = "kind" static func type(for action: MobileHomeScreenQuickAction) -> String { @@ -56,31 +57,29 @@ enum MobileHomeScreenQuickActions { case historyType: guard let hostIDValue = shortcutItem.userInfo?[hostIDKey] as? NSString, - let hostID = UUID(uuidString: hostIDValue as String), - let kindValue = shortcutItem.userInfo?[kindKey] as? NSString, - let kind = ConnectionHistoryEntry.Kind(rawValue: kindValue as String) + let hostID = UUID(uuidString: hostIDValue as String) else { return nil } - return .history(hostID: hostID, kind: kind) + return .history(hostID: hostID) default: return nil } } - static func descriptors(rows: [MobileConnectionHistoryRow]) -> [MobileHomeScreenQuickActionDescriptor] { - rows.prefix(historyLimit).map { row in + static func descriptors(rows: [MobileRecentHostRow]) -> [MobileHomeScreenQuickActionDescriptor] { + rows.lazy.filter(\.canOpen).prefix(historyLimit).map { row in return MobileHomeScreenQuickActionDescriptor( - action: .history(hostID: row.host.id, kind: row.entry.kind), + action: .history(hostID: row.host.id), title: row.host.name, - symbol: row.entry.kind.symbol + symbol: "server.rack" ) } } static func refresh(app: MobileAppModel) { - let rows = MobileConnectionHistory.rows( - entries: app.connectionHistory.entries(), + let rows = MobileConnectionHistory.recentHostRows( + entries: app.connectionHistory.localEntries(), library: app.library, - limit: historyLimit + limit: ConnectionHistoryModel.limit ) UIApplication.shared.shortcutItems = descriptors(rows: rows).map(\.shortcutItem) } diff --git a/TFITests/MobileCardRenderingTests.swift b/TFITests/MobileCardRenderingTests.swift index 8a74d1e8..7748f2c0 100644 --- a/TFITests/MobileCardRenderingTests.swift +++ b/TFITests/MobileCardRenderingTests.swift @@ -101,6 +101,16 @@ struct MobileCardRenderingTests { open: { _, _ in }) } } + try await capture("history", language: language, scheme: scheme) { + NavigationStack { + MobileConnectionHistoryView(app: app, open: { _, _ in }) + } + } + try await capture("workspaces-recents", language: language, scheme: scheme) { + NavigationStack { + MobileWorkspaceListView(app: app, scene: MobileSceneModel(app: app)) + } + } try await capture("snippets", language: language, scheme: scheme) { NavigationStack { MobileSnippetsView(app: app) } } diff --git a/TFITests/MobileCardTests.swift b/TFITests/MobileCardTests.swift index 565744e5..922d0c31 100644 --- a/TFITests/MobileCardTests.swift +++ b/TFITests/MobileCardTests.swift @@ -131,16 +131,21 @@ struct MobileCardTests { let ssh = try #require(app.open(hostID: host.id, kind: .ssh)) let sftp = try #require(app.open(hostID: host.id, kind: .sftp)) - defer { - app.close(ssh) - app.close(sftp) - } #expect(Set(app.connectionHistory.entries(for: host.id).map(\.kind)) == [.ssh, .sftp]) + #expect( + app.connectionHistory.entries(for: host.id).allSatisfy { + app.devices.name(for: $0.sourceDeviceID)?.isEmpty == false + }) + #expect(app.connectionHistory.entries(for: host.id).allSatisfy { $0.endedAt == nil }) #expect(app.lastConnectedAt(for: host.id) != nil) let restored = ConnectionHistoryModel( repository: SwiftDataConnectionHistoryRepository(context: ModelContext(app.container))) #expect(Set(restored.entries(for: host.id).map(\.kind)) == [.ssh, .sftp]) + + app.close(ssh) + app.close(sftp) + #expect(app.connectionHistory.entries(for: host.id).allSatisfy { $0.endedAt != nil }) } @Test func hostAndIdentityColorsRemainDraftsUntilSavedAndRoundTripThroughPersistence() throws { diff --git a/TFITests/MobileConnectionPickerTests.swift b/TFITests/MobileConnectionPickerTests.swift index bcb049d1..6c5dfb02 100644 --- a/TFITests/MobileConnectionPickerTests.swift +++ b/TFITests/MobileConnectionPickerTests.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import TermindApplication import TermindDomain import Testing import UIKit @@ -42,7 +43,7 @@ struct MobileConnectionPickerTests { #expect(selections.last?.1 == .sftp) } - @Test func emptyWorkspaceHistoryKeepsTheLatestFiveUniqueHostAndProtocolPairs() throws { + @Test func emptyWorkspaceHistoryKeepsTheLatestFiveUniqueLocalHosts() throws { let app = try MobileAppModel(inMemory: true) let scene = MobileSceneModel(app: app) let repeatedHost = Host( @@ -58,23 +59,23 @@ struct MobileConnectionPickerTests { app.connectionHistory.record(kind: .ssh, for: repeatedHost.id, at: Date(timeIntervalSince1970: 70))) _ = app.connectionHistory.record( kind: .ssh, for: repeatedHost.id, at: Date(timeIntervalSince1970: 60)) - let newestTelnet = try #require( - app.connectionHistory.record(kind: .telnet, for: repeatedHost.id, at: Date(timeIntervalSince1970: 58))) - let newestSFTP = try #require( - app.connectionHistory.record(kind: .sftp, for: repeatedHost.id, at: Date(timeIntervalSince1970: 55))) - let expectedEntries = try [ - newestSSH, - newestTelnet, - newestSFTP, - #require( - app.connectionHistory.record(kind: .sftp, for: otherHosts[0].id, at: Date(timeIntervalSince1970: 50))), - #require( - app.connectionHistory.record(kind: .ssh, for: otherHosts[1].id, at: Date(timeIntervalSince1970: 40))), - ] _ = app.connectionHistory.record( - kind: .sftp, for: otherHosts[2].id, at: Date(timeIntervalSince1970: 30)) + kind: .telnet, for: repeatedHost.id, at: Date(timeIntervalSince1970: 58)) _ = app.connectionHistory.record( - kind: .ssh, for: otherHosts[3].id, at: Date(timeIntervalSince1970: 20)) + kind: .sftp, for: repeatedHost.id, at: Date(timeIntervalSince1970: 55)) + let other0 = try #require( + app.connectionHistory.record( + kind: .sftp, for: otherHosts[0].id, at: Date(timeIntervalSince1970: 50))) + let other1 = try #require( + app.connectionHistory.record( + kind: .ssh, for: otherHosts[1].id, at: Date(timeIntervalSince1970: 40))) + let other2 = try #require( + app.connectionHistory.record( + kind: .sftp, for: otherHosts[2].id, at: Date(timeIntervalSince1970: 30))) + let other3 = try #require( + app.connectionHistory.record( + kind: .ssh, for: otherHosts[3].id, at: Date(timeIntervalSince1970: 20))) + let expectedEntries = [newestSSH, other0, other1, other2, other3] _ = app.connectionHistory.record( kind: .ssh, for: UUID(), at: Date(timeIntervalSince1970: 80)) @@ -82,10 +83,9 @@ struct MobileConnectionPickerTests { #expect(rows.count == MobileWorkspaceListView.historyLimit) #expect(rows.map(\.entry.id) == expectedEntries.map(\.id)) - #expect(rows.prefix(3).allSatisfy { $0.entry.hostID == repeatedHost.id }) - #expect(rows[1].entry.kind == .telnet) - #expect(rows[1].loginAddress == app.library.loginAddress(for: repeatedHost, protocol: .telnet)) - #expect(Set(rows.map { "\($0.entry.hostID.uuidString):\($0.entry.kind.rawValue)" }).count == rows.count) + #expect(rows.first?.preferredKind == .ssh) + #expect(rows.first?.loginAddress == app.library.loginAddress(for: repeatedHost, protocol: .ssh)) + #expect(Set(rows.map(\.host.id)).count == rows.count) } @Test func recentConnectionActionsFollowEveryHostProtocolCombination() { @@ -105,23 +105,65 @@ struct MobileConnectionPickerTests { host.sshEnabled = false host.telnetEnabled = false assertAvailability(host, ssh: false, telnet: false, sftp: false) + #expect(MobileConnectionHistory.preferredKind(for: host) == nil) + + host.telnetEnabled = true + #expect(MobileConnectionHistory.preferredKind(for: host) == .telnet) + host.sshEnabled = true + #expect(MobileConnectionHistory.preferredKind(for: host) == .ssh) } - @Test func unavailableRecentEntryStaysVisibleButCannotOpen() throws { + @Test func unavailableHistoryEventStaysVisibleButCannotOpen() throws { let app = try MobileAppModel(inMemory: true) let host = Host( name: "Legacy Telnet", host: "legacy.example.invalid", sshEnabled: true, telnetEnabled: false) app.library.upsert(host) - let entry = try #require(app.connectionHistory.record(kind: .telnet, for: host.id)) + _ = try #require(app.connectionHistory.record(kind: .telnet, for: host.id)) let row = try #require( - MobileConnectionHistory.rows(entries: [entry], library: app.library, limit: 1).first) + MobileConnectionHistory.eventRows( + history: app.connectionHistory, devices: app.devices, + library: app.library, limit: 1 + ).first) #expect(row.entry.kind == .telnet) #expect(!row.canOpen) } + @Test func historyPageKeepsEveryEventSearchesDeviceNameAndCapsAtOneHundred() throws { + let app = try MobileAppModel(inMemory: true) + let host = Host(name: "History", host: "history.example.invalid") + app.library.upsert(host) + let historyRepository = InMemoryConnectionHistoryRepository() + let deviceRepository = InMemoryDeviceDirectoryRepository() + for index in (0...ConnectionHistoryModel.presentationLimit).reversed() { + let deviceID = UUID() + try deviceRepository.upsert( + DeviceRecord(id: deviceID, displayName: "device-\(index)")) + try historyRepository.insert( + ConnectionHistoryEntry( + hostID: host.id, kind: index.isMultiple(of: 2) ? .ssh : .sftp, + connectedAt: Date(timeIntervalSince1970: TimeInterval(index)), + sourceDeviceID: deviceID), + limit: ConnectionHistoryModel.limit) + } + let history = ConnectionHistoryModel(repository: historyRepository) + let devices = DeviceDirectoryModel(repository: deviceRepository) + + let rows = MobileConnectionHistory.eventRows( + history: history, devices: devices, library: app.library, + limit: ConnectionHistoryModel.presentationLimit) + #expect(rows.count == ConnectionHistoryModel.presentationLimit) + #expect(rows.first?.sourceDeviceName == "device-100") + #expect(rows.filter { $0.host.id == host.id }.count == ConnectionHistoryModel.presentationLimit) + + let searched = MobileConnectionHistory.eventRows( + history: history, devices: devices, library: app.library, + query: "device-17", limit: ConnectionHistoryModel.presentationLimit) + #expect(searched.map(\.sourceDeviceName) == ["device-17"]) + } + @Test func availableRecentTelnetActionOpensATelnetWorkspace() throws { let app = try MobileAppModel(inMemory: true) let host = Host( diff --git a/TFITests/MobileHomeScreenQuickActionTests.swift b/TFITests/MobileHomeScreenQuickActionTests.swift index 42b28de3..c638ed8c 100644 --- a/TFITests/MobileHomeScreenQuickActionTests.swift +++ b/TFITests/MobileHomeScreenQuickActionTests.swift @@ -1,4 +1,5 @@ import Foundation +import TermindApplication import TermindDomain import Testing import UIKit @@ -9,7 +10,7 @@ import struct TermindDomain.Host @Suite @MainActor struct MobileHomeScreenQuickActionTests { - @Test func descriptorsUseTheNewestThreeUniqueValidHistoryRows() throws { + @Test func descriptorsUseTheNewestThreeUniqueLocalHosts() throws { let app = try MobileAppModel(inMemory: true) let repeated = Host( name: "Production", host: "production.example.invalid", @@ -35,10 +36,10 @@ struct MobileHomeScreenQuickActionTests { _ = app.connectionHistory.record( kind: .ssh, for: UUID(), at: Date(timeIntervalSince1970: 70)) - let rows = MobileConnectionHistory.rows( - entries: app.connectionHistory.entries(), + let rows = MobileConnectionHistory.recentHostRows( + entries: app.connectionHistory.localEntries(), library: app.library, - limit: MobileHomeScreenQuickActions.historyLimit + limit: ConnectionHistoryModel.limit ) let descriptors = MobileHomeScreenQuickActions.descriptors(rows: rows) @@ -46,22 +47,24 @@ struct MobileHomeScreenQuickActionTests { #expect( descriptors.map(\.action) == [ - .history(hostID: repeated.id, kind: .ssh), - .history(hostID: repeated.id, kind: .telnet), - .history(hostID: repeated.id, kind: .sftp), + .history(hostID: repeated.id), + .history(hostID: others[0].id), + .history(hostID: others[1].id), ]) - #expect(descriptors.map(\.title) == ["Production", "Production", "Production"]) - #expect(descriptors.map(\.symbol) == ["terminal", "text.rectangle", "folder"]) + #expect(descriptors.map(\.title) == ["Production", "Host 0", "Host 1"]) + #expect(descriptors.allSatisfy { $0.symbol == "server.rack" }) #expect(UIImage(systemName: descriptors[1].symbol) != nil) #expect(descriptors[1].shortcutItem.icon != nil) } @Test func historyShortcutPayloadRoundTripsAndMalformedItemsAreRejected() throws { let hostID = UUID() - let row = MobileConnectionHistoryRow( + let host = Host(id: hostID, name: "Files", host: "files.example.invalid") + let row = MobileRecentHostRow( entry: ConnectionHistoryEntry(hostID: hostID, kind: .sftp), - host: Host(id: hostID, name: "Files", host: "files.example.invalid"), - loginAddress: "deploy@files.example.invalid" + host: host, + loginAddress: "files.example.invalid", + preferredKind: .ssh ) let descriptor = try #require( MobileHomeScreenQuickActions.descriptors(rows: [row]).first) @@ -73,7 +76,18 @@ struct MobileHomeScreenQuickActionTests { #expect(shortcutItem.icon != nil) #expect( MobileHomeScreenQuickActions.action(from: shortcutItem) - == .history(hostID: hostID, kind: .sftp)) + == .history(hostID: hostID)) + + let legacy = UIApplicationShortcutItem( + type: MobileHomeScreenQuickActions.historyType, + localizedTitle: "Legacy", + localizedSubtitle: nil, + icon: nil, + userInfo: [ + MobileHomeScreenQuickActions.hostIDKey: hostID.uuidString as NSString, + MobileHomeScreenQuickActions.kindKey: ConnectionHistoryEntry.Kind.sftp.rawValue as NSString, + ]) + #expect(MobileHomeScreenQuickActions.action(from: legacy) == .history(hostID: hostID)) let malformed = UIApplicationShortcutItem( type: MobileHomeScreenQuickActions.historyType, diff --git a/TFITests/MobileShortcutTests.swift b/TFITests/MobileShortcutTests.swift index 90a2e64d..f4da5a55 100644 --- a/TFITests/MobileShortcutTests.swift +++ b/TFITests/MobileShortcutTests.swift @@ -82,21 +82,21 @@ struct MobileShortcutTests { #expect(scene.isConnectionPickerPresented) } - @Test func homeScreenHistoryActionOpensItsProtocolAndReportsAMissingHost() throws { + @Test func homeScreenHistoryActionOpensThePreferredTerminalAndReportsAMissingHost() throws { let app = try MobileAppModel(inMemory: true) let host = Host(name: "Files", host: "files.example.invalid") app.library.upsert(host) let scene = MobileSceneModel(app: app) defer { app.closeAll() } - #expect(scene.perform(.history(hostID: host.id, kind: .sftp))) + #expect(scene.perform(.history(hostID: host.id))) scene.activatePendingWorkspace() #expect(scene.activeWorkspace?.descriptor.hostID == host.id) - #expect(scene.activeWorkspace?.descriptor.kind == .sftp) - #expect(app.connectionHistory.entries(for: host.id).map(\.kind) == [.sftp]) + #expect(scene.activeWorkspace?.descriptor.kind == .ssh) + #expect(app.connectionHistory.entries(for: host.id).map(\.kind) == [.ssh]) let historyCount = app.connectionHistory.entries().count - #expect(!scene.perform(.history(hostID: UUID(), kind: .ssh))) + #expect(!scene.perform(.history(hostID: UUID()))) #expect(scene.isWorkspaceUnavailable) #expect(app.connectionHistory.entries().count == historyCount) } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index d05cabd4..a388de35 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -58,6 +58,7 @@ final class AppModel { let commandHistory: CommandHistoryModel let sftpHistory: SFTPHistoryModel let connectionHistory: ConnectionHistoryModel + let devices: DeviceDirectoryModel private let externalToolCalls: ToolAuditModel var toolAudit: ToolAuditModel { externalToolCalls } let externalAuthentication: ExternalAuthCoordinator @@ -146,6 +147,7 @@ final class AppModel { commandHistory: CommandHistoryModel = CommandHistoryModel(), sftpHistory: SFTPHistoryModel = SFTPHistoryModel(), connectionHistory: ConnectionHistoryModel = ConnectionHistoryModel(), + devices: DeviceDirectoryModel? = nil, externalToolCalls: ToolAuditModel = ToolAuditModel(), dependencies: AppDependencies? = nil, speechTranscription: SpeechTranscriptionCoordinator? = nil, @@ -185,6 +187,7 @@ final class AppModel { self.commandHistory = commandHistory self.sftpHistory = sftpHistory self.connectionHistory = connectionHistory + self.devices = devices ?? DeviceDirectoryModel(currentDeviceID: connectionHistory.sourceDeviceID) self.externalToolCalls = externalToolCalls connections = connectionCoordinator serialPorts = dependencies.serialPortService @@ -291,6 +294,7 @@ final class AppModel { repository: hostRepository, closeWorkspaces: { [weak self] deletion in self?.closeWorkspaces(for: deletion.hostID) + self?.connectionHistory.clear(for: deletion.hostID) }, cancelTransfers: { [weak self] deletion in self?.sftpEdits.invalidate(hostID: deletion.hostID) @@ -373,6 +377,10 @@ final class AppModel { } if !plan.forwardingRuleIDs.isEmpty { forwarding.stop(plan.forwardingRuleIDs) } hosts.replacePersistedSnapshot(snapshot) + if changedKeys.contains(where: { $0.kind == .connectionHistory }) { + connectionHistory.reload() + } + if changedKeys.contains(where: { $0.kind == .device }) { devices.reload() } forwarding.reconcileConfiguration() } @@ -439,6 +447,7 @@ final class AppModel { func registerWindow(_ window: WindowModel) { guard !window.isClosed, windows[ObjectIdentifier(window)]?.value !== window else { return } + configureConnectionHistory(in: window) window.connectionCoordinator = connections window.hostStore = hosts window.metricsModel = serverMetrics @@ -706,6 +715,7 @@ final class AppModel { as target: HostOpenTarget, in window: WindowModel ) -> Workspace { + configureConnectionHistory(in: window) beginOpening(host, as: target) switch target { @@ -837,19 +847,6 @@ final class AppModel { /// thing that should differ between them. Inlining this was fine while there /// were two. private func beginOpening(_ host: Host, as target: HostOpenTarget) { - if hosts.host(host.id) != nil { - let kind: ConnectionHistoryEntry.Kind - switch target { - case .preferredTerminal: kind = host.preferredTerminalProtocol == .telnet ? .telnet : .ssh - case .sshSession: kind = .ssh - case .telnetSession: kind = .telnet - case .sftpChannel: kind = .sftp - } - connectionHistory.record( - kind: kind, - for: host.id - ) - } switch target { case .preferredTerminal, .sshSession, .telnetSession: hosts.setStatus(.connecting, for: host.id) @@ -876,9 +873,10 @@ final class AppModel { /// Saved Hosts ordered by their latest connection event, with each Host /// appearing only once. - func recentHosts(limit: Int = 5) -> [Host] { + func recentHosts(limit: Int = 5, localOnly: Bool = false) -> [Host] { var seen = Set() - return connectionHistory.entries().compactMap { entry in + let entries = localOnly ? connectionHistory.localEntries() : connectionHistory.entries() + return entries.compactMap { entry in guard seen.insert(entry.hostID).inserted else { return nil } return hosts.host(entry.hostID) }.prefix(limit).map { $0 } @@ -888,6 +886,10 @@ final class AppModel { connectionHistory.mostRecentDate(for: hostID) } + func lastConnectedLocallyAt(for hostID: Host.ID) -> Date? { + connectionHistory.mostRecentLocalDate(for: hostID) + } + /// Opens multiple Hosts, with the layout chosen by the calling surface. /// /// The workspaces path is a loop over the singular `open` rather than its own code @@ -905,6 +907,7 @@ final class AppModel { layout: BatchLayout = .separateWorkspaces, in window: WindowModel ) { + configureConnectionHistory(in: window) switch layout { case .separateWorkspaces: let opened = hosts.map { open($0, as: target, in: window) } @@ -922,6 +925,7 @@ final class AppModel { _ snippets: [Snippet], overrideHostIDs: [Host.ID]? = nil, fallbackHostIDs: [Host.ID]? = nil, in window: WindowModel ) { + configureConnectionHistory(in: window) var opened: [Workspace] = [] for snippet in snippets { let ids = @@ -942,6 +946,16 @@ final class AppModel { if let first = opened.first { window.select(first.id) } } + private func configureConnectionHistory(in window: WindowModel) { + window.recordConnectionHistory = { [weak self] hostID, kind in + guard let self, self.hosts.host(hostID) != nil else { return nil } + return self.connectionHistory.record(kind: kind, for: hostID)?.id + } + window.endConnectionHistory = { [weak self] id in + self?.connectionHistory.end(id) + } + } + /// ⌘T, the window toolbar's `+`, the workspace bar's `+`, and Welcome. Raises the /// host chooser. /// diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 66448ea0..8dd5c08a 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -1,4 +1,5 @@ import AgentKitScrubber +import DeviceKit import SwiftData import SwiftUI import TermindApplication @@ -81,6 +82,21 @@ struct TermindApp: App { ? nil : VaultSyncCoordinator(context: container.mainContext) vaultSync?.beforeSend = { [weak hosts] in try hosts?.migrateCustomPasswordsToICloud() } + let connectionHistoryRepository = SwiftDataConnectionHistoryRepository( + context: container.mainContext) + let deviceDirectoryRepository = SwiftDataDeviceDirectoryRepository( + context: container.mainContext) + let connectionHistoryDeviceID = try VaultSyncJournal(context: container.mainContext).state().installationID + connectionHistoryRepository.didRecordVaultChanges = { [weak vaultSync] in + vaultSync?.noteLocalChanges($0) + } + deviceDirectoryRepository.didRecordVaultChanges = { [weak vaultSync] in + vaultSync?.noteLocalChanges($0) + } + let devices = DeviceDirectoryModel( + repository: deviceDirectoryRepository, + currentDeviceID: connectionHistoryDeviceID, + currentDeviceName: Self.computerName) let externalToolCalls = ToolAuditModel( modelContext: container.mainContext, hostName: { [weak hosts] id in hosts?.host(id)?.name } @@ -113,8 +129,10 @@ struct TermindApp: App { repository: SwiftDataSFTPHistoryRepository(context: container.mainContext) ), connectionHistory: ConnectionHistoryModel( - modelContext: container.mainContext + repository: connectionHistoryRepository, + sourceDeviceID: connectionHistoryDeviceID ), + devices: devices, externalToolCalls: externalToolCalls, dependencies: AppDependencies( hostRepository: repository, @@ -139,6 +157,10 @@ struct TermindApp: App { } } + fileprivate static var computerName: String { + Device.current.safeDescription + } + #if canImport(AppKit) @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate #endif @@ -264,6 +286,7 @@ struct TermindApp: App { } func applicationDidBecomeActive(_ notification: Notification) { + app?.devices.updateCurrentDeviceName(TermindApp.computerName) Task { [weak self] in await self?.app?.proAccess.refresh() } } diff --git a/TFM/Features/Hosts/HostActions.swift b/TFM/Features/Hosts/HostActions.swift index 665c978f..50e6cc62 100644 --- a/TFM/Features/Hosts/HostActions.swift +++ b/TFM/Features/Hosts/HostActions.swift @@ -15,6 +15,7 @@ import struct TermindDomain.Host struct HostContextMenu: View { let hosts: [Host] let showsDuplicate: Bool + let showsDelete: Bool let requestClearAllHistory: ([Host.ID]) -> Void var selectionDeleted: (() -> Void)? @@ -24,10 +25,12 @@ struct HostContextMenu: View { init( host: Host, showsDuplicate: Bool = true, + showsDelete: Bool = true, requestClearAllHistory: @escaping ([Host.ID]) -> Void ) { hosts = [host] self.showsDuplicate = showsDuplicate + self.showsDelete = showsDelete self.requestClearAllHistory = requestClearAllHistory selectionDeleted = nil } @@ -35,11 +38,13 @@ struct HostContextMenu: View { init( hosts: [Host], showsDuplicate: Bool = true, + showsDelete: Bool = true, selectionDeleted: (() -> Void)? = nil, requestClearAllHistory: @escaping ([Host.ID]) -> Void ) { self.hosts = hosts self.showsDuplicate = showsDuplicate + self.showsDelete = showsDelete self.selectionDeleted = selectionDeleted self.requestClearAllHistory = requestClearAllHistory } @@ -106,15 +111,17 @@ struct HostContextMenu: View { Divider() } - DestructiveMenuButton("Clear All History", systemImage: MenuSymbol.clearHistory) { + DestructiveMenuButton("Clear History", systemImage: MenuSymbol.clearHistory) { requestClearAllHistory(hosts.map(\.id)) } .disabled(!hasHistory) - DestructiveMenuButton("Delete") { - for host in hosts { app.requestHostDeletion(host.id) } - selectionDeleted?() + if showsDelete { + DestructiveMenuButton("Delete") { + for host in hosts { app.requestHostDeletion(host.id) } + selectionDeleted?() + } + .disabled(hosts.isEmpty) } - .disabled(hosts.isEmpty) } private var hasHistory: Bool { @@ -140,7 +147,7 @@ private struct ClearAllHostHistoryConfirmation: ViewModifier { ), titleVisibility: .visible ) { - Button("Clear All History", role: .destructive) { + Button("Clear History", role: .destructive) { guard let hostIDs else { return } app.clearAllHistory(for: hostIDs) self.hostIDs = nil diff --git a/TFM/Features/Sidebar/WindowSidebarView.swift b/TFM/Features/Sidebar/WindowSidebarView.swift index cabb53d5..c4eaf336 100644 --- a/TFM/Features/Sidebar/WindowSidebarView.swift +++ b/TFM/Features/Sidebar/WindowSidebarView.swift @@ -1207,13 +1207,18 @@ private struct ConnectionHistorySidebar: View { } private var rows: [(entry: ConnectionHistoryEntry, host: Host)] { - guard !query.isEmpty else { return allRows } - return allRows.filter { entry, host in - host.matches(query) - || historyAddress(entry, host: host) - .localizedCaseInsensitiveContains(query) - || entry.kind.label.localizedCaseInsensitiveContains(query) - } + let matches = + query.isEmpty + ? allRows + : allRows.filter { entry, host in + host.matches(query) + || historyAddress(entry, host: host) + .localizedCaseInsensitiveContains(query) + || entry.kind.label.localizedCaseInsensitiveContains(query) + || app.devices.name(for: entry.sourceDeviceID)? + .localizedCaseInsensitiveContains(query) == true + } + return Array(matches.prefix(ConnectionHistoryModel.presentationLimit)) } var body: some View { @@ -1302,7 +1307,7 @@ private struct ConnectionHistorySidebar: View { isSelected: selectedID == entry.id ) { VStack(alignment: .trailing, spacing: 1) { - Text(entry.kind.label) + Text(app.devices.name(for: entry.sourceDeviceID) ?? "—") .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) Text(entry.connectedAt, format: .relative(presentation: .numeric).locale(locale)) diff --git a/TFM/Features/Workspaces/Pane.swift b/TFM/Features/Workspaces/Pane.swift index f2d04b93..2dc952c6 100644 --- a/TFM/Features/Workspaces/Pane.swift +++ b/TFM/Features/Workspaces/Pane.swift @@ -96,6 +96,8 @@ nonisolated enum PaneKind: Hashable, Sendable { final class Pane: Identifiable { let id = UUID() var content: PaneContent + private var connectionHistoryEntryID: ConnectionHistoryEntry.ID? + private var endConnectionHistory: ((ConnectionHistoryEntry.ID) -> Void)? /// A name the user gave this pane. `nil` means the title tracks the content /// — the host it is on, the page it shows. @@ -139,6 +141,16 @@ final class Pane: Identifiable { session.historyStore = store } + func configureConnectionHistory( + entryID: ConnectionHistoryEntry.ID, + end: @escaping (ConnectionHistoryEntry.ID) -> Void + ) { + connectionHistoryEntryID = entryID + endConnectionHistory = end + } + + var hasActiveConnectionHistory: Bool { connectionHistoryEntryID != nil } + var kind: PaneKind { switch content { case .sftpChannel: .sftpChannel @@ -195,6 +207,11 @@ final class Pane: Identifiable { /// disappearance is not ownership: dragging a pane reparents its view and /// must leave the same live session connected. func disconnectConnection() { + if let connectionHistoryEntryID { + endConnectionHistory?(connectionHistoryEntryID) + self.connectionHistoryEntryID = nil + endConnectionHistory = nil + } switch content { case .sshSession(let sshSession): sshSession.disconnect() case .telnetSession(let telnetSession): telnetSession.disconnect() diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 2727b5d5..e5145d56 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -299,6 +299,9 @@ final class WindowModel { set { workspaceDeck.reopenStack = newValue } } + var recordConnectionHistory: ((Host.ID, ConnectionHistoryEntry.Kind) -> ConnectionHistoryEntry.ID?)? + var endConnectionHistory: ((ConnectionHistoryEntry.ID) -> Void)? + init( workspaces: [Workspace] = [], vaultDestination: VaultDestination = .welcome, @@ -815,6 +818,7 @@ final class WindowModel { _ workspace: Workspace, select shouldSelect: Bool = true ) -> Workspace { + workspace.panes.forEach(configureConnectionHistory) workspace.lifecycleRouter = self if let connectionCoordinator { workspace.configureConnections(using: connectionCoordinator) @@ -863,6 +867,7 @@ final class WindowModel { ) pane.sshSession?.connectionRoute = connectionRoute numberPane(pane) + configureConnectionHistory(pane) if placement == .currentWorkspace, let workspace = selectedWorkspace, @@ -879,6 +884,7 @@ final class WindowModel { func openTelnetSession(host: Host) -> Workspace { let pane = Pane.telnetSession(host: host) numberPane(pane) + configureConnectionHistory(pane) return open(Workspace(pane: pane)) } @@ -987,6 +993,7 @@ final class WindowModel { ) -> Workspace { let pane = Pane.sftpChannel(host: host, initialPath: initialPath) numberPane(pane) + configureConnectionHistory(pane) if let connectionCoordinator { pane.configureConnections(using: connectionCoordinator) } if let sftpHistoryStore { pane.configureSFTPHistory(using: sftpHistoryStore) } @@ -1009,6 +1016,19 @@ final class WindowModel { open(Workspace(pane: .serialSession(configuration: configuration, service: service))) } + private func configureConnectionHistory(_ pane: Pane) { + guard !pane.hasActiveConnectionHistory, let hostID = pane.hostID else { return } + let kind: ConnectionHistoryEntry.Kind + switch pane.kind { + case .sshSession: kind = .ssh + case .telnetSession: kind = .telnet + case .sftpChannel: kind = .sftp + case .serialSession, .empty: return + } + guard let entryID = recordConnectionHistory?(hostID, kind), let endConnectionHistory else { return } + pane.configureConnectionHistory(entryID: entryID, end: endConnectionHistory) + } + /// Opens the host editor, reusing the workspace that already holds one for the /// same host — and the same initial group counts as the same new Host form. /// The same reasoning as `openPage`: a half-typed form is easier to find diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index 62c6e974..856ca3d4 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -1074,7 +1074,7 @@ struct WelcomeView: View { } } } - let hosts = app.recentHosts(limit: max(0, 6 - templates.count)) + let hosts = app.recentHosts(limit: max(0, 6 - templates.count), localOnly: true) if !hosts.isEmpty { section("Recents") { ForEach(hosts) { host in @@ -1297,15 +1297,20 @@ private struct WelcomeHostRow: View { .contextMenu { HostContextMenu( host: host, + showsDelete: false, requestClearAllHistory: { clearHistoryHostIDs = $0 } ) + Divider() + Button("Delete", systemImage: MenuSymbol.delete, role: .destructive) { + app.connectionHistory.clear(for: host.id) + } } .clearAllHostHistoryConfirmation(hostIDs: $clearHistoryHostIDs) } @ViewBuilder private var trailing: some View { - if let date = app.lastConnectedAt(for: host.id) { + if let date = app.lastConnectedLocallyAt(for: host.id) { Text(date, format: .relative(presentation: .numeric)) .foregroundStyle(.tertiary) } diff --git a/TFMTests/ConnectionHistoryTests.swift b/TFMTests/ConnectionHistoryTests.swift index 52d1f2f5..f8256c81 100644 --- a/TFMTests/ConnectionHistoryTests.swift +++ b/TFMTests/ConnectionHistoryTests.swift @@ -17,6 +17,8 @@ import struct TermindDomain.Host struct ConnectionHistoryTests { @Test func connectionProtocolSymbolsAreDistinct() { + #expect(ConnectionHistoryModel.limit == 300) + #expect(ConnectionHistoryModel.presentationLimit == 100) #expect(ConnectionHistoryEntry.Kind.ssh.symbol == "terminal") #expect(ConnectionHistoryEntry.Kind.telnet.symbol == "text.rectangle") #expect(ConnectionHistoryEntry.Kind.sftp.symbol == "folder") @@ -63,7 +65,6 @@ struct ConnectionHistoryTests { let migratedHosts = try migrated.mainContext.fetch(FetchDescriptor()) #expect(migratedHosts.count == 1) - #expect(migratedHosts.first?.lastUsedAt == nil) #expect( try migrated.mainContext.fetch( FetchDescriptor() @@ -91,6 +92,62 @@ struct ConnectionHistoryTests { #expect(Set(entries.map(\.id)).count == 3) } + @Test + func recordsTheLocalDeviceAndEndsWhenItsPaneCloses() throws { + let host = Host(name: "Server", host: "server.example") + let deviceID = UUID() + let devices = DeviceDirectoryModel( + currentDeviceID: deviceID, currentDeviceName: "Test Mac") + let history = ConnectionHistoryModel(sourceDeviceID: deviceID) + let app = AppModel( + hosts: HostLibraryModel(hosts: [host]), connectionHistory: history, + devices: devices) + let window = WindowModel() + + let workspace = app.open(host, as: .sshSession, in: window) + let opened = try #require(history.entries().first) + #expect(opened.sourceDeviceID == deviceID) + #expect(devices.name(for: opened.sourceDeviceID) == "Test Mac") + #expect(opened.endedAt == nil) + + window.close(workspace.id) + + let ended = try #require(history.entries().first) + #expect(ended.endedAt != nil) + #expect(ended.endedAt.map { $0 >= ended.connectedAt } == true) + } + + @Test + func localProjectionUsesStableDeviceIDAndDeviceRenameUpdatesExistingEvents() throws { + let localID = UUID() + let remoteID = UUID() + let hostID = UUID() + let stableAfterRename = ConnectionHistoryEntry( + hostID: hostID, kind: .ssh, connectedAt: Date(timeIntervalSince1970: 3), + sourceDeviceID: localID) + let missingDeviceID = ConnectionHistoryEntry( + hostID: hostID, kind: .telnet, connectedAt: Date(timeIntervalSince1970: 2), + sourceDeviceID: nil) + let remote = ConnectionHistoryEntry( + hostID: hostID, kind: .sftp, connectedAt: Date(timeIntervalSince1970: 1), + sourceDeviceID: remoteID) + let historyRepository = InMemoryConnectionHistoryRepository( + entries: [stableAfterRename, missingDeviceID, remote]) + let model = ConnectionHistoryModel( + repository: historyRepository, sourceDeviceID: localID) + let devices = DeviceDirectoryModel( + currentDeviceID: localID, currentDeviceName: "old-name") + + #expect(model.localEntries().map(\.id) == [stableAfterRename.id]) + #expect(model.isLocal(stableAfterRename)) + #expect(!model.isLocal(missingDeviceID)) + #expect(!model.isLocal(remote)) + #expect(devices.name(for: stableAfterRename.sourceDeviceID) == "old-name") + + devices.updateCurrentDeviceName("Renamed Test Mac") + #expect(devices.name(for: stableAfterRename.sourceDeviceID) == "Renamed Test Mac") + } + @Test func globalLimitKeepsTheNewestAttempts() { let model = ConnectionHistoryModel() @@ -163,6 +220,32 @@ struct ConnectionHistoryTests { #expect(app.lastConnectedAt(for: first.id) != nil) } + @Test + func welcomeRecentsUseOnlyThisDevicesLatestEvents() { + let localID = UUID() + let local = Host(name: "Local", host: "local.example") + let remote = Host(name: "Remote", host: "remote.example") + let history = ConnectionHistoryModel( + entries: [ + ConnectionHistoryEntry( + hostID: remote.id, kind: .ssh, + connectedAt: Date(timeIntervalSince1970: 20), + sourceDeviceID: UUID()), + ConnectionHistoryEntry( + hostID: local.id, kind: .ssh, + connectedAt: Date(timeIntervalSince1970: 10), + sourceDeviceID: localID), + ], + sourceDeviceID: localID) + let app = AppModel( + hosts: HostLibraryModel(hosts: [local, remote]), connectionHistory: history) + + #expect(app.recentHosts(localOnly: true).map(\.id) == [local.id]) + #expect(app.recentHosts(localOnly: false).map(\.id) == [remote.id, local.id]) + #expect(app.lastConnectedLocallyAt(for: local.id) == Date(timeIntervalSince1970: 10)) + #expect(app.lastConnectedLocallyAt(for: remote.id) == nil) + } + @Test func clearAllHistoryForHostKeepsHostAndOtherHostHistory() { let host = Host(name: "Host", host: "host.example") diff --git a/TFMTests/RepositoryOutboxTests.swift b/TFMTests/RepositoryOutboxTests.swift index 932efc47..499eeda3 100644 --- a/TFMTests/RepositoryOutboxTests.swift +++ b/TFMTests/RepositoryOutboxTests.swift @@ -201,10 +201,8 @@ struct RepositoryOutboxTests { PersistentSFTPHistoryEntry( SFTPHistoryEntry(hostID: doomed.id, kind: .directory, path: "/srv") )) - context.insert( - PersistentConnectionHistoryEntry( - ConnectionHistoryEntry(hostID: doomed.id, kind: .ssh) - )) + let connectionHistory = ConnectionHistoryEntry(hostID: doomed.id, kind: .ssh) + context.insert(PersistentConnectionHistoryEntry(connectionHistory)) try context.save() let repository = SwiftDataHostRepository(context: context) @@ -241,6 +239,10 @@ struct RepositoryOutboxTests { #expect(try context.fetch(FetchDescriptor()).isEmpty) #expect(try context.fetch(FetchDescriptor()).isEmpty) #expect(try context.fetch(FetchDescriptor()).isEmpty) + let connectionHistoryEnvelope = try #require( + try VaultSyncJournal(context: context).envelope( + for: VaultRecordKey(kind: .connectionHistory, id: connectionHistory.id))) + #expect(connectionHistoryEnvelope.isDeleted) #expect(try context.fetch(FetchDescriptor()).isEmpty) #expect( Set(try context.fetch(FetchDescriptor()).map(\.id)) diff --git a/TFMTests/VaultSyncTests.swift b/TFMTests/VaultSyncTests.swift index eaadaa4d..f8965156 100644 --- a/TFMTests/VaultSyncTests.swift +++ b/TFMTests/VaultSyncTests.swift @@ -50,6 +50,12 @@ struct VaultSyncTests { let package = SnippetPackage(name: "Operations") let snippet = Snippet(title: "Uptime", body: "uptime", packageID: package.id) let forwarding = PortForwardingRule(hostID: host.id) + let connection = ConnectionHistoryEntry( + hostID: host.id, kind: .ssh, + sourceDeviceID: UUID(), endedAt: Date(timeIntervalSince1970: 30) + ) + let sourceDevice = DeviceRecord( + id: try #require(connection.sourceDeviceID), displayName: "Source Mac") let workspaceTemplate = WorkspaceTemplate( name: "Production", layout: .split( @@ -63,6 +69,7 @@ struct VaultSyncTests { .host(host), .hostGroup(group), .identity(identity), .sshKey(key), .knownHost(knownHost), .snippet(snippet), .snippetPackage(package), .portForwarding(forwarding), .workspaceTemplate(workspaceTemplate), + .connectionHistory(connection), .device(sourceDevice), ] let writer = UUID() @@ -76,6 +83,94 @@ struct VaultSyncTests { } } + @Test + func connectionHistorySyncsDeviceMappingRenameEndTimeAndDeletion() throws { + let source = try makeContainer("ConnectionHistorySource") + let destination = try makeContainer("ConnectionHistoryDestination") + let sourceHistoryRepository = SwiftDataConnectionHistoryRepository(context: source.mainContext) + let sourceDeviceRepository = SwiftDataDeviceDirectoryRepository(context: source.mainContext) + let sourceDeviceID = try VaultSyncJournal(context: source.mainContext).state().installationID + let sourceDevices = DeviceDirectoryModel( + repository: sourceDeviceRepository, currentDeviceID: sourceDeviceID, + currentDeviceName: "Source Mac") + let sourceModel = ConnectionHistoryModel( + repository: sourceHistoryRepository, sourceDeviceID: sourceDeviceID) + let hostID = UUID() + let connectedAt = Date(timeIntervalSince1970: 10) + let endedAt = Date(timeIntervalSince1970: 40) + let entry = try #require(sourceModel.record(kind: .ssh, for: hostID, at: connectedAt)) + sourceModel.end(entry.id, at: endedAt) + + let key = VaultRecordKey(kind: .connectionHistory, id: entry.id) + let deviceKey = VaultRecordKey(kind: .device, id: sourceModel.sourceDeviceID) + let sourceJournal = VaultSyncJournal(context: source.mainContext) + let envelope = try #require(try sourceJournal.envelope(for: key)) + let deviceEnvelope = try #require(try sourceJournal.envelope(for: deviceKey)) + _ = try HostPersistence(context: destination.mainContext).applyRemote( + [envelope, deviceEnvelope]) + + let received = try destination.mainContext.fetch( + FetchDescriptor() + ).map(\.value) + #expect(received.count == 1) + #expect(received.first?.sourceDeviceID == sourceModel.sourceDeviceID) + #expect(received.first?.connectedAt == connectedAt) + #expect(received.first?.endedAt == endedAt) + #expect( + try destination.mainContext.fetch( + FetchDescriptor() + ).map(\.value) == [ + DeviceRecord( + id: sourceModel.sourceDeviceID, displayName: "Source Mac") + ]) + + sourceDevices.updateCurrentDeviceName("Renamed Source Mac") + let renamedDeviceEnvelope = try #require(try sourceJournal.envelope(for: deviceKey)) + _ = try HostPersistence(context: destination.mainContext).applyRemote( + [renamedDeviceEnvelope]) + #expect( + try destination.mainContext.fetch( + FetchDescriptor() + ).map(\.displayName) == ["Renamed Source Mac"]) + + sourceModel.delete(entry.id) + let tombstone = try #require(try sourceJournal.envelope(for: key)) + #expect(tombstone.isDeleted) + _ = try HostPersistence(context: destination.mainContext).applyRemote([tombstone]) + #expect( + try destination.mainContext.fetchCount( + FetchDescriptor()) == 0) + } + + @Test + func existingConnectionHistoryBootstrapsAfterTheOriginalVaultBootstrap() throws { + let container = try makeContainer("ConnectionHistoryBootstrap") + let context = container.mainContext + let persistence = HostPersistence(context: context) + _ = try persistence.load() + let sourceDevice = DeviceRecord( + id: UUID(), displayName: "Source Phone") + let entry = ConnectionHistoryEntry( + hostID: UUID(), kind: .sftp, connectedAt: Date(timeIntervalSince1970: 5), + sourceDeviceID: sourceDevice.id) + context.insert(PersistentDeviceRecord(sourceDevice)) + context.insert(PersistentConnectionHistoryEntry(entry)) + try context.save() + + _ = try persistence.load() + + let key = VaultRecordKey(kind: .connectionHistory, id: entry.id) + let envelope = try #require(try VaultSyncJournal(context: context).envelope(for: key)) + #expect(envelope.payload == .connectionHistory(entry)) + let sourceDeviceEnvelope = try #require( + try VaultSyncJournal(context: context).envelope( + for: VaultRecordKey(kind: .device, id: sourceDevice.id))) + #expect(sourceDeviceEnvelope.payload == .device(sourceDevice)) + #expect( + try context.fetch(FetchDescriptor()) + .first(where: { $0.recordName == key.recordName })?.needsUpload == true) + } + @Test func importedKnownHostsSyncWithoutHostsAndPropagateEditsAndDeletion() throws { let source = try makeContainer("KnownHostsSource") diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 4545e715..920e831e 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ 10DD764492A94453449B3484 /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; 1D479CE3B3FF74B0C23BA3F2 /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; 20ADB83C9DB7F78EDF8BCBDF /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; + DEVK00000000000000000003 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DEVK00000000000000000002 /* DeviceKit */; }; + DEVK00000000000000000004 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DEVK00000000000000000002 /* DeviceKit */; }; 23E272F32F769A979F3ABF42 /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; 25C0658E2DD9B2FE28023DA7 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; 26C689ECB8B41A357765CCF1 /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; }; @@ -636,6 +638,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DEVK00000000000000000003 /* DeviceKit in Frameworks */, LOGG00000000000000000105 /* Logging in Frameworks */, BAAD00000000000000000011 /* GRDB in Frameworks */, AA1300000000000000000001 /* ListViewKit in Frameworks */, @@ -732,6 +735,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DEVK00000000000000000004 /* DeviceKit in Frameworks */, LOGG00000000000000000104 /* Logging in Frameworks */, BAAD00000000000000000010 /* GRDB in Frameworks */, 26C689ECB8B41A357765CCF1 /* TermindRemoteInfrastructure.framework in Frameworks */, @@ -879,6 +883,7 @@ ); name = TFI; packageProductDependencies = ( + DEVK00000000000000000002 /* DeviceKit */, LOGG00000000000000000002 /* Logging */, BAAD00000000000000000002 /* GRDB */, TRMD00000000000000100018 /* ListViewKit */, @@ -1189,6 +1194,7 @@ ); name = TFM; packageProductDependencies = ( + DEVK00000000000000000002 /* DeviceKit */, LOGG00000000000000000002 /* Logging */, BAAD00000000000000000002 /* GRDB */, TRMD0000000000000010001B /* AgentKit */, @@ -1320,6 +1326,7 @@ mainGroup = TRMD00000000000000000030; minimizedProjectReferenceProxies = 1; packageReferences = ( + DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */, BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */, LOGG00000000000000000001 /* XCRemoteSwiftPackageReference "swift-log" */, TRMD00000000000000100029 /* XCRemoteSwiftPackageReference "AgentKit" */, @@ -2762,6 +2769,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/devicekit/DeviceKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.8.0; + }; + }; BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/groue/GRDB.swift.git"; @@ -2845,6 +2860,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + DEVK00000000000000000002 /* DeviceKit */ = { + isa = XCSwiftPackageProductDependency; + package = DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */; + productName = DeviceKit; + }; BAAD00000000000000000002 /* GRDB */ = { isa = XCSwiftPackageProductDependency; package = BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */; diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ff19b10d..a258426e 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6d86b40bb4cf10a506717a0e4e3e9b8952872e88a046decb1109a13094d6e6c8", + "originHash" : "9e654d63a287d65c2696333fb86794057596d2796ccf325235594f6f4c28d6b8", "pins" : [ { "identity" : "agentkit", @@ -19,6 +19,15 @@ "version" : "0.2.0" } }, + { + "identity" : "devicekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devicekit/DeviceKit.git", + "state" : { + "revision" : "56b997e8a61707218f9af09f32b2a1d1806fd792", + "version" : "5.8.0" + } + }, { "identity" : "glyphixtextfx", "kind" : "remoteSourceControl", diff --git a/openwiki/.claims/architecture/desktop-ui.json b/openwiki/.claims/architecture/desktop-ui.json index 774d616e..e861012e 100644 --- a/openwiki/.claims/architecture/desktop-ui.json +++ b/openwiki/.claims/architecture/desktop-ui.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:bb94da4e49901db707f0db54a47d7303c3a320729b5d4ccdf12b7a2a87a38d4f", + "pageVersion": "sha256:e54867652d0a9b222655bb9e8dd43735a96cf9bf358c9a9f589e5c05b3b2a3b0", "claims": [ { "id": "claim_5a53c5a47b7445f7a7b6f8865297a805", @@ -145,10 +145,64 @@ "version": "repo-lines-v1:sha256:5b068452fbef1aa51055084952ffe5a65659654f7005cb478088ce4aec0273cb:eyJzZWxlY3RlZExpbmVDb3VudCI6MTM1LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiIwNTliNmRmNjgzMTA0ODE5MDZmMjgxZDIyMWIyMGQ3MDNmYjRkMWM2YzIwZjU0YjI4ODI4N2FhZWJhYWU5YzQ2IiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiI2NmI1ZjE4ODA5MTA2ZDU5MmUxMDc5OTcxODA5ZGZlMWFjMjQ5OGNhYzcwZjBiOWQ5ZTllZDUzOTQ0ZjQ3YzAzIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiJmNWJlYWMxN2RmMTlmMTQzYmQ5MzE5MWU0MWRiZmNhZjgzY2Q3ZmUyMjQ4YWIwMTQ0ZDg2MmRlMDkxNmRlMjBlIiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiJjNTAwNGFkN2FkNDNkZjUwNTZkNzI5ZTVlYTQyMzVmMmUyMTk1MjA5ZjM2OTQzZWY1YWMxMmIxNzE2NTI1OWMxIn0" } ] + }, + { + "id": "claim_53d787420d824b5f8c1cddda18563953", + "statement": "TFM Welcome projects only this installation's recent connection events and uses their local timestamps, while the History sidebar searches the synchronized all-device timeline and caps displayed matches at 100.", + "evidence": [ + { + "resource": "repo://TFM/App/AppModel.swift#L874-L891", + "version": "repo-lines-v1:sha256:21be749246950d4a070db63a1246e321b0b9cbde28bf1bd0c8201b0140ce26b4:eyJzZWxlY3RlZExpbmVDb3VudCI6MTgsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjEwZjViNDcwYmMzYjEwN2M5MjUwMTcyZGNjMTU3MjczNTUwZTE4YjcwMDYxNGRlYzkwNDFkYzIyZmZmNzE3OWEiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjBkZjRkYjc1MzQ1NWNlYmI5MjcxNjUzYzY2MGMxZjNiYmJlNDFjMzE0YWY5YWVhMDE3NzY4ODY4YWYyODQ3NDgiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjMwYjIxMmYzZGRiODMwZmUzMmM4YmJlNDE1NDRmN2Y4ODUyYTkzNGEwMzI0YmMzNDUzMGMwZDAyZGE5ZDg5NDcifQ" + }, + { + "resource": "repo://TFM/Features/Sidebar/WindowSidebarView.swift#L1194-L1222", + "version": "repo-lines-v1:sha256:298e850411c61f527715f0d5a72d05ea4b2c3c9979e0892826c3d5ba7e4e3869:eyJzZWxlY3RlZExpbmVDb3VudCI6MjksImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjRiZDViNWVhNTM2MmI3NTMwMmVlNDlkNDU0ZDk4NGQ3OTJjNWY2MzFkZjlkZDM2NDA4Yzg0OWQ2ZTM0Zjc4NmUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjE5ZTc2MzU1Y2YxMTk5NDgwYzNmOTk4MzZiMTQ4NmM4NDFkODkxZmFhZDkwNGM2MDhlMDkzMDIyZDM3NTdhZWUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6Ijg2OTE2ODZlNWJmYzdiNWQ4NTZiNzcwMTNmNjkzNWUwNGEyYTdhMmI1NDRmNDQ3MTFiYjQwNGExMDZlY2I4MzIifQ" + }, + { + "resource": "repo://TFM/Features/Workspaces/WorkspaceContentView.swift#L1255-L1317", + "version": "repo-lines-v1:sha256:4cb5889da5eebb5cb29d9c34bf15afc0f1b0054b50dc2181301c80329ed715e2:eyJzZWxlY3RlZExpbmVDb3VudCI6NjMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjE0YmYwNzJlZGMwNDE1NTAzYTRlNTc1ODg4NGUyODE5ODQ5NzdhM2RjZGM1YTljZDk4ZWQyMzUzNGNkNDA1ZTUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjE5ZTc2MzU1Y2YxMTk5NDgwYzNmOTk4MzZiMTQ4NmM4NDFkODkxZmFhZDkwNGM2MDhlMDkzMDIyZDM3NTdhZWUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjRmM2IxYmI3MjYyOTFlNjQ0MTZhM2JlZWU4NDQ0YTA5YTlhY2Y5MzM1YmYwNjIyOTg3NjhkNDNjNTAzNDFiNWUifQ" + } + ] + }, + { + "id": "claim_b47113d52cc54f959289cf3f1c272ada", + "statement": "Saved-host SSH, Telnet, and SFTP panes own connection-history events; closing ends the event, moving preserves it, and Serial or unsaved targets are excluded.", + "evidence": [ + { + "resource": "repo://TFM/App/AppModel.swift#L945-L953", + "version": "repo-lines-v1:sha256:fffac2073372d7b2eeb063bd1cc2e6559c6596ae31ceccc39b07d1f591d0ac73:eyJzZWxlY3RlZExpbmVDb3VudCI6OSwiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiMDM5MmMwY2IzYWM3ZjdjNzI0ODQ1ZjI5ZjU3YzVhNzYxZDdmZjJlMDcwZWJmNDRiMTRlYThlYjE4ZDg4YzQ0YiIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiNDg5M2Y0MGUzNzgxZWI4OTZjODJkMDQ2YTA4MDY3YTExZTM1Y2RmMmYxZGZhZjI2ZmVhOTBiOGRiNjY3NjQxMyIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiM2QyZGJkZDIzYWZkZGM4ZTRmNTQ3MzVhZDlmYTMzOGMyODNiMzkyMGVlNzc1ZTJlMjc2YTFiMDRjOWMxYmFmOCIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiNWFiNmI1M2RiNzY3ZWI0YzcwODgxZTNkZWIwMTNiNDgyZmEzMzY3Y2EzY2I2ZWIwYjgwMjUxZWFjODIwMzY2ZSJ9" + }, + { + "resource": "repo://TFM/Features/Workspaces/Pane.swift#L96-L217", + "version": "repo-lines-v1:sha256:9d7af678ba78037ce05e5a7bc18dd9cd229bd01662d9022bd07666edd171a70f:eyJzZWxlY3RlZExpbmVDb3VudCI6MTIyLCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiJiNGM1ZjVkYjkyYWQzNjYxNTE4YzlmNDAwZGYzNjk2NmM5OWUzN2MyYmUzMWE1NWIwY2M1Nzk2Yjk0OTg0MGUyIiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiJhMjQxODI1ZjE1N2M1NWU3ODFjYzlmZTlmMDQ5ZGNiYzRlZWNiMTVhMTM2ZjA3YzFiZWNjZWI0MDJhYmE5ZWFmIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiI3ZGRiYmE2ODgzZGU1MzUxNDViMGMxZDExODgwMGRjZDRiMmMwYzc5MzFjZjQ0MmE0NDAzNWI4OWQ5ZDczNDc4IiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiI2NDBlOWRjODY0MjI4YmY3NzIyOTA0NDljYTZiNzg4YzRlMDZmZTYwNDhiYzM4NDQ3ZTBiOWFlZWFhMTAxNDdmIn0" + }, + { + "resource": "repo://TFM/Features/Workspaces/WindowModel.swift#L1019-L1029", + "version": "repo-lines-v1:sha256:a7a3129aa3ad4026cb332d95d6014c9767d2d915c96baa95a47bf2ccd9cc0866:eyJzZWxlY3RlZExpbmVDb3VudCI6MTEsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6Ijc4MTk0NTE5ZWZhYzIzYzA2NWIzNTcxNGYzMTdmMTc4YmU5MTM4N2U2MTYwNjYwYTI3MmI0NzhmNWE3NTc1YzgiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjlhNGQ3Y2MwMDM0NDgyMDI3MzY3OTY4NDA5N2Q0OGVjMGZlNDIzOWRlZWRiZjVlNzg0NDYyOTVkMWI0ZTEyZjkiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImQ1ZmExNDU5MmY0NDQyODUwMzE1MjFjNWE2OGI2YThkNTE5N2VmYWIyMTUzOTY4NTcyY2Q5MzYwMmQzNmM1YTYiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjZjYzk1YzhiYTUxZjFiZDFkMDRkNzRkNmJiNGU4Yjg4NTNjMzZhYzkxYjYyYTdiNjY3OTZiOWRhYWQ0NmZlN2YifQ" + } + ] + }, + { + "id": "claim_5d2f06c8ba364d70a30fbb7c9d7fa6a3", + "statement": "TFM History rows resolve each event's sourceDeviceID through the reusable app-wide device directory, so the displayed device name follows directory updates rather than remaining a hostname captured inside the event.", + "evidence": [ + { + "resource": "repo://Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift#L26-L58", + "version": "repo-lines-v1:sha256:e339de41e16ec84556ec21c593095e36a5d87b9b95f124a5e7de3eda7472100d:eyJzZWxlY3RlZExpbmVDb3VudCI6MzMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImQ4ZmQ0ZmYyODEyNjE3MTNkMjgzZWY3NTE2NGQ5NjU1NmRjMWVjNGMwMzU0NTk3MjM4NGU2N2I0YmJiMzg1YTUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImMwY2MyNjc2ZjViM2QxZWY1NjIzNzQ3MThjMzEwYzYwY2Y5MTU2OTRhMmYzYzU2NDU4ZWQzY2FhYTc5NjRiOGUiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImY2MjRkNDljNzU1ZWZmNTgzOTNmMmM4YmU3Nzg2MjI4ZGRiYTRkM2ViMmUzOTcxNDExYTlkMzE5NjExYmRiNmUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImQxZTU0YWFlOWRkYjRmMmI0Yjg0OGVlZDRlNjhmY2RhZDY0Zjg5MTgxZDZiNGZlYTZhYjUxNTc1NzNkMTJiYTcifQ" + }, + { + "resource": "repo://TFM/Features/Sidebar/WindowSidebarView.swift#L1202-L1221", + "version": "repo-lines-v1:sha256:2d4ed81e1357045077c3628f9cbdef7d67d1abaaa7f098b92577b6fb45552571:eyJzZWxlY3RlZExpbmVDb3VudCI6MjAsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjE1MjFiZjdjNDZkMTJkOWVhZDU4YzY5ZjMzMWQ1Y2M4NmE4ZDdiOTBjMWM1NTllZThiNDBkNDk5MWRkMzUwMjEiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImFjODFhZTcyODBiYzIzZGNjMjM5M2YyZjQzNGNlNzA2MWJjNzMzZWY1OWE2NDhhODliM2I0YTdlZTc0NWRiMzgiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6Ijk2YjQ3YTY5ZTg0Mzk2ZTg1Y2JhOGRhNTM2MjBlMjE2YzNjY2JiODA2ZDdmNjc2MDkzMGFhMjc4MjBiNzhhYjUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjhiOWRiYzFmMDQ5YTIwNDhlYWE2NmU3OTVjNDkzODkzYjg2ZDNjZDI5NTEzM2E5ODQwMzRmNjM5MzI2ODMwNjUifQ" + }, + { + "resource": "repo://TFM/Features/Sidebar/WindowSidebarView.swift#L1300-L1316", + "version": "repo-lines-v1:sha256:d636624a511397bcc5ac3ec25d058259b4e3c1d8243a878b96340684fd6ab0d4:eyJzZWxlY3RlZExpbmVDb3VudCI6MTcsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImZiZjJmOTUzMTg2NDUzNDdiNjBmZWY5NDg5Y2E5MmEwNWJkNTcwYWNjYjc1ZWI4YTNkMTY2YzRhNDM3ODhmNzkiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjNlZTlmY2QxZjc0MGM5MzBmZDRmYzlkNzgyMjIyNWNiNDI0YTZhZTI3NzRhMDVmYTEyNDI2ZTcxNmMyOGIxMDIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjQ5NTg0Njc2ZWZjMGU1YjI4MWYyNzViYTgwNjYwMzY0ZTRlMTI1MjkzOGZjMGRiMjZiMzVhNjE4MDgzMjM4ZDciLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjcwZGM1ODY1MTVjMTY1YWViYmJiZjRlZjgzZjA4NDM1ZTU1OTFmYjY5NjQzYmU2NTgzZTdjZWZlM2JhZjk5ZjgifQ" + } + ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T03:23:14.035Z" + "at": "2026-09-22T09:17:11.491Z" } } diff --git a/openwiki/.claims/architecture/engineering-invariants.json b/openwiki/.claims/architecture/engineering-invariants.json index 26d35c0d..d83aaf54 100644 --- a/openwiki/.claims/architecture/engineering-invariants.json +++ b/openwiki/.claims/architecture/engineering-invariants.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:2650185619c739fcdeb85d549047e17a13b75073a4e6d6ec380afaa8e6f986c8", + "pageVersion": "sha256:abc66b27a943dab3394bc6d58bd9353a336a69a9e76afa6a3df35739c803e564", "claims": [ { "id": "claim_72f9329a158e418cbf0fd6394f82815e", @@ -22,7 +22,7 @@ "evidence": [ { "resource": "repo://Frameworks/TermindPersistence/TermindPersistence.swift", - "version": "repo-file-v1:sha256:f756ca3c13349717a9bc863d4520e74336321a245eb1f74471d43b8e9c7dd782" + "version": "repo-file-v1:sha256:9613dfe8aac0035ffcaca53b94efe4ae8d5d0fd2d86690608d2aaf4f7e553ca4" }, { "resource": "repo://TFI/App/TermindIOSApp.swift#L19-L33", @@ -44,19 +44,19 @@ "evidence": [ { "resource": "repo://TFI/App/MobileAppModel.swift", - "version": "repo-file-v1:sha256:f84584e85c49f80b312450980f1a741b62d7816892c3a0574edf5312e8fa4adc" + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" }, { "resource": "repo://TFI/App/MobileSceneModel.swift", - "version": "repo-file-v1:sha256:a0b746118649353c30e5030e6d1d85d4137ad91a45d245162beaa36c96166a18" + "version": "repo-file-v1:sha256:e795822b9fdd0257bfa13088cec2d09b38e8b6d3757bc4f322f7ab999560a229" }, { "resource": "repo://TFM/App/AppModel.swift", - "version": "repo-file-v1:sha256:2e763faebc42af2c9f4f7647f399b5ea9bdfb9489f160e9d7380380f5118162c" + "version": "repo-file-v1:sha256:32faf4e7aced6a7dcb64ac04369a8c95838ed6cdcb896da42bec8ca7ad562dea" }, { "resource": "repo://TFM/Features/Workspaces/WindowModel.swift", - "version": "repo-file-v1:sha256:99ad7526d51924fd3797629c0d56417704fcd49b6bc416e924c13d6283cf80d1" + "version": "repo-file-v1:sha256:005db3b60e96b760cf9e6ebed4cfdd0477f4c15db69205924901e47103c9949a" } ] }, @@ -154,7 +154,7 @@ "evidence": [ { "resource": "repo://TFI/App/MobileAppModel.swift", - "version": "repo-file-v1:sha256:f84584e85c49f80b312450980f1a741b62d7816892c3a0574edf5312e8fa4adc" + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" }, { "resource": "repo://TFI/Features/Workspaces/MobileWorkspace.swift", @@ -176,19 +176,15 @@ }, { "id": "claim_af851820a38b45619b056c87c69e7131", - "statement": "Local schema V31 additively introduces scoped quarantine and recovery models while preserving V1–V30 history and the existing CloudKit envelopes.", + "statement": "Local schema V31 additively introduced scoped quarantine and recovery models, while V32 replaces only the live Host and connection-history models, adds the generic device-directory model, removes obsolete Host usage storage, and backfills existing history with the stable installation ID plus a separate initial DeviceRecord without changing the V1 CloudKit envelope format.", "evidence": [ { - "resource": "repo://FrameworkTests/CloudSyncSafetyPersistenceTests.swift", - "version": "repo-file-v1:sha256:fe8c66a47936a9120eb3b71def8c148801e215d53fda47a326349388b30f9c1d" + "resource": "repo://FrameworkTests/PersistenceCompatibilityTests.swift", + "version": "repo-file-v1:sha256:0bc3fd0f5052bbc3ae57a157adf11e614d7a228aca3b7517b2996cdd52e3b7a0" }, { - "resource": "repo://Frameworks/TermindPersistence/CloudSyncSafetyStore.swift", - "version": "repo-file-v1:sha256:c028e57ece449c591c32f8ac849fda2f6a82cda00974acc87f832295bb3acc3d" - }, - { - "resource": "repo://Frameworks/TermindPersistence/HostPersistence.swift#L784-L791", - "version": "repo-lines-v1:sha256:8d79c417b0144fe8ea720dac157a20fb35b68045b6d5611819f811488698647c:eyJzZWxlY3RlZExpbmVDb3VudCI6OCwiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiN2FjYmQ4ZDg5YjViZTJkYWYyZDQ0ZWYwNDhkY2UxYmZmNjlmYzFlZGEyZjE3NmIzY2RjYzQzMjIwMDJmM2Q4MSIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiMmE2YWQyYmNlYjdkNWJhNzgyYjJmZWM4YjFlZTM3ZjUzOGE3MDNkOGVlZTMxMDc2ZjZiYTU1ZWI5ODQ4ZjIzYyIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiMTllNzYzNTVjZjExOTk0ODBjM2Y5OTgzNmIxNDg2Yzg0MWQ4OTFmYWFkOTA0YzYwOGUwOTMwMjJkMzc1N2FlZSIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiM2VmNDZiNWVlZDczMWY4OWQ1MDA5MmE1NzYwMGRiOTQxMzQ5OGZjMTBlMDZlOWQ3ZDIxZmUxOWIyNTZlZTM0MyJ9" + "resource": "repo://Frameworks/TermindPersistence/HostPersistence.swift#L770-L895", + "version": "repo-lines-v1:sha256:73dc81eb20101571d798c214cdd216be804ae8e8db29b7e8f191dc6eca8377df:eyJzZWxlY3RlZExpbmVDb3VudCI6MTI2LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiJmN2QzZTNlY2JlYzc4Y2U2NmVjMjQ5YWRmYzU1NWY4NzVlNGNlODMwYmIyMTg0MWJhZWQwYzA0NWY4ZGE1ODMyIiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiI0ODkzZjQwZTM3ODFlYjg5NmM4MmQwNDZhMDgwNjdhMTFlMzVjZGYyZjFkZmFmMjZmZWE5MGI4ZGI2Njc2NDEzIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiJkMWIwNTQ4OTc3Njg1MTE4N2EyMTEwODI0ODZkOGUyYjc1NWQxMTJkMjYxOGNjNjQ0NjE4NDk4ZjdhOTFkNzdjIiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiIwY2YzMTVkYmQ1MWNiMGU3NGUzZWYwNTQxMzk5NzZlNjAxNjJlZTVhZmMzYTlkZGE0MzI5NTdlMjgyMWRhMDQ5In0" } ] }, @@ -198,7 +194,7 @@ "evidence": [ { "resource": "repo://FrameworkTests/CloudSyncSafetyPersistenceTests.swift", - "version": "repo-file-v1:sha256:fe8c66a47936a9120eb3b71def8c148801e215d53fda47a326349388b30f9c1d" + "version": "repo-file-v1:sha256:46b7af81fc4ee722076d51522aca8b1c8dd5738c704da39d95034c734aaf851b" }, { "resource": "repo://FrameworkTests/CloudSyncSafetyTests.swift", @@ -221,10 +217,36 @@ "version": "repo-file-v1:sha256:f51520fa35e2250ee2b1d5bf50961c02549b02d4e9a6ee28a0cfc428f2722be4" } ] + }, + { + "id": "claim_e0132529187b45d393b5590f72043afe", + "statement": "Connection history synchronizes complete saved-host SSH/Telnet/SFTP events with stable source-device ID, start time and optional end time, while a separate reusable synchronized device directory maps installation IDs to mutable display names; stores deterministically retain 300 events and desktop/mobile all-device History surfaces present at most 100.", + "evidence": [ + { + "resource": "repo://Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift", + "version": "repo-file-v1:sha256:d9b5104bf66f06c45f5b038b7e24a14ce0f2bc09bcc5fa0bdc668d12dc7dc7a8" + }, + { + "resource": "repo://Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift", + "version": "repo-file-v1:sha256:368841be82a49dc8d6660dca31216bd0f319fdc76276a9bd1e22b6a8ea4a2cf8" + }, + { + "resource": "repo://Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift", + "version": "repo-file-v1:sha256:edcf5020f1965dcc1bad5d1b9dc1523cced21a62d1a93f3a1fd079dde25fb37f" + }, + { + "resource": "repo://Frameworks/TermindDomain/DeviceRecord.swift", + "version": "repo-file-v1:sha256:7bd4537277fda9c7e6e70318290ee3f17ce0271433ebff5c3ac0a28394f1d1d0" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift", + "version": "repo-file-v1:sha256:c727e97334d7ac3d7e5a820afe328a8c1809c08d73abd9c03e91287599d66fb0" + } + ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T05:22:55.650Z" + "at": "2026-09-22T09:51:39.958Z" } } diff --git a/openwiki/.claims/architecture/mobile-ui.json b/openwiki/.claims/architecture/mobile-ui.json index 16dd6a91..64d38153 100644 --- a/openwiki/.claims/architecture/mobile-ui.json +++ b/openwiki/.claims/architecture/mobile-ui.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:ff129fd4d82995b518da4a70a87e87f8f3bf920a0f4425d866ba0f36aa86ec79", + "pageVersion": "sha256:eb1d8cc2a67d82c77cf8a81e2ed3c32d502195a66d6e8c942daaab33dd0ee51c", "claims": [ { "id": "claim_fbb919f62a2d4ab88aafcc4c1186731b", @@ -22,7 +22,7 @@ "evidence": [ { "resource": "repo://TFI/App/MobileSceneModel.swift#L83-L128", - "version": "repo-lines-v1:sha256:ab6c66761d5743f76343b319a30dbdb113dffcf9b01a21a180e87ef109d63c60:eyJzZWxlY3RlZExpbmVDb3VudCI6NDYsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjEwOTA1MzRlNGFlMDdmZThiMGM5MTk4MWE3MGY1ZDFjMmM4ZWE1YzMzZmY5OWEzZGQ2YTBjMWQwNWM3NzBmNjAiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjI0OTBmZjExZjVjZTJiYzA2ZTFkODlhZTNjZmEyN2Y4NzhiMzZhNzE4Yjg0NjU4ZDFlNjI3OGExNjdmNjFiMmUiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjhjYzk0ZjdiZjEyMjBmOTFjNDQxODMzYjYxMGRjZGQ1MGM1N2ViNjc1YzNiYzY0YmY3Y2EyZThlYzkxNzBhYmEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjNmMDZjZDBkMzM2MDE5NDAwZDMzYmMwYTA3ZDBhMTE5ZTViZTAxZTFlYjhiY2M1MWM1Nzg4NTM1ZTQ5NjJmNjEifQ" + "version": "repo-lines-v1:sha256:3fd379cb52f15ff04e2e6b6aac563325062772680b18c9d641b60eea728707a4:eyJzZWxlY3RlZExpbmVDb3VudCI6NDgsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjEwOTA1MzRlNGFlMDdmZThiMGM5MTk4MWE3MGY1ZDFjMmM4ZWE1YzMzZmY5OWEzZGQ2YTBjMWQwNWM3NzBmNjAiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjI0OTBmZjExZjVjZTJiYzA2ZTFkODlhZTNjZmEyN2Y4NzhiMzZhNzE4Yjg0NjU4ZDFlNjI3OGExNjdmNjFiMmUiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjhjYzk0ZjdiZjEyMjBmOTFjNDQxODMzYjYxMGRjZGQ1MGM1N2ViNjc1YzNiYzY0YmY3Y2EyZThlYzkxNzBhYmEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjNmMDZjZDBkMzM2MDE5NDAwZDMzYmMwYTA3ZDBhMTE5ZTViZTAxZTFlYjhiY2M1MWM1Nzg4NTM1ZTQ5NjJmNjEifQ" }, { "resource": "repo://TFI/App/MobileSceneModel.swift#L9-L42", @@ -40,7 +40,7 @@ "evidence": [ { "resource": "repo://TFI/App/MobileAppModel.swift#L584-L600", - "version": "repo-lines-v1:sha256:4695fd57bfd1677ccfbaa7ef3e23c87ff6c636ab1d93fe889299f5b87723ace0:eyJzZWxlY3RlZExpbmVDb3VudCI6MTcsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjA4YjM0MjE2MjYzZjM3NTkzNDUyZGMyNDNlYTQyNTQ3MDJkZTEzYjI0OTliYTQ4ODYxODk2NTI4NGNmNDQzNmUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImQ1NWE2YmQ5MDhjMTBjOGM0MzYyZDBkNDAyZDI0Y2JkMDY2MDgyYjNjMjQ0N2YwZTA0MjZhYjEzY2M4NDc2M2MiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImY4ZDgxNTIzY2NhNWZlYmRiNWVhZDU5MDU2NmU5NjhlMTVkOTViZjRlODNlZjNkYjExNmJhOGM1NThhNDY0ZDQifQ" + "version": "repo-lines-v1:sha256:02916598d6ce980461cd4d2f19b3eb1cfeb5f71d7478cdac129e5cc0c88a9a59:eyJzZWxlY3RlZExpbmVDb3VudCI6MTYsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjA4YjM0MjE2MjYzZjM3NTkzNDUyZGMyNDNlYTQyNTQ3MDJkZTEzYjI0OTliYTQ4ODYxODk2NTI4NGNmNDQzNmUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImQ1NWE2YmQ5MDhjMTBjOGM0MzYyZDBkNDAyZDI0Y2JkMDY2MDgyYjNjMjQ0N2YwZTA0MjZhYjEzY2M4NDc2M2MiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImY4ZDgxNTIzY2NhNWZlYmRiNWVhZDU5MDU2NmU5NjhlMTVkOTViZjRlODNlZjNkYjExNmJhOGM1NThhNDY0ZDQifQ" }, { "resource": "repo://TFI/App/MobileSceneModel.swift#L142-L178", @@ -156,11 +156,11 @@ }, { "id": "claim_e7423fe4f094438aaacb38fb8f626adf", - "statement": "TFI aggregates scene phases before entering background handling; background expiry suspends workspaces, stops forwarding, pauses transfers and expires Assistant work, while an active scene resumes retained workspaces and appropriate services.", + "statement": "TFI aggregates scene phases before entering background handling; background expiry suspends workspaces, stops forwarding, pauses transfers and expires Assistant work, while an active scene refreshes the current device name, resumes retained workspaces and restarts appropriate services.", "evidence": [ { - "resource": "repo://TFI/App/MobileAppModel.swift#L491-L523", - "version": "repo-lines-v1:sha256:3e5f5ee2eaa48a81fe415e9bb3d77294bdb003e45542404a755bb1e569796e90:eyJzZWxlY3RlZExpbmVDb3VudCI6MzMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjExYjYwOWM3ODJiMmM2NDVhM2M2ZDFmOWMyMDA3YzdjMGVlMWI5MTI1YjJjOGIxOTU0YTFlMTVjY2UxM2VkYjMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjExMGVhYzUyYWE2NjM0MDczNjQ0MjU1MTg2YTc5NGIxMjdmYzQ5MTA2NTRmYTNhMjBiOTgzMjMxOTIxZWU2ZWEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjYwZTJhMWYwNzIwY2I4YWNhODQ4ZjIzNzM0N2E4YjUzNGY2YTEzZDVhZTBmMTQ3MDE4ODQ5ZmZjYjkxOTRlZWMifQ" + "resource": "repo://TFI/App/MobileAppModel.swift#L512-L544", + "version": "repo-lines-v1:sha256:b41d3de43fde9c50d0b478946d6263804791efe4519784713c872a1679a205dc:eyJzZWxlY3RlZExpbmVDb3VudCI6MzMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjExYjYwOWM3ODJiMmM2NDVhM2M2ZDFmOWMyMDA3YzdjMGVlMWI5MTI1YjJjOGIxOTU0YTFlMTVjY2UxM2VkYjMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImU2YjA0NDkxNDY5NzIyZTgzNjNhOTRlNDA0NDUzOGUwMTNhMmEyMTdlMzZiMWU1NjhiYTYwYWNiN2I0YzhmMmUiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjExMGVhYzUyYWE2NjM0MDczNjQ0MjU1MTg2YTc5NGIxMjdmYzQ5MTA2NTRmYTNhMjBiOTgzMjMxOTIxZWU2ZWEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImI0NTg0ZDFiMmZhZDA3YjA0ZTIwZTVjOGE2MTM3OGEzNDFjZGJlN2E4ZDgwMzAwZDQ2NzJlNjhhYjcxZTFjZjMifQ" }, { "resource": "repo://TFI/App/MobileRootView.swift#L49-L74", @@ -191,10 +191,56 @@ "version": "repo-lines-v1:sha256:6da6b4de4629b151fe2d915afe6eafcc3013b98cccccdccc1cfda9a9aa2a47ea:eyJzZWxlY3RlZExpbmVDb3VudCI6MjEsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImNiMTc4YjMyZGYyYWZhNjMxODU1ZThhZWViMDNhNTg1YzM0MWE1OGRjYWZkZWJkNWJmMDYxZmViNjhlMjgzYTkiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQxMmNhMzQ1Y2NmNzViZjljMDgwNmJjZTY5NWJlOGRlODA4Yjc5OTg0MjUxYTdhNTRkMjAyY2Y2MTAxZGQ0NTEiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjE5ZTc2MzU1Y2YxMTk5NDgwYzNmOTk4MzZiMTQ4NmM4NDFkODkxZmFhZDkwNGM2MDhlMDkzMDIyZDM3NTdhZWUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjE0M2NlZmIwZjcyMmRiNzRjN2E2YzI2OTgwMzU1ZjQyMjA0Yzc3NjJlNzY0ZjI4YTFhMzk4OGU5MmMzMjgxZWMifQ" } ] + }, + { + "id": "claim_691b837e633448ff9d3dd27ecf34c11e", + "statement": "TFI Workspaces Recents uses only local-device events and aggregates to five Hosts, Home Screen quick actions publish at most three host-only items, and the Vault History page presents at most 100 all-device events.", + "evidence": [ + { + "resource": "repo://TFI/Features/Vault/MobileVaultView.swift", + "version": "repo-file-v1:sha256:cd7a3b0fcbc217401dd3273c3a3463c4feb7f55a26f3985a0a15daefa1d178f4" + }, + { + "resource": "repo://TFI/Features/Workspaces/MobileWorkspaceListView.swift", + "version": "repo-file-v1:sha256:74dec02cd6c1e7fde02be7f4699c1d4b4a5ec88cf21823a1c80774ceec0ca107" + }, + { + "resource": "repo://TFI/Platform/MobileHomeScreenQuickActions.swift", + "version": "repo-file-v1:sha256:876e36822c6894079fa98043df42e98d7044a2b7658eacb3a4e049075d41cf88" + } + ] + }, + { + "id": "claim_f57a5ebe578c463da8af95776a484bed", + "statement": "Workspaces, Recent Workspaces and local Recents share a 36-point Host icon slot and aligned text geometry; live Workspaces alone overlay an eight-point status dot, and TFI exposes no Serial workspace/history path.", + "evidence": [ + { + "resource": "repo://TFI/Features/HostChooser/MobileConnectionPicker.swift", + "version": "repo-file-v1:sha256:de787b4445b1e2ef524dd0b380198a8b764c62442282844286bb42a12fb2c64b" + }, + { + "resource": "repo://TFI/Features/Workspaces/MobileWorkspaceListView.swift", + "version": "repo-file-v1:sha256:74dec02cd6c1e7fde02be7f4699c1d4b4a5ec88cf21823a1c80774ceec0ca107" + } + ] + }, + { + "id": "claim_cae8a14003284a1b83d88cbd0287d331", + "statement": "The TFI Workspace switch menu renders unselected entries with a Host distribution icon and connection-status dot, but replaces that composite with a checkmark and no dot for the selected Workspace.", + "evidence": [ + { + "resource": "repo://TFI/Features/Workspaces/MobileWorkspaceView.swift#L155-L163", + "version": "repo-lines-v1:sha256:98304409e0a75beb0ec408396b79953a757b498247fbc63b9bc0e63e2d278a74:eyJzZWxlY3RlZExpbmVDb3VudCI6OSwiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiZDU3ZjRiMGI3ZTBhODY1YWQ1Y2E0Y2YxN2RiODM1OTU1Y2NkZTllYTE3MzE3ODUxYTlkMjMzZDhmZTM4NTAwZSIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiZTZiMDQ0OTE0Njk3MjJlODM2M2E5NGU0MDQ0NTM4ZTAxM2EyYTIxN2UzNmIxZTU2OGJhNjBhY2I3YjRjOGYyZSIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiMmIxZGRiYTlkMjIwNDg0YTA0NGI5NTc4ODU1YjYxNTk4ZWFmNTg4MWM2OTI5NjllZDE5ZjFiMGNjMWQ2M2FmZSIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiNjE3YzhlNGNlNWYxYzcwNDAxNzJlZDZmMTc3NGU2OGVlMjczNzcwZWNlYjA1ZWQxZTJkZjQ1ZWZhNzZmZDM0YyJ9" + }, + { + "resource": "repo://TFI/Features/Workspaces/MobileWorkspaceView.swift#L701-L737", + "version": "repo-lines-v1:sha256:24637aa3319a2336a161a4b695c79b1167ba9384a33688405e8e5633d80d1781:eyJzZWxlY3RlZExpbmVDb3VudCI6MzcsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImYwMjA0MzJkYzQ3NGFmN2VmY2NlYmMwZTQ1ZDAzYzIxYmM3OTY0ZWMzZWY5NDE2Zjg5NmRlMDVlNmM0ODAzMzQiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQzOTgyY2E5ZDgxZDYzMWVlNTY4ODNhNjRlMmZkOTExNTUzNTE3MjUzODQwNThhYjIwNjVkMDQxMzI0YjQyZmYiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImZmMGQ3NTk1MGRmMGZmOWEzZWU5NjYzOWRjYzhmZjVlYWVkYjQwZDY4MmJlYTkyNjIyYjUyMGRkODg3ZDkzZTkiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImJiODlkNTljMjE3MGEwZWFmOWY0NDc2YmVjMjRjOThlYjdlYTRiMWNlYTY4ZDQ5MmQ2OGNkNzAzOGZkNDE0YmQifQ" + } + ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T03:23:14.035Z" + "at": "2026-09-22T09:51:39.958Z" } } diff --git a/openwiki/.claims/architecture/module-map.json b/openwiki/.claims/architecture/module-map.json index 1afd765f..f55de21c 100644 --- a/openwiki/.claims/architecture/module-map.json +++ b/openwiki/.claims/architecture/module-map.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:e707ee9af65f4140151b9354f42ab37041d2c10b718d0f4bc934c7a158ff0281", + "pageVersion": "sha256:e7d976e0a8d7e48d3895759656fa2444706f867074efa93eed6d990f409992a7", "claims": [ { "id": "claim_34230e88036241baad093fb8de5bac8e", @@ -8,7 +8,7 @@ "evidence": [ { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" } ] }, @@ -32,19 +32,19 @@ "evidence": [ { "resource": "repo://TFI/App/MobileAppModel.swift", - "version": "repo-file-v1:sha256:f84584e85c49f80b312450980f1a741b62d7816892c3a0574edf5312e8fa4adc" + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" }, { - "resource": "repo://TFI/App/MobileSceneModel.swift#L9-L48", - "version": "repo-lines-v1:sha256:c97b3079703d5b843ec181ca74ca90ffdaba21804a82899731ee8a8e6e6fee56:eyJzZWxlY3RlZExpbmVDb3VudCI6NDAsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjdlMzBiMWNmYzNlMDdhNWQzNDFjZTY5NjdjZDYzMTI4MWQzYzQwYmYzMWQzNTI1MTM2M2MyNzYxN2NiZmIwMzYiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImFiNTQ1YzBjNDIzZTliY2M2YjQ2MmYyZDE3MjM1MzA5NzkwNzI5ZDAyNzEwYjIzYWM0MjA1YWUxYjdkYzgzN2EiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImNkYTMxMmZlMTU1NzA0MzkzMzRiYzkwMGRiNDQyOTUzNjJmMzg5ZjVjNmVkOGU0MTViODIyYzBmN2YwNDJjNDgiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImZlMzM2NGIwNzc5MDQ0MmMxZjE2NTM1MjJlZDhiMzY1ODcxNDNiZTZlNzEwNDA2ZmZjOTI5ZDZhM2U5ODcyY2IifQ" + "resource": "repo://TFI/App/MobileSceneModel.swift", + "version": "repo-file-v1:sha256:e795822b9fdd0257bfa13088cec2d09b38e8b6d3757bc4f322f7ab999560a229" }, { - "resource": "repo://TFM/App/TermindApp.swift#L13-L159", - "version": "repo-lines-v1:sha256:4c3e634889e145d4c87210fbe24bbe3604039684abb44ccebb708343b5ac6cd2:eyJzZWxlY3RlZExpbmVDb3VudCI6MTQ3LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiI1Y2ZiZGJiNTAxNmFjN2RlZWE1NmU4OTBhZTkzYWQ3NzM2NWNiNGQ4YTBkODY4YjJlNmM5YmUzY2MxMWM0NjRkIiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiI5YTE1YWJkZGFiMDU4NmY2YmMxYjFhYzVmYzdiMGIyYTk3MzM1ZWNjOTYyNTg5ZDc3YThiMGJiMTNkYThmNGQzIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiI1YTJkYjY2ZjRhNTMyNTc1ZGM1MGZiYzkxY2Y0YmNhOGIxNWQ3ZThkMmJlOWY1Y2MxOGUwNjYyNjU5YzBhNWY4IiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiI0MmMzODZhMWQ3MWExZDc1Nzk2NzM1MWJiMTdiNTU2MWVkMjNkODYwYzc0NjUxODVkZTBmNjliN2ZkZjFlYTViIn0" + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" }, { - "resource": "repo://TFM/Features/Workspaces/WindowModel.swift#L15-L106", - "version": "repo-lines-v1:sha256:6531d20ce697bdbcd8d29c4d93f1ced4851f4d6bc48e5e9f9d8f0609eaab3034:eyJzZWxlY3RlZExpbmVDb3VudCI6OTIsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQxMmNhMzQ1Y2NmNzViZjljMDgwNmJjZTY5NWJlOGRlODA4Yjc5OTg0MjUxYTdhNTRkMjAyY2Y2MTAxZGQ0NTEiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjgwOTJkZGExNzNhMjQyOTg1N2E4ZTE0NjQzODVlOTU3MDFjZTM5MDU5ZDFhYzhjNGIwYjMzY2RhMmE1ZWEyMDQiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjkyYzE3ZTc4YTkyNDNkYmFhZWY3ZDczZDdlMTZjYTc5YmFlMjNlOTlkNDE4ZjE2NTAwZTgwZjYyNmNmMzMzYzEifQ" + "resource": "repo://TFM/Features/Workspaces/WindowModel.swift", + "version": "repo-file-v1:sha256:005db3b60e96b760cf9e6ebed4cfdd0477f4c15db69205924901e47103c9949a" } ] }, @@ -62,7 +62,7 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" }, { "resource": "repo://TermindMCPBridge/main.swift#L4-L87", @@ -92,7 +92,7 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" } ] }, @@ -117,10 +117,40 @@ "version": "repo-lines-v1:sha256:e40e7d35b50b70b7b5c4f18fb5197a6ef3ce99dc496bcd4f962b9267320b4431:eyJzZWxlY3RlZExpbmVDb3VudCI6NTMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImNhNjJhYTkyN2M1ZmY1YzlmNjk0ZDJlNGJjZWFmODVmNTg3MDUyMjkxODYzNjJkNWU1MzFjZDRmY2JlZDkwNGIiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjAzZjljZGY2MTc5ZWZmZjc5OThlZWJkNDhmNzRlYTJhYmU2OTA2YjM5MmFiMzljYmMyMjVhOWRhMmFkMzVkZmIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjZmYTUyMmViMzUwNWYwNjhiNTJkZjYxNTVmZmE5OGMzZDA0YzE1MzhjMWUxZWNiNmUyMzIzMDZhMjZiY2FiNTkiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjZiZTdlYjA0YjEyNzE1YzZjYzlmNDFjNzk1M2ZhMzRmNjM1NjM0ZGJmZWE0OTU1ZWEwN2I5ZjM4NTVlNjRkYWYifQ" } ] + }, + { + "id": "claim_5015b59262b040c49bab3e5fe71d8a7f", + "statement": "The reusable device directory follows the normal shared-module boundary: TermindDomain owns DeviceRecord, TermindApplication owns the repository port and observable model, Persistence owns the SwiftData implementation, and each app supplies platform-specific display-name acquisition.", + "evidence": [ + { + "resource": "repo://Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift", + "version": "repo-file-v1:sha256:368841be82a49dc8d6660dca31216bd0f319fdc76276a9bd1e22b6a8ea4a2cf8" + }, + { + "resource": "repo://Frameworks/TermindApplication/Repositories/DeviceDirectoryRepository.swift", + "version": "repo-file-v1:sha256:0a1a1ff68cedf0d380d197304d6088d35aafbe510723b8508c94b33583eadde3" + }, + { + "resource": "repo://Frameworks/TermindDomain/DeviceRecord.swift", + "version": "repo-file-v1:sha256:7bd4537277fda9c7e6e70318290ee3f17ce0271433ebff5c3ac0a28394f1d1d0" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift", + "version": "repo-file-v1:sha256:9a3e7296fd09c4b397c90c7b15c20775a0950a145ec1c506ac431c04fc5ea020" + }, + { + "resource": "repo://TFI/App/MobileAppModel.swift", + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" + }, + { + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" + } + ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T05:22:55.650Z" + "at": "2026-09-22T10:02:02.985Z" } } diff --git a/openwiki/.claims/architecture/native-app-directories.json b/openwiki/.claims/architecture/native-app-directories.json index 242a1b98..d4104fbd 100644 --- a/openwiki/.claims/architecture/native-app-directories.json +++ b/openwiki/.claims/architecture/native-app-directories.json @@ -1,26 +1,26 @@ { "schemaVersion": 1, - "pageVersion": "sha256:b80a3e4c39b80b3b1b701983db69b57bc48fa869ab0b4e6b6955c7150766f692", + "pageVersion": "sha256:766867bc49c356007fb41d79eb8444693a2cd016fed01a7a1631d4a59ab13867", "claims": [ { "id": "claim_8e9cf2adb50847f98fcd4516cb0ed521", "statement": "TFM App assembles app-wide services and per-window composition; WindowModel owns workspace and UI state, while TFI uses app-owned workspace runtimes and scene-local navigation and presentation.", "evidence": [ { - "resource": "repo://TFI/App/MobileSceneModel.swift#L9-L48", - "version": "repo-lines-v1:sha256:c97b3079703d5b843ec181ca74ca90ffdaba21804a82899731ee8a8e6e6fee56:eyJzZWxlY3RlZExpbmVDb3VudCI6NDAsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjdlMzBiMWNmYzNlMDdhNWQzNDFjZTY5NjdjZDYzMTI4MWQzYzQwYmYzMWQzNTI1MTM2M2MyNzYxN2NiZmIwMzYiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImFiNTQ1YzBjNDIzZTliY2M2YjQ2MmYyZDE3MjM1MzA5NzkwNzI5ZDAyNzEwYjIzYWM0MjA1YWUxYjdkYzgzN2EiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImNkYTMxMmZlMTU1NzA0MzkzMzRiYzkwMGRiNDQyOTUzNjJmMzg5ZjVjNmVkOGU0MTViODIyYzBmN2YwNDJjNDgiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImZlMzM2NGIwNzc5MDQ0MmMxZjE2NTM1MjJlZDhiMzY1ODcxNDNiZTZlNzEwNDA2ZmZjOTI5ZDZhM2U5ODcyY2IifQ" + "resource": "repo://TFI/App/MobileSceneModel.swift", + "version": "repo-file-v1:sha256:e795822b9fdd0257bfa13088cec2d09b38e8b6d3757bc4f322f7ab999560a229" }, { - "resource": "repo://TFI/Features/Workspaces/MobileWorkspace.swift#L48-L150", - "version": "repo-lines-v1:sha256:16fc9803a6aac8d551214f3531c532b699e4560a0f7fd9a4b8eeee7d1d463168:eyJzZWxlY3RlZExpbmVDb3VudCI6MTAzLCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiI2OTZhYmUwMDhjNjk0NGIwYTMyYmU3MmQzNmNmZjIzZDA3NDJhNDAxMDAyM2NiODZhYTY2MDYwMTBhZjFjNzZkIiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiI5MGIxYjkzYTJlYTViNmUxZjBlOTFkNDAxMTI4NjRkMjVhMjZjNGRlMzAzZmY4MDk5YzVmOGI5MzY4NDg4N2Y2IiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiI2YzRmNjFkNGFhMzc5MTE0OGE1MjRmZjcwNmFhOWE4NjI0MDA2ZDRjYTEyM2VjNzVlOGI0Mzk0ZWJmZGI3YTllIiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiIyZWI5MTIzYmRmYTY5N2RhODdlMWUzYzRlY2Q3OGEzMjQ2ODA3MzM3MjVhZjQyMDMwYWExOWZkMWJkMzhmNDIyIn0" + "resource": "repo://TFI/Features/Workspaces/MobileWorkspace.swift", + "version": "repo-file-v1:sha256:c09fe88df3cf6750e10e7fdbfde73b89bf17bc1acc94ad0e7f3f169f4934f293" }, { - "resource": "repo://TFM/App/TermindApp.swift#L13-L159", - "version": "repo-lines-v1:sha256:4c3e634889e145d4c87210fbe24bbe3604039684abb44ccebb708343b5ac6cd2:eyJzZWxlY3RlZExpbmVDb3VudCI6MTQ3LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiI1Y2ZiZGJiNTAxNmFjN2RlZWE1NmU4OTBhZTkzYWQ3NzM2NWNiNGQ4YTBkODY4YjJlNmM5YmUzY2MxMWM0NjRkIiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiI5YTE1YWJkZGFiMDU4NmY2YmMxYjFhYzVmYzdiMGIyYTk3MzM1ZWNjOTYyNTg5ZDc3YThiMGJiMTNkYThmNGQzIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiI1YTJkYjY2ZjRhNTMyNTc1ZGM1MGZiYzkxY2Y0YmNhOGIxNWQ3ZThkMmJlOWY1Y2MxOGUwNjYyNjU5YzBhNWY4IiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiI0MmMzODZhMWQ3MWExZDc1Nzk2NzM1MWJiMTdiNTU2MWVkMjNkODYwYzc0NjUxODVkZTBmNjliN2ZkZjFlYTViIn0" + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" }, { - "resource": "repo://TFM/Features/Workspaces/WindowModel.swift#L15-L106", - "version": "repo-lines-v1:sha256:6531d20ce697bdbcd8d29c4d93f1ced4851f4d6bc48e5e9f9d8f0609eaab3034:eyJzZWxlY3RlZExpbmVDb3VudCI6OTIsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQxMmNhMzQ1Y2NmNzViZjljMDgwNmJjZTY5NWJlOGRlODA4Yjc5OTg0MjUxYTdhNTRkMjAyY2Y2MTAxZGQ0NTEiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjgwOTJkZGExNzNhMjQyOTg1N2E4ZTE0NjQzODVlOTU3MDFjZTM5MDU5ZDFhYzhjNGIwYjMzY2RhMmE1ZWEyMDQiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjkyYzE3ZTc4YTkyNDNkYmFhZWY3ZDczZDdlMTZjYTc5YmFlMjNlOTlkNDE4ZjE2NTAwZTgwZjYyNmNmMzMzYzEifQ" + "resource": "repo://TFM/Features/Workspaces/WindowModel.swift", + "version": "repo-file-v1:sha256:005db3b60e96b760cf9e6ebed4cfdd0477f4c15db69205924901e47103c9949a" } ] }, @@ -144,11 +144,11 @@ }, { "id": "claim_93d1823292234666a2e4ce42906b2126", - "statement": "Vault composes specialized native resource-management surfaces, while Settings assembles multiple independently owned settings capabilities and SFTP browser presentation is separate from process-level transfer coordination.", + "statement": "Vault composes specialized native resource-management surfaces, including mobile connection History, while Settings assembles independently owned settings capabilities and SFTP browser presentation remains separate from process-level transfer coordination.", "evidence": [ { - "resource": "repo://Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift#L78-L130", - "version": "repo-lines-v1:sha256:e40e7d35b50b70b7b5c4f18fb5197a6ef3ce99dc496bcd4f962b9267320b4431:eyJzZWxlY3RlZExpbmVDb3VudCI6NTMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImNhNjJhYTkyN2M1ZmY1YzlmNjk0ZDJlNGJjZWFmODVmNTg3MDUyMjkxODYzNjJkNWU1MzFjZDRmY2JlZDkwNGIiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjAzZjljZGY2MTc5ZWZmZjc5OThlZWJkNDhmNzRlYTJhYmU2OTA2YjM5MmFiMzljYmMyMjVhOWRhMmFkMzVkZmIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjZmYTUyMmViMzUwNWYwNjhiNTJkZjYxNTVmZmE5OGMzZDA0YzE1MzhjMWUxZWNiNmUyMzIzMDZhMjZiY2FiNTkiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjZiZTdlYjA0YjEyNzE1YzZjYzlmNDFjNzk1M2ZhMzRmNjM1NjM0ZGJmZWE0OTU1ZWEwN2I5ZjM4NTVlNjRkYWYifQ" + "resource": "repo://Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift", + "version": "repo-file-v1:sha256:632c184da2c9be4d572f5d3d2a6b3a8c8357be0f8c8a5e0d7a78b70323ec9c96" }, { "resource": "repo://TFI/Application/Transfers/MobileSFTPChannelModel.swift", @@ -160,7 +160,7 @@ }, { "resource": "repo://TFI/Features/Vault/MobileVaultView.swift", - "version": "repo-file-v1:sha256:dac393fd064055573a5cd6e8334601c2cd92c14b889cc30b8951c31f4220ea30" + "version": "repo-file-v1:sha256:cd7a3b0fcbc217401dd3273c3a3463c4feb7f55a26f3985a0a15daefa1d178f4" }, { "resource": "repo://TFM/Features/SFTP/SFTPChannelModel.swift", @@ -206,13 +206,27 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" + } + ] + }, + { + "id": "claim_7f2d91654f904de190065beceb9ff84c", + "statement": "Native app composition adapts platform device naming into the shared device directory: both TFM and TFI read DeviceKit's Device.current.safeDescription; TFM refreshes it on app activation and TFI on active scene transitions.", + "evidence": [ + { + "resource": "repo://TFI/App/MobileAppModel.swift", + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" + }, + { + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" } ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T03:45:03.442Z" + "at": "2026-09-22T10:02:02.985Z" } } diff --git a/openwiki/.claims/architecture/platform-runtime.json b/openwiki/.claims/architecture/platform-runtime.json index 9b023402..5a529816 100644 --- a/openwiki/.claims/architecture/platform-runtime.json +++ b/openwiki/.claims/architecture/platform-runtime.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:8fbc2ace1837fd30267a65e459fd76473f9ea6b2d3ed561f824fc1d288372ea8", + "pageVersion": "sha256:89221b450455e43209b8cf3fd10ebdd9baf9642dbe7d84c8fdbce1816c91c714", "claims": [ { "id": "claim_bd9f5ddda6b6466eb79c622b02fd73f2", @@ -20,47 +20,35 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" } ] }, { "id": "claim_67bc0bd4217544de9b7ddb97de66989f", - "statement": "On macOS, `TermindApp` is the composition root: it creates one SwiftData container, prepares persistence and Keychain-backed services, constructs sync/outbox/repository dependencies, and retains one `AppModel` in SwiftUI state; failure to open the local container terminates startup rather than substituting an empty store.", + "statement": "On macOS, TermindApp is the composition root: it creates one SwiftData container, prepares persistence and Keychain-backed services, constructs sync/outbox/repository dependencies, and retains one AppModel in SwiftUI state; failure to open the local container terminates startup rather than substituting an empty store.", "evidence": [ { - "resource": "repo://TFM/App/TermindApp.swift#L17-L20", - "version": "repo-lines-v1:sha256:58dfb9d543fbe26e0e9685791c843a88d19dea527b3e1bfad82837d66b1a12e2:eyJzZWxlY3RlZExpbmVDb3VudCI6NCwiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiYzY3MDc0OTM5MmVmYmVhMWIzMjMwYTdhZDBhNTM5MDMxNjgyNzZiNWExMDY0OGFjMjFhZTU1YmNiMGJiNzQ0OCIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiMDFiYTQ3MTljODBiNmZlOTExYjA5MWE3YzA1MTI0YjY0ZWVlY2U5NjRlMDljMDU4ZWY4Zjk4MDVkYWNhNTQ2YiIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiMmFhZTQ5YmFlNzNkMzFhMzk2Y2IzNTg5MjU4MjVlMzkxNGFkNTRkNmMwZmI1ZTQzNWYyYTcyODRkZWYyYmUxYSIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiYjAzMTkyYWFlOGU2ZGIzZjk3M2MyMjYyODQyNDdjOWI2ODgxYWMxNTEyOGUzOGQ3N2JjNmMyNDQ3Mjg4NWZlZCJ9" - }, - { - "resource": "repo://TFM/App/TermindApp.swift#L30-L138", - "version": "repo-lines-v1:sha256:da735745bc8b4d982752f5614f36d0bef93a6fe352c2695c43b2c93434ebf779:eyJzZWxlY3RlZExpbmVDb3VudCI6MTA5LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiIxYzI1OGI3YjRmNWI0YWY4ODYxOGQyODRiZDg4NWNlZmMzYjJkMTlkYTE5OTk5ZjdhMTQyNGJkMTA5YjIzY2YzIiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiJhY2U2ZTRmM2EzYmY1YjY4ZmQ0OTNlMjkwNGRlNTNiMmNmYTMyNDMxMDYyMDdhODAxYjhmZGU3NDNkZmFiM2U3IiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiI0YTM2NTM4ODFmODkyM2RhMmFiZjNiYWQzZjJiYWIzYmRiOGEzNjY5NWI2OTJmOWI5ZjZhMTFiNzVkODI5MGYwIiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiI5OTliZGVmZWVhY2E2MzQwZTAyOTBkYzg2ZTg4OWM4OWQ1ZTE4OWFhNWJhNDRiNTg1YjNiNzA3MzRmNGE2MmQ4In0" + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" } ] }, { "id": "claim_3740255f82db44d5960768217e4669c0", - "statement": "`AppModel` is app-wide desktop coordination state, while `WindowModel` owns state that may differ per window—including workspaces, selection, sidebar and search state—and registered windows receive shared services without transferring workspace ownership to the app model.", + "statement": "AppModel is app-wide desktop coordination state, while WindowModel owns state that may differ per window—including workspaces, selection, sidebar and search state—and registered windows receive shared services without transferring workspace ownership to the app model.", "evidence": [ { - "resource": "repo://TFM/App/AppModel.swift#L36-L48", - "version": "repo-lines-v1:sha256:1f1a4c4ed6efe1ff110509ba5d5234e8c6ed2daf61023f3a1742f08e4c539da3:eyJzZWxlY3RlZExpbmVDb3VudCI6MTMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImQzYjAwMzIyNmFiNjY2MjhjYzliNDFmOTQ0ZGIwNDUwNTBmNDIyOTA2ZjJjNmM1ZDU4NzEyYjU2YjQxZjQ3MGYiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjVmMzkyNGUxNGRjMTM4NjZlODJlNGQ4ODk2ZGM3MDg4Yjc4MmI4OTIxZTM0OTY4M2U5OTE5NGI5NGRiYzA5ZjEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjAzZGRmZjMwMjE5YjY1ZGI1MTg3NmJkYjJkYjIwMzJhMWU3MWNjMDM0Nzc4YzhhNjRjMGI5NTViMzcyZGM5M2QifQ" + "resource": "repo://TFM/App/AppModel.swift", + "version": "repo-file-v1:sha256:32faf4e7aced6a7dcb64ac04369a8c95838ed6cdcb896da42bec8ca7ad562dea" }, { - "resource": "repo://TFM/App/AppModel.swift#L435-L483", - "version": "repo-lines-v1:sha256:66425b677043ad80e7539f1934c7f80b1ec165f96cfc5d7a3445f426cef38715:eyJzZWxlY3RlZExpbmVDb3VudCI6NDksImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImZiMDNmZDkyNGM2ZDY3NmQ2Y2Y4YzA1MGUxZWFjZDI4ZDMwNTE4NzU3ODIwMTViNWNiMTAxYzY3MTI2MzU1Y2QiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImI1NmZjODJmYjZlYmU4YjJhNzY1ZjU0OTZkMjUzMTIzNDIwMTM2MDBlZjE0MTRiNWMwZDQ2OGYyZDFkYmE1MjUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImU1YTFlYzk2OGVhY2JmYWU2Y2VjYmU3MGFjOGFhNjYzZjExZWFjZjY5OTE3NTdlZDM4MDk3YWM1ZWU0MTAzOGYifQ" + "resource": "repo://TFM/App/RootView.swift", + "version": "repo-file-v1:sha256:6807c19b63ac86381892424de1971677ef4bc6ec42515fe57fee184be3951767" }, { - "resource": "repo://TFM/App/AppModel.swift#L50-L116", - "version": "repo-lines-v1:sha256:f42357f9bf35b10d60fe8daaef9077c5f6da17ad900b9b0bb661e17cfa5eb1ed:eyJzZWxlY3RlZExpbmVDb3VudCI6NjcsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjA3NDZjZjIyOTJiYzNmYzYwNWU0MDk1ZmYwOTQwMzNhNzE3NTU1NmFlMWQ4YzU4NmNiMzZjNDEzZTM1NDZiZTIiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImI4NjY3ZDE1NjU4NGZjZGYxNTY0NjJmMDY3OThmMjYzZDc1NTE2Yzg1ZWVkYTc3MmFhYzZjMjkyMzkwYzAxNDMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImYyOGM4NTE1Nzc4MjNmMWQ2Y2E4NmEyM2RlNDI2YmQ4ZDIwZjZiM2M2YmVhMDIzMDZlMzNiMDNlZWU4M2ExNTgiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImI3YzVkNTgzZDAzNDQ5OGMyMWE2MmNiMjE0MmVhMGNmNzc3YTU3YmI4NjU3NDIwYjc1NTRiZGI0NTcxMWEzY2MifQ" - }, - { - "resource": "repo://TFM/App/RootView.swift#L188-L203", - "version": "repo-lines-v1:sha256:285d5010d326e778a3894b5a491496643929c899279182a6295339008e89ff44:eyJzZWxlY3RlZExpbmVDb3VudCI6MTYsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjRiZWEyYWFlNzM1MmYwOWMyZTg5NGE1ODRkNWY5YmMzNzkyOWJmZjIyNjk5MzIxYjMyMGY0MTMwM2E1YmNlY2EiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjAwMDljNzJjMDIzYWFiMWYxZTU0NmQ0OGJjZjM5OTE2MWQzODFhYTQ0NmYyZTBjMDkzYTE4NDVkYzQxODhmOWUiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjJiNTI4MTA3MjY3NzU5YWYzYmQyYzJjNDNkODg5OWQwYTJmNWVkZWU1NjE1NjVjZTQ3NjQzMjZhNTczOWVjODUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjBhYmY5NjgwOTZmOGRiMTM1Mjc5MDA1MzAxNWQ2ZmI4MWRmM2EyMzExZjVmNDg0ZWI3M2MzNzAwNDYwZjg4ZWEifQ" - }, - { - "resource": "repo://TFM/App/RootView.swift#L6-L12", - "version": "repo-lines-v1:sha256:385e37c29ac3360b6d6c13770600b7f05f500dbf4aae2166554bb47959e8cfd9:eyJzZWxlY3RlZExpbmVDb3VudCI6NywiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiMjg2OWM0NjBiYjViODM0YzhmNzhjOGMyMTMyMDI1OGJlZmMwZWZkYTMxNmZlMTYzNjQ4NjU3M2FkOTIwYmY1OSIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiMjRhNTQ3NGNhNDJkZjJiNTlhZDgzNGVlMTNiMWI1NzNhOWE3OTE5YzU4ODU0NmM5ODgxYjk3NzkxYzBlOWI4MSIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiNTcxZGE3NDM0NTdjMDNhMjQwNDRhMTk3NTQ2MGE2YzQ4OTllODBiNzUyOGUzMDRkMzE0ZDY3ZmU1NWE0MTYwNiIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiNjRlNjdhZDA1MjQ5NGIzMjM1OWIxZTVhNWY1NTIyZDk1ZDViYzJmMjY5NDg5ZmYzODY2MzJhZjUwYjI5MGZlZSJ9" + "resource": "repo://TFM/Features/Workspaces/WindowModel.swift", + "version": "repo-file-v1:sha256:005db3b60e96b760cf9e6ebed4cfdd0477f4c15db69205924901e47103c9949a" } ] }, @@ -124,15 +112,11 @@ }, { "id": "claim_bd6009649be84c4291e149d30476e3a3", - "statement": "`MobileAppModel` owns the mobile app-wide container, libraries, sync, assistant, metrics, transfer, forwarding, workspace registry and descriptor store; production persists `Termind.store` under Application Support while in-memory/test modes use an in-memory container and omit production sync.", + "statement": "MobileAppModel owns the mobile app-wide container, libraries, sync, assistant, metrics, transfer, forwarding, workspace registry, descriptor store, and synchronized connection-history model; production persists Termind.store under Application Support while in-memory/test modes omit production sync.", "evidence": [ { - "resource": "repo://TFI/App/MobileAppModel.swift#L104-L220", - "version": "repo-lines-v1:sha256:0ce45f41f79ef0529cdaa4bd371d5d6378921ead85d0b6994e50f54e35f2011b:eyJzZWxlY3RlZExpbmVDb3VudCI6MTE3LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiJhZTFlMGE4ZDBhODljNDRhNDI0MjM4Yjk0ODVjOWJmZGVlMjBkNzNjNTgxYmExMzljMTgzYjExMGM1ODdhY2I0IiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiJmY2I3MmJhNGQ5NjQwZDkyNjNiZTM3MGViYjcyZTM1ZWQwODBmNmUzNTY1NTIwMThkYWViYjJhZmZiN2FmNzVkIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiIzYmI1MzE1NmEzZGIwOTA3ZmNiODQxMDYyYzY3OTY3YjMzMGNiOTU5NWY1YjVhMzNiOGVlZjU2Y2U1ZTM3M2YzIiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiI0ODdhZTQyM2M0ZDA5N2YwYmQ1OGQ1ZDIzMDAyOTg0YmUzMzA4YTM0MTAxMTI3ODFhZmEwN2FmZGEyMTY5NWRhIn0" - }, - { - "resource": "repo://TFI/App/MobileAppModel.swift#L35-L84", - "version": "repo-lines-v1:sha256:9b7bf812883d719506d6d5770e57352438a10f5b18c25e4096ffe4fedf33a8b6:eyJzZWxlY3RlZExpbmVDb3VudCI6NTAsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ0NzUxNDBlMjZlOWM1YTE3MTFkNjQ0NWExOGI3ZjRhZTIwMjJmOTkzZWY2Mjg0MDc1NTQzMDFiOTZlMGRjMjAiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImJmZjliNTBiNjc3MTVhYTVmNjlmZjNkN2VmZjcxZTU4MjNkOWI2ZmE5ZWE1YzE2OGJlMGJhNDdkODViYTg3YmIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjE5ZTc2MzU1Y2YxMTk5NDgwYzNmOTk4MzZiMTQ4NmM4NDFkODkxZmFhZDkwNGM2MDhlMDkzMDIyZDM3NTdhZWUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjI3NjQzMTk0ZmY2ZTJiNTNhMjZmZGI3ZmUxN2VlMWNjM2I1NWY3MjMxOTAzOGE3MWRjMjQ5ZTdlNjNjODJhZDAifQ" + "resource": "repo://TFI/App/MobileAppModel.swift", + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" } ] }, @@ -156,23 +140,19 @@ }, { "id": "claim_449cc2faba114c37bc4e51a189667299", - "statement": "Mobile live workspaces are app-wide: `openWorkspace` reuses an existing instance or publishes a newly constructed workspace before connecting, allowing multiple scenes to select one live workspace without duplicate initial connections.", + "statement": "Mobile live workspaces are app-wide: openWorkspace reuses an existing instance or publishes a newly constructed workspace before connecting, allowing multiple scenes to select one live workspace without duplicate initial connections.", "evidence": [ { - "resource": "repo://TFI/App/MobileAppModel.swift#L584-L600", - "version": "repo-lines-v1:sha256:4695fd57bfd1677ccfbaa7ef3e23c87ff6c636ab1d93fe889299f5b87723ace0:eyJzZWxlY3RlZExpbmVDb3VudCI6MTcsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjA4YjM0MjE2MjYzZjM3NTkzNDUyZGMyNDNlYTQyNTQ3MDJkZTEzYjI0OTliYTQ4ODYxODk2NTI4NGNmNDQzNmUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImQ1NWE2YmQ5MDhjMTBjOGM0MzYyZDBkNDAyZDI0Y2JkMDY2MDgyYjNjMjQ0N2YwZTA0MjZhYjEzY2M4NDc2M2MiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImY4ZDgxNTIzY2NhNWZlYmRiNWVhZDU5MDU2NmU5NjhlMTVkOTViZjRlODNlZjNkYjExNmJhOGM1NThhNDY0ZDQifQ" + "resource": "repo://TFI/App/MobileAppModel.swift", + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" }, { - "resource": "repo://TFI/App/MobileAppModel.swift#L677-L701", - "version": "repo-lines-v1:sha256:25da6d1276fcd4f2d70d3d960f0f81aec17035b0f078a20b775db3579cc06321:eyJzZWxlY3RlZExpbmVDb3VudCI6MjUsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImUxODI4YWZmNmM2MDJhZDBjNDkzM2FhYjA1ZmQzZTgzOTVmYmI4ZjY3NTI1OGU4NzBiMmE4MGY5MjhjYzhmZWIiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImU2YjA0NDkxNDY5NzIyZTgzNjNhOTRlNDA0NDUzOGUwMTNhMmEyMTdlMzZiMWU1NjhiYTYwYWNiN2I0YzhmMmUiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjMyNGNmZGM2MjI3YzViMTBhM2MyMmRkNjNmNWFhMDg4N2IwODRjNGUyNTJhN2Y5YzU0M2EzMDEzZTY3NjAyOTQiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjoyLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjM3OTRkMDJhODEyMTk0NGQxNzZkMjY1ZTliYjljYzU5ZjZhYjg5MGJjYWRmOGFlZmRlYmRiYmI0ZjJlYTg0NDQifQ" + "resource": "repo://TFI/App/MobileSceneModel.swift", + "version": "repo-file-v1:sha256:e795822b9fdd0257bfa13088cec2d09b38e8b6d3757bc4f322f7ab999560a229" }, { - "resource": "repo://TFI/App/MobileSceneModel.swift#L141-L173", - "version": "repo-lines-v1:sha256:046edd6f6b167f8f15d96ba41e370795db4c16457cdeea6f78d28f59cf9e7ae5:eyJzZWxlY3RlZExpbmVDb3VudCI6MzMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjczZjY3MmNkMDk3YWMyMjczZDY2N2Q1OGE0OGRhNTM4ODA0ZjI5NmUzZThhMmU5NmVhMTg3MmMwN2IwZTc1MDYiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjhlNWQ3ODg1MGNkODkxODM4ZjUxYzk1YzllY2FmZDRkZTU0Y2M1NjkzOTNjZDM4MDA1YzQwNzlkMDg0Yzc3ZmQiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImNjNjA5ZTE4OTFiNTYxZjIzOWZlODAxNDMzNGFlZDk1NDk1NTczMGY2ODVlZjAxNTEzYmQxNzQwMGNhODI3NTYifQ" - }, - { - "resource": "repo://TFITests/MobileAppTests.swift#L1442-L1480", - "version": "repo-lines-v1:sha256:2439296f96421e4c851e8967430d0ff1414fbe1d0cb8c1488c854946e13ace17:eyJzZWxlY3RlZExpbmVDb3VudCI6MzksImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjkyNjg1Mjg5MWQ1ZjVjNTVlNjY5YzQyNjRiNzc3NzAyOTVkMDcwYTY0YTVlZDAyZTc4ZmJiYTJhMTk5NGFkNDAiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImYxYzcwNGY0MzY4NDQ0ZDNlNGEzODU3OTA2YmU0NjIwMjA0MDI0M2Q5MGNkZDgwYTYwYzMzYTc4NTY0ZWJlYzkiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjczNzhlZjkzMGE2OGVlNWNmZTg4Y2NlMTZlOWYzYjkwMzBhNjExMzYwNzg2OTI0ZDM4ZGU5MzJlZGM3NjI0NDIiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjJiNWNiMmQwMGY5Y2FiODA3MjdkZDE5YjRlNWIwZjVhZGE5YzRiYTkzZTVhZmZhYjc1ZDI5N2NkYzNjYTk3NjIifQ" + "resource": "repo://TFITests/MobileAppTests.swift", + "version": "repo-file-v1:sha256:c0bd40724858241894caa3d378d8d7cf5e04ec80d26b5480a8036d5e643ac2e4" } ] }, @@ -196,11 +176,11 @@ }, { "id": "claim_9b627f261e1648d8a6a30ed16515bf1f", - "statement": "Mobile background behavior is aggregated across scenes: only when all tracked scenes are backgrounded does the app suspend sync/monitoring and begin a finite background task; expiry suspends workspace, forwarding, transfer, and assistant runtime work, while an active scene resumes existing workspaces and foreground services.", + "statement": "Mobile background behavior is aggregated across scenes: only when all tracked scenes are backgrounded does the app suspend sync/monitoring and begin a finite background task; expiry suspends workspace, forwarding, transfer, and assistant runtime work, while an active scene refreshes the current device record and resumes existing workspaces and foreground services.", "evidence": [ { - "resource": "repo://TFI/App/MobileAppModel.swift#L491-L536", - "version": "repo-lines-v1:sha256:f39329e8c7e533ceaef89590620b417dfffec9d46ddbf28a15a29bc868a69396:eyJzZWxlY3RlZExpbmVDb3VudCI6NDYsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjExYjYwOWM3ODJiMmM2NDVhM2M2ZDFmOWMyMDA3YzdjMGVlMWI5MTI1YjJjOGIxOTU0YTFlMTVjY2UxM2VkYjMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjExMGVhYzUyYWE2NjM0MDczNjQ0MjU1MTg2YTc5NGIxMjdmYzQ5MTA2NTRmYTNhMjBiOTgzMjMxOTIxZWU2ZWEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjQ1MGZiNmUwZThhYmY3YmNiNjdkODIzMzJjMmVlOGZlNzY0NzYzZDFlZjA5ODc4NWM3ZmM4Y2Q1Zjk2ZmRkOGUifQ" + "resource": "repo://TFI/App/MobileAppModel.swift#L512-L544", + "version": "repo-lines-v1:sha256:b41d3de43fde9c50d0b478946d6263804791efe4519784713c872a1679a205dc:eyJzZWxlY3RlZExpbmVDb3VudCI6MzMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjExYjYwOWM3ODJiMmM2NDVhM2M2ZDFmOWMyMDA3YzdjMGVlMWI5MTI1YjJjOGIxOTU0YTFlMTVjY2UxM2VkYjMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImU2YjA0NDkxNDY5NzIyZTgzNjNhOTRlNDA0NDUzOGUwMTNhMmEyMTdlMzZiMWU1NjhiYTYwYWNiN2I0YzhmMmUiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjExMGVhYzUyYWE2NjM0MDczNjQ0MjU1MTg2YTc5NGIxMjdmYzQ5MTA2NTRmYTNhMjBiOTgzMjMxOTIxZWU2ZWEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImI0NTg0ZDFiMmZhZDA3YjA0ZTIwZTVjOGE2MTM3OGEzNDFjZGJlN2E4ZDgwMzAwZDQ2NzJlNjhhYjcxZTFjZjMifQ" }, { "resource": "repo://TFI/App/MobileRootView.swift#L49-L51", @@ -229,10 +209,42 @@ "version": "repo-lines-v1:sha256:1f318118564e3c8a1ceac6136bf9c344ae39976ff5793b9dd39658561f5381d0:eyJzZWxlY3RlZExpbmVDb3VudCI6MzcsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImQ3ZThkZDgzNmY2YmU5M2Q1ZTk4YjZmNGM4ZjZkNTFjMGNmMjBhOTlhZjMyYzQwNTUyYzE3MDRjODQ2M2ZkZjkiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjIyM2QxNzZhYTU5ZjhjMDRhOWVkZjg2YTRiZDA1MDk4Y2FlMWUyODFmMTkzMzBjMWI3NTc4OGFkMDE2YWNmYTAiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjoxLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjQxMmNhMzQ1Y2NmNzViZjljMDgwNmJjZTY5NWJlOGRlODA4Yjc5OTg0MjUxYTdhNTRkMjAyY2Y2MTAxZGQ0NTEifQ" } ] + }, + { + "id": "claim_dcc48f62cc4c4cedba7c9c62cfed4508", + "statement": "Connection-history event lifetime follows the saved-host workspace or pane: creation starts an event, explicit close ends it, desktop pane moves preserve it, and Serial or temporary connections are excluded.", + "evidence": [ + { + "resource": "repo://TFI/App/MobileAppModel.swift", + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" + }, + { + "resource": "repo://TFM/Features/Workspaces/Pane.swift", + "version": "repo-file-v1:sha256:b385002c836e3a670278bf487552d97daa0edc44fcfa6ec0d5e77bf8510c272b" + }, + { + "resource": "repo://TFM/Features/Workspaces/WindowModel.swift", + "version": "repo-file-v1:sha256:005db3b60e96b760cf9e6ebed4cfdd0477f4c15db69205924901e47103c9949a" + } + ] + }, + { + "id": "claim_b19f509c5c4640898fee787a3fa8efaa", + "statement": "Both app composition roots create an app-wide reusable DeviceDirectoryModel with the Vault installation ID and a display name from DeviceKit's Device.current.safeDescription; foreground activation refreshes the directory record without rewriting connection events.", + "evidence": [ + { + "resource": "repo://TFI/App/MobileAppModel.swift", + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" + }, + { + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" + } + ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T05:22:55.650Z" + "at": "2026-09-22T10:02:02.985Z" } } diff --git a/openwiki/.claims/architecture/shared-module-directories.json b/openwiki/.claims/architecture/shared-module-directories.json index b3bfc299..e0767c51 100644 --- a/openwiki/.claims/architecture/shared-module-directories.json +++ b/openwiki/.claims/architecture/shared-module-directories.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:46da12757deeb46a90ef6fb1b732fb70c55f422f008a7a43e317c7f5f31e1681", + "pageVersion": "sha256:8f4fafaa7a5c62b52e73921856983f69fcafed01d18b9f2903aa53d8b883eec5", "claims": [ { "id": "claim_6e30bc8cde134c23815d274543d0786c", @@ -74,7 +74,7 @@ }, { "id": "claim_4ed733207e8b495f9fb9f0db86f1ad04", - "statement": "Persistence owns the versioned SwiftData schema/container and persistence adapters, including V31 scoped sync restrictions and recovery checkpoints; GRDBMemoryStore separately owns a lazily opened SQLite memory database and full-text message index.", + "statement": "Persistence owns the versioned SwiftData schema/container and persistence adapters, including scoped sync restrictions, recovery checkpoints, V32 connection-history storage, and the independent generic device-directory store; GRDBMemoryStore separately owns a lazily opened SQLite memory database and full-text message index.", "evidence": [ { "resource": "repo://Frameworks/TermindPersistence/AISecretStore.swift", @@ -85,26 +85,26 @@ "version": "repo-file-v1:sha256:c028e57ece449c591c32f8ac849fda2f6a82cda00974acc87f832295bb3acc3d" }, { - "resource": "repo://Frameworks/TermindPersistence/GRDBMemoryStore.swift#L6-L47", - "version": "repo-lines-v1:sha256:367ad381b7e20356b66e497b9e380ad6c8d11ce26150386d054664175c7b2dda:eyJzZWxlY3RlZExpbmVDb3VudCI6NDIsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImU2ZTgzNWJiMzkzNjdhODBkMDdiMDkxZDJmZGIwODFhZGM0YThkYTg0NmQ4MjhjYjM1YzUzY2IwYzUyMjc4MDIiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjAxYmE0NzE5YzgwYjZmZTkxMWIwOTFhN2MwNTEyNGI2NGVlZWNlOTY0ZTA5YzA1OGVmOGY5ODA1ZGFjYTU0NmIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImNiZWFjYTU1MGI4N2Y4MTM4MGM0NDM2MDc1YTA3MDQ0NGJkMTRhYWQ4MzMwNTZmNjE0MmYyMDdiNzlhNjdkMDUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjE3NTFkYmVhZWI2MDk3MWM4NGIwNWEzYjVjNWZlMmIyN2E2NmY0MjQxZTM5NTkwOTQ1NzU4NzljZGVkMjg1YTEifQ" + "resource": "repo://Frameworks/TermindPersistence/GRDBMemoryStore.swift", + "version": "repo-file-v1:sha256:5d148d9b678c6512cdf58a6803d31d360869a54ecf3c7cd1176abd1126ed2bb6" }, { - "resource": "repo://Frameworks/TermindPersistence/HostPersistence.swift#L784-L791", - "version": "repo-lines-v1:sha256:8d79c417b0144fe8ea720dac157a20fb35b68045b6d5611819f811488698647c:eyJzZWxlY3RlZExpbmVDb3VudCI6OCwiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiN2FjYmQ4ZDg5YjViZTJkYWYyZDQ0ZWYwNDhkY2UxYmZmNjlmYzFlZGEyZjE3NmIzY2RjYzQzMjIwMDJmM2Q4MSIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiMmE2YWQyYmNlYjdkNWJhNzgyYjJmZWM4YjFlZTM3ZjUzOGE3MDNkOGVlZTMxMDc2ZjZiYTU1ZWI5ODQ4ZjIzYyIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiMTllNzYzNTVjZjExOTk0ODBjM2Y5OTgzNmIxNDg2Yzg0MWQ4OTFmYWFkOTA0YzYwOGUwOTMwMjJkMzc1N2FlZSIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiM2VmNDZiNWVlZDczMWY4OWQ1MDA5MmE1NzYwMGRiOTQxMzQ5OGZjMTBlMDZlOWQ3ZDIxZmUxOWIyNTZlZTM0MyJ9" + "resource": "repo://Frameworks/TermindPersistence/HostPersistence.swift", + "version": "repo-file-v1:sha256:4e71ea8935acb91019318067495b9123e1540ea7c74c407f49ed804648ecb77d" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift", + "version": "repo-file-v1:sha256:9a3e7296fd09c4b397c90c7b15c20775a0950a145ec1c506ac431c04fc5ea020" }, { "resource": "repo://Frameworks/TermindPersistence/TermindPersistence.swift", - "version": "repo-file-v1:sha256:f756ca3c13349717a9bc863d4520e74336321a245eb1f74471d43b8e9c7dd782" - }, - { - "resource": "repo://TFMTests/ArchitectureBoundaryTests.swift#L207-L223", - "version": "repo-lines-v1:sha256:bb65a8050531c288646a62d024e2c5ce82632ce10e8f598b6d9de35f3de1606f:eyJzZWxlY3RlZExpbmVDb3VudCI6MTcsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjRjZjA5ZWIwYzU3NDRmYjk3MzMxYjk4ZmYzZmJmMDVhNjAyYmEzZjdmNjdlNWRhNWJlOTA5NTAzM2Y2MjU4MjUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjAxYmE0NzE5YzgwYjZmZTkxMWIwOTFhN2MwNTEyNGI2NGVlZWNlOTY0ZTA5YzA1OGVmOGY5ODA1ZGFjYTU0NmIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImNlNDNhMjAwNmYyNDA5MjZmMDAwYzdlNGI5MzIxNjY5M2MyNGRkZjU3MWQ3YTM3MDRjNGU1MmM4MWI1ZjBkZmQiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjAzOWVhMmE5NzQ2MjU5N2MwNmU3ODEyZDZmYWRkNzJkMTNmMTYxMDliYTBkZTgwOWQ3MjRlM2E3NTQ1ZjZhNzkifQ" + "version": "repo-file-v1:sha256:9613dfe8aac0035ffcaca53b94efe4ae8d5d0fd2d86690608d2aaf4f7e553ca4" } ] }, { "id": "claim_15c2069e54654cb49ac2a21c36d8dae6", - "statement": "Explicit Infrastructure CloudKit coordinators use Persistence journals, scoped safety storage and a shared lifecycle adapter while retaining independent category engines; received and conflicted records share validation, and the SwiftData container still disables automatic CloudKit storage.", + "statement": "Explicit Infrastructure CloudKit coordinators use Persistence journals, scoped safety storage and a shared lifecycle adapter while retaining independent category engines; received and conflicted records share validation, and the SwiftData container disables automatic CloudKit storage.", "evidence": [ { "resource": "repo://Frameworks/TermindDomain/Vault/CloudSyncSafety.swift", @@ -128,7 +128,7 @@ }, { "resource": "repo://Frameworks/TermindPersistence/TermindPersistence.swift", - "version": "repo-file-v1:sha256:f756ca3c13349717a9bc863d4520e74336321a245eb1f74471d43b8e9c7dd782" + "version": "repo-file-v1:sha256:9613dfe8aac0035ffcaca53b94efe4ae8d5d0fd2d86690608d2aaf4f7e553ca4" } ] }, @@ -215,12 +215,12 @@ "statement": "Presentation contains shared forms, formatting, transcript state, SFTP presentation and terminal appearance, while architecture tests prohibit native UI, persistence and remote-infrastructure dependencies in this framework.", "evidence": [ { - "resource": "repo://Frameworks/TermindPresentation/AssistantTranscriptPresentationState.swift#L6-L44", - "version": "repo-lines-v1:sha256:f9ecbc961f95aecdc449c42921074a82d24baeb3a14a49d0eb6e53f3d6b2dcad:eyJzZWxlY3RlZExpbmVDb3VudCI6MzksImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ5YmYzM2M3N2QyMzg5MzY2ODA4Y2RlZmNhNTNjNzUzMjMzNzMxYzI0ZWYyOWM2ZjIzNTE1NWM5NDliYTZjYTAiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjAxYmE0NzE5YzgwYjZmZTkxMWIwOTFhN2MwNTEyNGI2NGVlZWNlOTY0ZTA5YzA1OGVmOGY5ODA1ZGFjYTU0NmIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjEzOGNiZTU0MDNhODcyNDlmNWE4MjY3ZGEwOTg1MDk2ZmVkMDQ3ZTZjOTJkOGMyZmE2YTUxYmMwMWVjZDdiOTAiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImE4Zjg1M2VhNGM5ZDhhMmRkNDk3M2M5MDRjMmJlM2FjZTZkNjE1NDdiYTQxYWYxZDBjZTM0ZDJiZjAzNGJhNGEifQ" + "resource": "repo://Frameworks/TermindPresentation/AssistantTranscriptPresentationState.swift", + "version": "repo-file-v1:sha256:94bd45c53dafabe0c801233776f67fead7b8e1ddcc6d2f9624dc046d93ddcf90" }, { "resource": "repo://Frameworks/TermindPresentation/IdentityFormSections.swift", - "version": "repo-file-v1:sha256:7c3c7dd1ba4148f10f1ce45aa97b648555ff481c59d69097d544dce24e66aac0" + "version": "repo-file-v1:sha256:93de372e215799eb224092fa676a7e7716a646f5545127504c3dfe7d94a2516e" }, { "resource": "repo://Frameworks/TermindPresentation/SFTP/RemoteFile%2BPresentation.swift", @@ -231,8 +231,8 @@ "version": "repo-file-v1:sha256:a8ac25cdd81d3d6205d9a1645a307d05b17e4f7690cdb2d8d25cd536d9ca449b" }, { - "resource": "repo://TFMTests/ArchitectureBoundaryTests.swift#L179-L189", - "version": "repo-lines-v1:sha256:9ae795cb580db70eb440a08fa626cf8b370a155c52c4f587825aa8585d631216:eyJzZWxlY3RlZExpbmVDb3VudCI6MTEsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjBkNDI5OTVlMWFiMGZhNTYyM2ViZDQ4NzRmMDliZDFhYjBmMjY2OWVkOGQ0Y2E3ZmIyMjU4NjMzYmE0MTVkNjUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjQ3NDI5ODQ5YmU0MTZhMzMzMmY5NTcyYmY1NTM5ZDkzOTZjNDEzMjY1MWE4MzhiZGRhZGIzN2IwNzg1OWViMmUifQ" + "resource": "repo://TFMTests/ArchitectureBoundaryTests.swift", + "version": "repo-file-v1:sha256:93f91b564371034f72c76c52581281fea35eebe77efa124b0f9668b4ed5b4034" } ] }, @@ -262,13 +262,35 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" + } + ] + }, + { + "id": "claim_dd6ae4fba22f4214851997a12baa41db", + "statement": "Device metadata is reusable across features: Domain owns DeviceRecord, Application owns DeviceDirectoryModel and its repository port, and Persistence owns the SwiftData repository and Vault journaling adapter independently of connection-history storage.", + "evidence": [ + { + "resource": "repo://Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift", + "version": "repo-file-v1:sha256:368841be82a49dc8d6660dca31216bd0f319fdc76276a9bd1e22b6a8ea4a2cf8" + }, + { + "resource": "repo://Frameworks/TermindApplication/Repositories/DeviceDirectoryRepository.swift", + "version": "repo-file-v1:sha256:0a1a1ff68cedf0d380d197304d6088d35aafbe510723b8508c94b33583eadde3" + }, + { + "resource": "repo://Frameworks/TermindDomain/DeviceRecord.swift", + "version": "repo-file-v1:sha256:7bd4537277fda9c7e6e70318290ee3f17ce0271433ebff5c3ac0a28394f1d1d0" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift", + "version": "repo-file-v1:sha256:9a3e7296fd09c4b397c90c7b15c20775a0950a145ec1c506ac431c04fc5ea020" } ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T05:22:55.650Z" + "at": "2026-09-22T10:02:02.985Z" } } diff --git a/openwiki/.claims/architecture/supporting-system-directories.json b/openwiki/.claims/architecture/supporting-system-directories.json index 3e269701..132f5718 100644 --- a/openwiki/.claims/architecture/supporting-system-directories.json +++ b/openwiki/.claims/architecture/supporting-system-directories.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:d571512d2bbb036fb97369a80a51af74729053b8d7d4164b178ba7b1e0919d80", + "pageVersion": "sha256:fa243e662bcccf538fa4c830c781851a26ec9aea0c86163cfc99644ac7364159", "claims": [ { "id": "claim_8809151c43594803b83cf0451d849112", @@ -12,11 +12,11 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" }, { "resource": "repo://Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", - "version": "repo-file-v1:sha256:02109cb8656598ce20a6c8f9a527e97630ffd1b5f00ea9536425abc0417187fd" + "version": "repo-file-v1:sha256:4a5a5f330b29137020004d3059eaf48e080a45eea8460e1a74fa434565200e13" } ] }, @@ -100,7 +100,7 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" }, { "resource": "repo://WorkspaceActivitySupport/WorkspaceActivityAttributes.swift", @@ -226,7 +226,7 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" }, { "resource": "repo://Termind.xcodeproj/xcshareddata/xcschemes/TFM.xcscheme", @@ -237,6 +237,6 @@ ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T03:45:03.442Z" + "at": "2026-09-22T10:02:02.985Z" } } diff --git a/openwiki/.claims/architecture/system-boundaries.json b/openwiki/.claims/architecture/system-boundaries.json index cb291c24..f57f27d9 100644 --- a/openwiki/.claims/architecture/system-boundaries.json +++ b/openwiki/.claims/architecture/system-boundaries.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:52ee85c8226f34cc3e7f73201ee744ae289dce13814ec7f5f2035f3e66f38f84", + "pageVersion": "sha256:17dc0f2b25ec8c65104d2cb491b9afc63b341e639478ff0bcfdfcf583e74a55f", "claims": [ { "id": "claim_085ce722f39f48eea75366ec3b3f7554", @@ -16,7 +16,7 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" } ] }, @@ -36,19 +36,19 @@ }, { "id": "claim_04df7977ba1a43bfba0b2585bc5f185a", - "statement": "Persistence exclusively owns SwiftData models, versioned schemas, migrations, and ModelContainer construction; its current container uses TermindSchemaV31 and the test suite rejects @Model declarations outside TermindPersistence.", + "statement": "Persistence exclusively owns SwiftData models, versioned schemas, migrations, and ModelContainer construction; its current container uses TermindSchemaV32 and architecture tests reject @Model declarations outside TermindPersistence.", "evidence": [ { - "resource": "repo://AGENTS.md#L7-L11", - "version": "repo-lines-v1:sha256:eb1d08366705afdc99b54adcffa378299221d4d7be416d22cc011f6a15dbcfd2:eyJzZWxlY3RlZExpbmVDb3VudCI6NSwiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiOWM0OTlkYTUxNTAxNzdiYTFlNzljZjc5YTM0ODFiMzM4NmViYmJlYTU4MGI3Zjc2ZTBjZjUxOTNmYWE5OTFjYiIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiYjEzMjhkOGFmMzQ1YjI3MzczOTBkZjBlMWYwN2I1ZmZlYmE3Y2IyZWIyNzZmZDBiMDk1NTg5ZmVkZjgwOGE2NiIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiODRkOWIzNTZmZmVmMmJmZWFiZmM5MmQwM2EwMDkyMWY3YTk0OTUwMzk5Nzc3YTY1MmFhZTZkNmZhMTcyZTQ0OCIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiMmM0MzRhZjQzYmUwYTA3MjE2NzE5ZjBkMjg5MjdhNjkzMzNhYmJlZDhiY2U0MWZmOTA4ODM2ZDIxNDk3NmFjNyJ9" + "resource": "repo://AGENTS.md", + "version": "repo-file-v1:sha256:7cd9b9e940106ce07b1fc987586fc9c6ca1c4f9489cabb02791b627a05a1eedb" }, { "resource": "repo://Frameworks/TermindPersistence/TermindPersistence.swift", - "version": "repo-file-v1:sha256:f756ca3c13349717a9bc863d4520e74336321a245eb1f74471d43b8e9c7dd782" + "version": "repo-file-v1:sha256:9613dfe8aac0035ffcaca53b94efe4ae8d5d0fd2d86690608d2aaf4f7e553ca4" }, { - "resource": "repo://TFMTests/ArchitectureBoundaryTests.swift#L207-L223", - "version": "repo-lines-v1:sha256:bb65a8050531c288646a62d024e2c5ce82632ce10e8f598b6d9de35f3de1606f:eyJzZWxlY3RlZExpbmVDb3VudCI6MTcsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjRjZjA5ZWIwYzU3NDRmYjk3MzMxYjk4ZmYzZmJmMDVhNjAyYmEzZjdmNjdlNWRhNWJlOTA5NTAzM2Y2MjU4MjUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjAxYmE0NzE5YzgwYjZmZTkxMWIwOTFhN2MwNTEyNGI2NGVlZWNlOTY0ZTA5YzA1OGVmOGY5ODA1ZGFjYTU0NmIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImNlNDNhMjAwNmYyNDA5MjZmMDAwYzdlNGI5MzIxNjY5M2MyNGRkZjU3MWQ3YTM3MDRjNGU1MmM4MWI1ZjBkZmQiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjAzOWVhMmE5NzQ2MjU5N2MwNmU3ODEyZDZmYWRkNzJkMTNmMTYxMDliYTBkZTgwOWQ3MjRlM2E3NTQ1ZjZhNzkifQ" + "resource": "repo://TFMTests/ArchitectureBoundaryTests.swift", + "version": "repo-file-v1:sha256:93f91b564371034f72c76c52581281fea35eebe77efa124b0f9668b4ed5b4034" } ] }, @@ -58,23 +58,19 @@ "evidence": [ { "resource": "repo://Frameworks/TermindPersistence/TermindPersistence.swift", - "version": "repo-file-v1:sha256:f756ca3c13349717a9bc863d4520e74336321a245eb1f74471d43b8e9c7dd782" + "version": "repo-file-v1:sha256:9613dfe8aac0035ffcaca53b94efe4ae8d5d0fd2d86690608d2aaf4f7e553ca4" }, { - "resource": "repo://TFI/App/MobileAppModel.swift#L126-L126", - "version": "repo-lines-v1:sha256:fb0eb95a9489d07f13bfc5dd8a79a4ea9a3c8e5ecb77e91490982cb9490b92ce:eyJzZWxlY3RlZExpbmVDb3VudCI6MSwiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiZmIwZWI5NWE5NDg5ZDA3ZjEzYmZjNWRkOGE3OWE0ZWE5YTNjOGU1ZWNiNzdlOTE0OTA5ODJjYjk0OTBiOTJjZSIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiZmIwZWI5NWE5NDg5ZDA3ZjEzYmZjNWRkOGE3OWE0ZWE5YTNjOGU1ZWNiNzdlOTE0OTA5ODJjYjk0OTBiOTJjZSIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiMmFiM2M4YmNlOTM0ZTMzMTZjMDVjMGI4YWFhYjJhZTg4ZTI3NDM1Y2NkODU1Mjg0MjVjODBkYzViOGUzMDdhOSIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiMDNhOTkxMGRkNDRlMTA2OWExY2VkMDFhZTMzOTk3YzNlMTU0YjcyN2U3YzczMDg0MjMzODdlYTQ1NzlmYmY2ZSJ9" + "resource": "repo://TFI/App/MobileAppModel.swift", + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" }, { - "resource": "repo://TFI/App/TermindIOSApp.swift#L11-L30", - "version": "repo-lines-v1:sha256:da8be22039112fa704351fb896f9a2ced4d1600f566c8cd5afde7aeac3066350:eyJzZWxlY3RlZExpbmVDb3VudCI6MjAsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjhiY2M2MTFhZjIzYTkxYzNiNzI3ZDBhY2U0ZGUyMjI0MmZjNDkwNmM3MTk0YTYzNDkxOWI2NWQ3MDNkZDAwYjAiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjU1MjJjNDJjZDE5YzZkMDNkODA4YzQ1MjcyMDU1MjgyNWI3ZWExYzViNDkxYjIxODUxMWNhYzc0MTVjZjlkOWIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImNmYTc4NDM4ZDRhOWM4YzI5OWQ1NmJlNDRmNDNhN2Y1YWQ5M2FjYjRlMzllNzI1Y2YwMjYwYjY0OWQ4NjA5YWMiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjllYmU5ZjVkMjdiOGY2ZjAyMTc1ZTAyYWFhYTJlMzI0YjM0NjVkODMxMmM4ZWVjM2I4ZWQyOWRmNTkyY2ExZDgifQ" + "resource": "repo://TFI/App/TermindIOSApp.swift", + "version": "repo-file-v1:sha256:9daf1c0933fdbcff826165a8edc171876c54faa343a184348284e63f736e86bf" }, { - "resource": "repo://TFM/App/TermindApp.swift#L137-L139", - "version": "repo-lines-v1:sha256:7e521f0009c7097b3075811418a0f0975f3a229ce9bb3012a58b7cd360f45681:eyJzZWxlY3RlZExpbmVDb3VudCI6MywiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiYTc0MzYyN2VkOThlMDJiNTc4ZjUyNjljNzYwZWU1ODRlZTU0MmMzZTNjOWE0NDA4OTFjZDE1MTYyNWE1OWRmNyIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiZTZiMDQ0OTE0Njk3MjJlODM2M2E5NGU0MDQ0NTM4ZTAxM2EyYTIxN2UzNmIxZTU2OGJhNjBhY2I3YjRjOGYyZSIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiYjk1Mzg1OGIwNGQ2ZDU2ZTRkNWMxMzQxNThmNDA4NDRkNWRlZDMzMzliMWYyYWZmZjFkMzcwYzVlODUyN2Y1MyIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiZWE4YzE5YmU1ZDgzZTVhODY3NzY1ZTdiZmJmZDg3YTA2NDBiYjRjOTk4Yjg2MTIxY2U3N2U1ZGIxNzUwNDAxMSJ9" - }, - { - "resource": "repo://TFM/App/TermindApp.swift#L30-L39", - "version": "repo-lines-v1:sha256:8adecd981086eaed1cad0903e82917b4d86a8e9d6e35d0314bcbb752f450f95d:eyJzZWxlY3RlZExpbmVDb3VudCI6MTAsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjFjMjU4YjdiNGY1YjRhZjg4NjE4ZDI4NGJkODg1Y2VmYzNiMmQxOWRhMTk5OTlmN2ExNDI0YmQxMDliMjNjZjMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjlhZTg1YmI2MTQ1ZGM4ZDE4OWJhMzEyOTRhNDk4Njc0ZjU4M2RlZjE4ODIwM2JiZDJlZWVhYWQyN2Q3OTNjYmEiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjRhMzY1Mzg4MWY4OTIzZGEyYWJmM2JhZDNmMmJhYjNiZGI4YTM2Njk1YjY5MmY5YjlmNmExMWI3NWQ4MjkwZjAiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImQxMmJjZjU5NmJmOTk3NWM2Y2ZhOTU1NWJhZjAwMjBiZmM4OWFkMjA1N2U2YWNmNzQ3NzJjZTJlNWIxMDQxMTAifQ" + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" } ] }, @@ -145,10 +141,48 @@ "version": "repo-lines-v1:sha256:04c57608a4f36e066a86d45b1927320ec3f1b3912960cf3ead95574b6a0deab2:eyJzZWxlY3RlZExpbmVDb3VudCI6MzksImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjRjZjA5ZWIwYzU3NDRmYjk3MzMxYjk4ZmYzZmJmMDVhNjAyYmEzZjdmNjdlNWRhNWJlOTA5NTAzM2Y2MjU4MjUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImI5ZjdjNGNiNzk4NDRmM2QyMTg3YjU1MGRiNjhlZmVlMjU5MDQ4NDZiNTgwZWRmNjIxNTc3MGE4NTAyYTIwMzgiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjQ5Zjg3ODhhMDEwYWMyNjY0MDQxYThhYzZjNjU3MTdiOGU0Y2RiZTYzMmMyY2JjNTMyMzZhMWMwNWI5MzhjNmUifQ" } ] + }, + { + "id": "claim_af53e904e34a4a029b78a9356cc9b56e", + "statement": "Connection-history ownership is layered and independent from device naming: Domain defines events that reference sourceDeviceID, Application defines the history model/repository port, and Persistence owns event storage/journal transactions; the reusable DeviceRecord-to-name directory has its own Domain value, Application model/port, and Persistence repository, while both TFM and TFI supply DeviceKit's Device.current.safeDescription as their app-owned naming adapter.", + "evidence": [ + { + "resource": "repo://Frameworks/TermindApplication/Repositories/ConnectionHistoryRepository.swift", + "version": "repo-file-v1:sha256:52a0371f6d25d3eaa7692a88d55cb6fe40487800882b96f592f1f1cc8f211059" + }, + { + "resource": "repo://Frameworks/TermindApplication/Repositories/DeviceDirectoryRepository.swift", + "version": "repo-file-v1:sha256:0a1a1ff68cedf0d380d197304d6088d35aafbe510723b8508c94b33583eadde3" + }, + { + "resource": "repo://Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift", + "version": "repo-file-v1:sha256:edcf5020f1965dcc1bad5d1b9dc1523cced21a62d1a93f3a1fd079dde25fb37f" + }, + { + "resource": "repo://Frameworks/TermindDomain/DeviceRecord.swift", + "version": "repo-file-v1:sha256:7bd4537277fda9c7e6e70318290ee3f17ce0271433ebff5c3ac0a28394f1d1d0" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataConnectionHistoryRepository.swift", + "version": "repo-file-v1:sha256:e0468dec12004d9d8552b2f0d7745008a17b0271f20b65c1b425d9b917e0319a" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift", + "version": "repo-file-v1:sha256:9a3e7296fd09c4b397c90c7b15c20775a0950a145ec1c506ac431c04fc5ea020" + }, + { + "resource": "repo://TFI/App/MobileAppModel.swift", + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" + }, + { + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" + } + ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T05:22:55.650Z" + "at": "2026-09-22T10:02:02.985Z" } } diff --git a/openwiki/.claims/concepts/persistence-and-secrets.json b/openwiki/.claims/concepts/persistence-and-secrets.json index 2ec75bad..7fc8e273 100644 --- a/openwiki/.claims/concepts/persistence-and-secrets.json +++ b/openwiki/.claims/concepts/persistence-and-secrets.json @@ -1,40 +1,36 @@ { "schemaVersion": 1, - "pageVersion": "sha256:8e063637cd5085c209089a4ce3cbbb163b175213e719bae65342a1a5598f9b8e", + "pageVersion": "sha256:c7ce6e8151265464db5b835c9233fca3a6d9321c828b252363e3d7d0552da05c", "claims": [ { "id": "claim_e50b505da2214c6781fa8eb1a146dacf", - "statement": "TermindPersistence.makeContainer is the single SwiftData container factory: it fixes the current V31 schema and migration plan, disables automatic SwiftData CloudKit storage, rejects an in-memory request with a file URL, and propagates store-open errors.", + "statement": "TermindPersistence.makeContainer is the single SwiftData container factory: it fixes the current V32 schema and migration plan, disables automatic SwiftData CloudKit storage, rejects an in-memory request with a file URL, and propagates store-open errors.", "evidence": [ { "resource": "repo://Frameworks/TermindPersistence/TermindPersistence.swift", - "version": "repo-file-v1:sha256:f756ca3c13349717a9bc863d4520e74336321a245eb1f74471d43b8e9c7dd782" + "version": "repo-file-v1:sha256:9613dfe8aac0035ffcaca53b94efe4ae8d5d0fd2d86690608d2aaf4f7e553ca4" }, { - "resource": "repo://TFI/App/MobileAppModel.swift#L118-L127", - "version": "repo-lines-v1:sha256:8a5d262e80caed31f8089ea83dc01c1168a7c8d943178df9eb503ba742bf686c:eyJzZWxlY3RlZExpbmVDb3VudCI6MTAsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImMwMDc4NTkwOGZlY2YyODUxNWNhMDMxOGZkYjI4ODhhNjU4ZTQyMjVkZWZjMTAxNWExYWFmNzUxZGQ3OTUwNTgiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ2YzY1MmUyMjgxMzZlMDc4MGYyYzE2ODBmMGVjZTA5NzlmMTRiYjFlOTM1OGZmNzY3MGJjZWMyMTM2Y2YyM2UiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjlmN2M4NjBjOTAzOGM2ODEzOWVjMTYzOGNiZThkNDYzNzYxMDYyOGY0NGU4MTk1OThjYmY2YjA0Y2MzOWIzNWIiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImI3MThiYjE3MDU3MDRiYmUxN2JjNWM5ZDRmODc2NDI3NzY4MWUyYjkwNDk5NWExYzJiOWY5MWJlYTdiNzNkZmMifQ" + "resource": "repo://TFI/App/MobileAppModel.swift", + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" }, { - "resource": "repo://TFM/App/TermindApp.swift#L30-L39", - "version": "repo-lines-v1:sha256:8adecd981086eaed1cad0903e82917b4d86a8e9d6e35d0314bcbb752f450f95d:eyJzZWxlY3RlZExpbmVDb3VudCI6MTAsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjFjMjU4YjdiNGY1YjRhZjg4NjE4ZDI4NGJkODg1Y2VmYzNiMmQxOWRhMTk5OTlmN2ExNDI0YmQxMDliMjNjZjMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjlhZTg1YmI2MTQ1ZGM4ZDE4OWJhMzEyOTRhNDk4Njc0ZjU4M2RlZjE4ODIwM2JiZDJlZWVhYWQyN2Q3OTNjYmEiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjRhMzY1Mzg4MWY4OTIzZGEyYWJmM2JhZDNmMmJhYjNiZGI4YTM2Njk1YjY5MmY5YjlmNmExMWI3NWQ4MjkwZjAiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImQxMmJjZjU5NmJmOTk3NWM2Y2ZhOTU1NWJhZjAwMjBiZmM4OWFkMjA1N2U2YWNmNzQ3NzJjZTJlNWIxMDQxMTAifQ" + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" } ] }, { "id": "claim_b568795bf1ad46c4b6004396995adb73", - "statement": "The macOS and mobile composition roots retain one process-level `ModelContainer` and inject contexts into persistence-backed services; the mobile root creates Application Support before opening its file store and uses in-memory dependencies for test/injected operation.", + "statement": "The macOS and mobile composition roots retain one process-level ModelContainer and inject contexts into persistence-backed services; the mobile root creates Application Support before opening its file store and uses in-memory dependencies for test/injected operation.", "evidence": [ { - "resource": "repo://TFI/App/MobileAppModel.swift#L118-L180", - "version": "repo-lines-v1:sha256:db92778684ec854d21ad83fb0e5a404e35f1b8c641d70b6e391c4263f5e48330:eyJzZWxlY3RlZExpbmVDb3VudCI6NjMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImMwMDc4NTkwOGZlY2YyODUxNWNhMDMxOGZkYjI4ODhhNjU4ZTQyMjVkZWZjMTAxNWExYWFmNzUxZGQ3OTUwNTgiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjZlZmNhMTIzMDYwZmVkZGE3ODBjZjAzMDMwM2YwNGYxNTM1ZWE5NzQ1ZWQxOTU4MTg1N2M2MTIyYTU3NmRmYTkiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjlmN2M4NjBjOTAzOGM2ODEzOWVjMTYzOGNiZThkNDYzNzYxMDYyOGY0NGU4MTk1OThjYmY2YjA0Y2MzOWIzNWIiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImI3OTYyMzI4MWRlNDNmYTI5YTFhYWUwMjYxMzE0N2U0NmQ3NGQzYTkxYzFmZGMxMDg3MDRmNjIxMzQ0ZTFhMGEifQ" + "resource": "repo://TFI/App/MobileAppModel.swift", + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" }, { - "resource": "repo://TFM/App/TermindApp.swift#L15-L18", - "version": "repo-lines-v1:sha256:72f66055489ca0a54a2d7ba2dae85c23c53633772a0410e5cab61e509ede8233:eyJzZWxlY3RlZExpbmVDb3VudCI6NCwiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiNGFjZDYzNGUzMzQ5OTk5YzFmMmJkYTYyYzViMTY2OWNmZDRmMzY2ZDNmYWQyZmQzNjZhNDdmMDJmYmFmYmQ3ZiIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiMDgzYmJlNTc2ZTk0MTRlYjJjMTQwMTk4YzEyOTU1NDEzNGRhOWZmMjA2OTRmOWJkMzU4ODE1MjVlMjdlMDg5YyIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiMzEwYmYxOWZlNGNjNzhjZDE3MWFjOGMxOWVlYmUzNzUwNzhiNGY4Yjg5NjI5MDAwOTY4NGU5OWEyZWIzNjkyNSIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiYjAwYTIzMzNkNWNiZTQ1NWZmNGRlM2E3ODFmMjUyNWU5OTUxMjc0NjQ5YTRiYTE0ZDdlYzIzZGQwNTE2NjFhOCJ9" - }, - { - "resource": "repo://TFM/App/TermindApp.swift#L38-L77", - "version": "repo-lines-v1:sha256:cd060dc855bcde4d82c78e5a5f417b74beed952a63a8748fdd9eac673f76054f:eyJzZWxlY3RlZExpbmVDb3VudCI6NDAsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImNjMTgzNWMwYTZhOTg4ZDMzNTU2ZmQxNThmMGVlNDAwNzM2YTU0MTk5MjRlZDY0MWI2ODg5ZmFhMDBmZTIxNDAiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImM4MDAwODJlYmI2ODI4YTdkMzkzYjcwZTY3NjNhNWQ1ZTFkNzdlNmNkNWI3ZDA4ZmNlMWMzZGI0NWJhZDg4MjIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImQxNmEwNjE2YjQzMDc3MTFiODYyZjA2ODAyYTI3OGU2ZTc2Nzk0MWRlMDM4YmI3NjlkZmNkZmYzYmE0ZDI4MTciLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjlmYmU4MTBiMWQyMjc1MDIyNTEwZjg4MmNiZDlhMmMzY2Y3Mjc5NjhiYTMxOTA1YTQxZTk3NWRiYjUxMDM5NDkifQ" + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" } ] }, @@ -64,37 +60,29 @@ }, { "id": "claim_724958e5129a4a6a847deb92d7f33b5f", - "statement": "Termind retains adjacent V1-through-V31 migrations and frozen historical shapes; V30 replaces the Known Host mismatch model, and V31 adds scoped sync quarantine and recovery entities without modifying V1–V30 models or the CloudKit envelopes.", + "statement": "Termind retains adjacent V1-through-V32 migrations and frozen historical shapes; V31 adds scoped sync quarantine/recovery, while V32 removes the live Host usage column, adds connection-history source/lifecycle fields, backfills sourceDeviceID, and inserts a separate generic DeviceRecord without changing the CloudKit envelope format.", "evidence": [ { "resource": "repo://FrameworkTests/CloudSyncSafetyPersistenceTests.swift", - "version": "repo-file-v1:sha256:fe8c66a47936a9120eb3b71def8c148801e215d53fda47a326349388b30f9c1d" + "version": "repo-file-v1:sha256:46b7af81fc4ee722076d51522aca8b1c8dd5738c704da39d95034c734aaf851b" }, { - "resource": "repo://Frameworks/TermindPersistence/AIPersistence.swift#L6-L14", - "version": "repo-lines-v1:sha256:f1914d2ea96c7a89b7c606e14c91e627344f1409a27f1a3e9f6e8d6f9ff001de:eyJzZWxlY3RlZExpbmVDb3VudCI6OSwiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiOWM4NmYxYWIzZGIwOWViNWJlMDk4ZDc2YzBjZWNkZmYwZWJmMTkwMmE5Yzc1YjU1OTYxOWUzZmVmMzZiMmYwNSIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiZDBmNzdjZGRlNGQ3ZWMyYjQyMjI3ZDRhYjA3NDc2ZTc5MjY2MWVlNzI3OTQyMzZmYmYzYmI1ODMzMWUyNjc0ZSIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiOGNkYjk3N2ZhZTAwYWNjNjBhNTJhZTM2ZmQ0MmI3NGQyMTVlNjI1ZDM4YWE5MTRmN2JlMWU3N2U1OTYzYTA4OCIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiNDJkZGQzYWM3ODZkOWI1NmNlOWI0NDIyMTk2ODE1ZTI5YmY5MWYxNzA4NWNjOWVmZTZmYTM4OTVkMGQ1MTkwNCJ9" + "resource": "repo://Frameworks/TermindPersistence/HostPersistence.swift#L770-L895", + "version": "repo-lines-v1:sha256:73dc81eb20101571d798c214cdd216be804ae8e8db29b7e8f191dc6eca8377df:eyJzZWxlY3RlZExpbmVDb3VudCI6MTI2LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiJmN2QzZTNlY2JlYzc4Y2U2NmVjMjQ5YWRmYzU1NWY4NzVlNGNlODMwYmIyMTg0MWJhZWQwYzA0NWY4ZGE1ODMyIiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiI0ODkzZjQwZTM3ODFlYjg5NmM4MmQwNDZhMDgwNjdhMTFlMzVjZGYyZjFkZmFmMjZmZWE5MGI4ZGI2Njc2NDEzIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiJkMWIwNTQ4OTc3Njg1MTE4N2EyMTEwODI0ODZkOGUyYjc1NWQxMTJkMjYxOGNjNjQ0NjE4NDk4ZjdhOTFkNzdjIiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiIwY2YzMTVkYmQ1MWNiMGU3NGUzZWYwNTQxMzk5NzZlNjAxNjJlZTVhZmMzYTlkZGE0MzI5NTdlMjgyMWRhMDQ5In0" }, { - "resource": "repo://Frameworks/TermindPersistence/HostPersistence.swift#L769-L877", - "version": "repo-lines-v1:sha256:a8bdcc148c506d96cf15169ba66dd0e323058cff374b192c5efb5589bdc03671:eyJzZWxlY3RlZExpbmVDb3VudCI6MTA5LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiIxZWQzNDc3ZWQwNTY1Y2UzMjI1ODMyOTJhZWQ5YWM5Y2FiNzBjNTVkOWU0MGI0MTNjMjNjZWExNGUxZmZmODM1IiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiI0MTJjYTM0NWNjZjc1YmY5YzA4MDZiY2U2OTViZThkZTgwOGI3OTk4NDI1MWE3YTU0ZDIwMmNmNjEwMWRkNDUxIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiI2MmYwZWE3ODMzNjQ1Mzc2MWVmMDM5NjgwNDYwYzhlNGFkODBlNzAyNGJmOGFhMzRkY2VlMWEyNWU2NjU5YWQyIiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiJmZGMxN2M3NWE5OTc3NWFkYzA5NGJkM2VmNjQ3NDViYmZmMTRiMDEyMWUwOWZhY2UwZjBlMmMxMzhmMDBjMjNmIn0" - }, - { - "resource": "repo://Frameworks/TermindPersistence/TermindSchemaV30KnownHostModels.swift#L5-L35", - "version": "repo-lines-v1:sha256:4750546fa8333fe6fb3d2bd6b3b800c6c51e25138e64ac311a0da8c6e24158bb:eyJzZWxlY3RlZExpbmVDb3VudCI6MzEsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImY1NmVjMWMyYTlkNGE0ZjRiYjNjODhkYjNmN2ExYjQ1YWRhNTdjNWU3NzlmNjdkYzA3YWE3Mjk3NGQyYWI5MDQiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImU2YjA0NDkxNDY5NzIyZTgzNjNhOTRlNDA0NDUzOGUwMTNhMmEyMTdlMzZiMWU1NjhiYTYwYWNiN2I0YzhmMmUiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjhjZGI5NzdmYWUwMGFjYzYwYTUyYWUzNmZkNDJiNzRkMjE1ZTYyNWQzOGFhOTE0ZjdiZTFlNzdlNTk2M2EwODgiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjoyLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjM3OTRkMDJhODEyMTk0NGQxNzZkMjY1ZTliYjljYzU5ZjZhYjg5MGJjYWRmOGFlZmRlYmRiYmI0ZjJlYTg0NDQifQ" + "resource": "repo://Frameworks/TermindPersistence/TermindSchemaV30KnownHostModels.swift", + "version": "repo-file-v1:sha256:7fb9e124a2b060499c6844e10541ff2d3d26ca796eabcfae45da4ffc8255613a" } ] }, { "id": "claim_e5eace34b0c14e78bff0e37229bf9f6f", - "statement": "Persistence compatibility tests open legacy V1/V14 fixtures, verify reopen and full-schema in-memory behavior, and exercise migrations that preserve prior values while applying new defaults or removing obsolete data.", + "statement": "Persistence compatibility tests open legacy V1/V14 fixtures, verify reopen and full-schema in-memory behavior, and exercise migrations through V32 that preserve prior values while applying new defaults or removing obsolete data.", "evidence": [ { - "resource": "repo://FrameworkTests/PersistenceCompatibilityTests.swift#L22-L89", - "version": "repo-lines-v1:sha256:86e0ea6e63d5624d21afc6b082e4d62104188200b4b484e2ef92513b8a3fcf68:eyJzZWxlY3RlZExpbmVDb3VudCI6NjgsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQwY2NlMjYyMzJlNGRmZGY0MzdkMDRjNDAxN2I1MWQ5ZGY0ZGYyODVjYmFkNTI4YzcxMDY5ZTA5NGRkNjNhMTYiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjljZDY0Y2U3MWNmNThiZmMxOGRjOGNlOTc3N2RlNjVhMGU4MjcyNDc2ZDUzMDUzNDdiZDYyNmI3YjFiZTJjMmIiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImJmNjM3YTI0NzMyOGUzMTA0MWU4Y2I4MjE3NzhiYTVkNjE3ZjNlYzIyNzE2ZTgzMTdjMTU2NTU4YTk0Mjg4ZTIifQ" - }, - { - "resource": "repo://FrameworkTests/PersistenceCompatibilityTests.swift#L91-L227", - "version": "repo-lines-v1:sha256:1635e37d882f0c41f616f465bab3aac02ed1473bedeb48acb1c48f5f7b57adb8:eyJzZWxlY3RlZExpbmVDb3VudCI6MTM3LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiI0Y2YwOWViMGM1NzQ0ZmI5NzMzMWI5OGZmM2ZiZjA1YTYwMmJhM2Y3ZjY3ZTVkYTViZTkwOTUwMzNmNjI1ODI1IiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiI0ODkzZjQwZTM3ODFlYjg5NmM4MmQwNDZhMDgwNjdhMTFlMzVjZGYyZjFkZmFmMjZmZWE5MGI4ZGI2Njc2NDEzIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiI2NjJjMzkyZmU4OGM0ZjhkYjc0ZjRlNmY3MjU1ZTQwMzBkYmM4MjMwMzVmZTc2MDkwYmExZTg1NmExN2U4MzVlIiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MSwiZm9sbG93aW5nQ29udGV4dEhhc2giOiI0MTJjYTM0NWNjZjc1YmY5YzA4MDZiY2U2OTViZThkZTgwOGI3OTk4NDI1MWE3YTU0ZDIwMmNmNjEwMWRkNDUxIn0" + "resource": "repo://FrameworkTests/PersistenceCompatibilityTests.swift", + "version": "repo-file-v1:sha256:0bc3fd0f5052bbc3ae57a157adf11e614d7a228aca3b7517b2996cdd52e3b7a0" } ] }, @@ -282,7 +270,7 @@ }, { "resource": "repo://FrameworkTests/CloudSyncSafetyPersistenceTests.swift", - "version": "repo-file-v1:sha256:fe8c66a47936a9120eb3b71def8c148801e215d53fda47a326349388b30f9c1d" + "version": "repo-file-v1:sha256:46b7af81fc4ee722076d51522aca8b1c8dd5738c704da39d95034c734aaf851b" }, { "resource": "repo://FrameworkTests/CloudSyncSafetyTests.swift", @@ -295,16 +283,34 @@ { "resource": "repo://Frameworks/TermindPersistence/CloudSyncSafetyStore.swift", "version": "repo-file-v1:sha256:c028e57ece449c591c32f8ac849fda2f6a82cda00974acc87f832295bb3acc3d" + } + ] + }, + { + "id": "claim_8df49fd2e5c34c6da847b1fb49c965d7", + "statement": "Connection-history mutations and their event envelopes or tombstones commit together and retain the newest 300 deterministically; the separate device-directory repository atomically journals reusable ID-to-name DeviceRecord upserts and is not subject to event retention or History clearing, while V32 removes Host lastUsedAt but preserves distinct local Identity lastUsedAt semantics.", + "evidence": [ + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataConnectionHistoryRepository.swift", + "version": "repo-file-v1:sha256:e0468dec12004d9d8552b2f0d7745008a17b0271f20b65c1b425d9b917e0319a" }, { - "resource": "repo://Frameworks/TermindPersistence/ModelContext%2BCommit.swift", - "version": "repo-file-v1:sha256:eb6c9e743e73e3abf1dc356315ecf10a802e3eece10252e62c7eed6bafc1c10e" + "resource": "repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift", + "version": "repo-file-v1:sha256:9a3e7296fd09c4b397c90c7b15c20775a0950a145ec1c506ac431c04fc5ea020" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift", + "version": "repo-file-v1:sha256:c727e97334d7ac3d7e5a820afe328a8c1809c08d73abd9c03e91287599d66fb0" + }, + { + "resource": "repo://Frameworks/TermindPersistence/TermindSchemaV29HostModels.swift", + "version": "repo-file-v1:sha256:c1465646a13591d520a940f795376acd2bbb031b68b7726b8ebbaa13eb9d87c2" } ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T05:49:38.238Z" + "at": "2026-09-22T10:02:02.985Z" } } diff --git a/openwiki/.claims/concepts/remote-services-and-forwarding.json b/openwiki/.claims/concepts/remote-services-and-forwarding.json index 306fcaf8..64890be7 100644 --- a/openwiki/.claims/concepts/remote-services-and-forwarding.json +++ b/openwiki/.claims/concepts/remote-services-and-forwarding.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:e8db6dcac9048304c352ba94dabfb8b5085a83d1526e3ded743e49ba354dee43", + "pageVersion": "sha256:0ad48d7eea0ecc929457a1bca2df7de4bef0220eab49611c612e4fe607d8f241", "claims": [ { "id": "claim_7c677489fd8b4a4fadd006a4da4e9937", @@ -20,7 +20,7 @@ }, { "resource": "repo://Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift", - "version": "repo-file-v1:sha256:7e2865ba559d6d095a3ada2e92a1e7ee55f23275d8ae05f27a1236a0058edf68" + "version": "repo-file-v1:sha256:38a40a249644340b3f66d76952056567047ccceb9aaea49a6e4a8d6a63c7270c" } ] }, @@ -195,6 +195,6 @@ ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T03:23:14.035Z" + "at": "2026-09-22T05:59:31.338Z" } } diff --git a/openwiki/.claims/concepts/shell-integration.json b/openwiki/.claims/concepts/shell-integration.json index 687c9b06..67588d54 100644 --- a/openwiki/.claims/concepts/shell-integration.json +++ b/openwiki/.claims/concepts/shell-integration.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:e27880aa34f40deedddffbc3737c872c8b3018d93003f868cd5fa14e7ddf78e0", + "pageVersion": "sha256:7969689131767daee6522896f5d6a8237a9f4931eea0e640c17a5fa1d1c58bba", "claims": [ { "id": "claim_bc1531cd55cc445cbd6964ff7331b6c1", @@ -68,11 +68,15 @@ }, { "id": "claim_c3541976db5f4e84a583c4486ddd24e2", - "statement": "Bash, Zsh and Fish installers define session-local hooks and compose bindings, report cwd through OSC 7 and command lifecycle through token-scoped OSC 777, preserving the shell-specific hook mechanisms rather than editing startup files.", + "statement": "Bash, Zsh and Fish installers define session-local hooks and compose bindings, report cwd through OSC 7 and command lifecycle through token-scoped OSC 777, preserving shell-specific hook mechanisms rather than editing startup files; Bash fallback DEBUG handling excludes Termind helpers and brackets existing PROMPT_COMMAND work.", "evidence": [ { - "resource": "repo://Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift#L443-L463", - "version": "repo-lines-v1:sha256:fe419183706a45905e697a9a26e6b1fc4abd95021c8a4df32e1bd9d05dcdf4af:eyJzZWxlY3RlZExpbmVDb3VudCI6MjEsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImRhNDI5MzllMTZkMTk0YTI5ZmFkOGE0NWMwZjBhNDQ1MGIyNDk5NDk3MzQwYzlhZWFjOWM3MmUzNDFhODI5MjUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjUzNGQwODExMTYyODk0ZDlkMDBkOGVjODBiZWUxMjA1ODFmMjVhNzUxN2E3MTIwMTk1MzViMjdhNjFmNDAyYzkiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjNkMTdhYjYyYWVkZjI3MjI0ZGJjYmY4NTBiZjkwZGY3ZDAwMjUxODM5MzA0YzUzMzVhNWNmNTVlMzNmMGYxYmUifQ" + "resource": "repo://Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift#L443-L490", + "version": "repo-lines-v1:sha256:6f05c52e722daf8c76820d4adb5bbbc3f2f9ad87c7105766583a85b2336fd433:eyJzZWxlY3RlZExpbmVDb3VudCI6NDgsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImRhNDI5MzllMTZkMTk0YTI5ZmFkOGE0NWMwZjBhNDQ1MGIyNDk5NDk3MzQwYzlhZWFjOWM3MmUzNDFhODI5MjUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImZmZDJjZjM1MjBhMjcyNDUwMmE3ZDhiNWMzYmNkZjUwNTYyMGZlODE4MjY5M2Q4ZGU4NjYxMzZkNjg1ZDJiODQiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjUzNGQwODExMTYyODk0ZDlkMDBkOGVjODBiZWUxMjA1ODFmMjVhNzUxN2E3MTIwMTk1MzViMjdhNjFmNDAyYzkiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImUyMTljMWEyZWIwYmRlYjkwMmE4MTczZjkwYjA4MjEyYTk4YTMyMTNkOWFkYWI3ZTY0ZGE2YzY4NjY4YjI3YzMifQ" + }, + { + "resource": "repo://TFMTests/ShellIntegrationBootstrapTests.swift", + "version": "repo-file-v1:sha256:1251f7f9e903f0a58cc819516506a38a542a3516478e58afc37f072a6cd85a76" } ] }, @@ -153,6 +157,6 @@ ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T03:23:14.035Z" + "at": "2026-09-22T05:59:31.338Z" } } diff --git a/openwiki/.claims/concepts/ui-design-principles.json b/openwiki/.claims/concepts/ui-design-principles.json index 669bd84c..4d0c6bb9 100644 --- a/openwiki/.claims/concepts/ui-design-principles.json +++ b/openwiki/.claims/concepts/ui-design-principles.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:0ef048b57e0be46c11bece53519653eaa7e6e4106f7a43d19b73af10bf2d560a", + "pageVersion": "sha256:3c9dd812481bbfb7ac06fdb0073a97ba6a5d2d2966f90d7619e1c7d1d5fc6319", "claims": [ { "id": "claim_3a403468456840acbfe0a524b90d5717", @@ -173,8 +173,8 @@ "statement": "Continuity uses different retention budgets: desktop parked workspace geometry, an unmounted-owner transcript LRU, mobile app-owned workspaces with background-expiration policy, and stable children in adaptive mobile region layouts.", "evidence": [ { - "resource": "repo://TFI/App/MobileAppModel.swift#L491-L523", - "version": "repo-lines-v1:sha256:3e5f5ee2eaa48a81fe415e9bb3d77294bdb003e45542404a755bb1e569796e90:eyJzZWxlY3RlZExpbmVDb3VudCI6MzMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjExYjYwOWM3ODJiMmM2NDVhM2M2ZDFmOWMyMDA3YzdjMGVlMWI5MTI1YjJjOGIxOTU0YTFlMTVjY2UxM2VkYjMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjExMGVhYzUyYWE2NjM0MDczNjQ0MjU1MTg2YTc5NGIxMjdmYzQ5MTA2NTRmYTNhMjBiOTgzMjMxOTIxZWU2ZWEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjYwZTJhMWYwNzIwY2I4YWNhODQ4ZjIzNzM0N2E4YjUzNGY2YTEzZDVhZTBmMTQ3MDE4ODQ5ZmZjYjkxOTRlZWMifQ" + "resource": "repo://TFI/App/MobileAppModel.swift#L512-L544", + "version": "repo-lines-v1:sha256:b41d3de43fde9c50d0b478946d6263804791efe4519784713c872a1679a205dc:eyJzZWxlY3RlZExpbmVDb3VudCI6MzMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjExYjYwOWM3ODJiMmM2NDVhM2M2ZDFmOWMyMDA3YzdjMGVlMWI5MTI1YjJjOGIxOTU0YTFlMTVjY2UxM2VkYjMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImU2YjA0NDkxNDY5NzIyZTgzNjNhOTRlNDA0NDUzOGUwMTNhMmEyMTdlMzZiMWU1NjhiYTYwYWNiN2I0YzhmMmUiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjExMGVhYzUyYWE2NjM0MDczNjQ0MjU1MTg2YTc5NGIxMjdmYzQ5MTA2NTRmYTNhMjBiOTgzMjMxOTIxZWU2ZWEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImI0NTg0ZDFiMmZhZDA3YjA0ZTIwZTVjOGE2MTM3OGEzNDFjZGJlN2E4ZDgwMzAwZDQ2NzJlNjhhYjcxZTFjZjMifQ" }, { "resource": "repo://TFI/DesignSystem/MobileContentRegions.swift#L138-L163", @@ -193,6 +193,6 @@ ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T03:23:14.035Z" + "at": "2026-09-22T09:51:39.958Z" } } diff --git a/openwiki/.claims/integrations/mcp-server-and-bridge.json b/openwiki/.claims/integrations/mcp-server-and-bridge.json index 1fc89a89..dc1e12c0 100644 --- a/openwiki/.claims/integrations/mcp-server-and-bridge.json +++ b/openwiki/.claims/integrations/mcp-server-and-bridge.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:0dd44becda4b103f8f913c5fb5293ffc1b0a87287a43708514270a266cafd70a", + "pageVersion": "sha256:a43999af9e328429d4b484cd413c52e5db3fd5c0ad110ae4fb0a86922769d48a", "claims": [ { "id": "claim_b47979774aa94e7dae517a6894c508d4", @@ -8,19 +8,19 @@ "evidence": [ { "resource": "repo://TFI/App/MobileAppModel.swift", - "version": "repo-file-v1:sha256:f84584e85c49f80b312450980f1a741b62d7816892c3a0574edf5312e8fa4adc" + "version": "repo-file-v1:sha256:bd9d3a8a5ecd863ee34c85a8266effff511cce6da6a00b101e28b59501c159ce" }, { - "resource": "repo://TFM/App/MCPServerController.swift#L68-L89", - "version": "repo-lines-v1:sha256:2b418ed8ee4e50e58cae8abc2517708b7cc9df75f646a77fecbc91788e23f7fe:eyJzZWxlY3RlZExpbmVDb3VudCI6MjIsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjU1YTgyM2UzZTMyYjI3MjM5YjhjMmE4ZWJkYTRmYmViNWM4ZDZmN2I3MmI2MmM3NjY5MzE2MzJkM2M5OGRiMDUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjA5OGM5Y2I5ZmFjNDVhN2JjNTY3NGZiNGRhYjhjYWE4YjAwMmE0M2U5ZDUwMGFkNjFiYzI0ZjE3MjcwYmQ4NTEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6Ijk5M2EwMzlmOTVjZWNhZjJlYzIwMjc2ZTY2YThiYWUyN2UwZWZlOTQ1NzQzYjgzYjZlNjRmMjFiZjUyYWQ2NTgifQ" + "resource": "repo://TFM/App/MCPServerController.swift", + "version": "repo-file-v1:sha256:7374829e1807503b67c654c88edc8dba34bbd50a8499c6beec23170a73745c99" }, { - "resource": "repo://TFM/Infrastructure/Networking/LoopbackHTTPServer.swift#L99-L141", - "version": "repo-lines-v1:sha256:72c450807cefd172dec598041b65c8223bb27e73804e3b786cb06fa9779acd32:eyJzZWxlY3RlZExpbmVDb3VudCI6NDMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjE2NWVkOTY2ZmY0ZjE5YWQxMWExOWU0MDZmMzNjZDZmNzBjZmU1ZDAzZDRiNTAyZjM4NjJiOTc5OGQ4OGEwOTMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjU1MjJjNDJjZDE5YzZkMDNkODA4YzQ1MjcyMDU1MjgyNWI3ZWExYzViNDkxYjIxODUxMWNhYzc0MTVjZjlkOWIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImY3NWVkMDUwMmI1NWMxMGUwZmFiOTNlZDBjMmI2NWU4NzY0OGMxMGViMjczYWJlMTM5YzkyNThmODgwNzI3NjUiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjRhOWMyOTVjM2JhMjIwNDNhZWM5OWE0YzUyYjYyM2YyNDkxZjY4YzFhNTc4NjUwM2VlYmJkNmU5OGI3ZDYyOTAifQ" + "resource": "repo://TFM/Infrastructure/Networking/LoopbackHTTPServer.swift", + "version": "repo-file-v1:sha256:5a91a4b29083f71858834b4b1adee9fb64551de704e850ebe8026926ff9a6b9b" }, { - "resource": "repo://TFMTests/LoopbackHTTPServerTests.swift#L24-L28", - "version": "repo-lines-v1:sha256:d6f3744573638c3ecbc1208feaf69c76d8144774b3c19128eeb01cbc0ca9a391:eyJzZWxlY3RlZExpbmVDb3VudCI6NSwiZmlyc3RTZWxlY3RlZExpbmVIYXNoIjoiNGNmMDllYjBjNTc0NGZiOTczMzFiOThmZjNmYmYwNWE2MDJiYTNmN2Y2N2U1ZGE1YmU5MDk1MDMzZjYyNTgyNSIsImxhc3RTZWxlY3RlZExpbmVIYXNoIjoiNDg5M2Y0MGUzNzgxZWI4OTZjODJkMDQ2YTA4MDY3YTExZTM1Y2RmMmYxZGZhZjI2ZmVhOTBiOGRiNjY3NjQxMyIsInByZWNlZGluZ0NvbnRleHRMaW5lQ291bnQiOjMsInByZWNlZGluZ0NvbnRleHRIYXNoIjoiOWVkNjQ1MDk5YWIxNGY0OTYxNmQ2NjRlYWE1NGNhYTRiODFjM2NiNWFkZTE0Y2E4NTQxZGEwM2NmMDMwMmU0OSIsImZvbGxvd2luZ0NvbnRleHRMaW5lQ291bnQiOjMsImZvbGxvd2luZ0NvbnRleHRIYXNoIjoiN2EyZjkzYWJkODYzNTI5MjBmYzFiOWNkOTJiZmU2MGJhZjllNjgzMjI4N2RjNzcxYjc1MTgwZDM3NmNjZmI2YyJ9" + "resource": "repo://TFMTests/LoopbackHTTPServerTests.swift", + "version": "repo-file-v1:sha256:6368181157868cd99acca34b80e89c5ce7590f64593a0ac9d56cc6d5949048e5" } ] }, @@ -293,6 +293,6 @@ ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T03:23:14.035Z" + "at": "2026-09-22T09:51:39.958Z" } } diff --git a/openwiki/.claims/operations/cloudkit-release-readiness.json b/openwiki/.claims/operations/cloudkit-release-readiness.json index cfcaf314..0b06ca8b 100644 --- a/openwiki/.claims/operations/cloudkit-release-readiness.json +++ b/openwiki/.claims/operations/cloudkit-release-readiness.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:bce6dd2e986f7546e50a3b4acdc5b4a482f438c4763e19ffdb999ba9b26d3b5e", + "pageVersion": "sha256:acb6af63437610432dc40d7788ee89b512c3d0687c32b3088fe72c176c5a6bfd", "claims": [ { "id": "claim_bca4a6f8574343d5b8310421bbdd595d", @@ -108,35 +108,19 @@ }, { "id": "claim_e195235702f84b7188836027fefa15f1", - "statement": "The first compatibility safety layer adds local V31 quarantine/recovery entities without changing the existing category zones or V1 CloudKit envelope formats.", + "statement": "The compatibility safety layer uses local V31 quarantine/recovery entities, and V32 connection-history plus generic device-directory payload additions continue using the existing category zones, VaultItem record type, and V1 CloudKit envelope format.", "evidence": [ - { - "resource": "repo://Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift", - "version": "repo-file-v1:sha256:f6da8659fad8979740ca0fea736a7ca6be9a4ecee8fb07946018949693eefb22" - }, - { - "resource": "repo://Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift", - "version": "repo-file-v1:sha256:58849f2ece4cc28631048cd41cd1107edb4715d3c52283b29cee706ddccb3818" - }, { "resource": "repo://Frameworks/TermindDomain/Vault/VaultSyncTypes.swift", - "version": "repo-file-v1:sha256:3521d0abdf1ad1819522a371f30850dd5c392e958bfd6efb9dfa8bb98425f036" + "version": "repo-file-v1:sha256:5186b2e30bb07fa6c53dd4def6a5cfba285e103690004e7767bbd40f9f3fe18c" }, { - "resource": "repo://Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift#L8-L35", - "version": "repo-lines-v1:sha256:2cff6d5af9152b94d09ea9d49b379118ddc8faaea976498f6ced9dfce85c8e33:eyJzZWxlY3RlZExpbmVDb3VudCI6MjgsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjYxZGI5YzNkNzg0ZGI1YWRjNDAzNzM5NzJkNGFhZDQ2NzVhYTVmOGYxYmNlNzc2NDgyM2NjZjU2ZTNkZGE5ZWYiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImE5YTBlZDY1YzFiNDA2MDdlZjlkNjlkZTZhM2UyMmU4MDkyY2Y5ZWVlNDhjODQwZGY3YWQ4N2JjZDczOGNmZWQiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjNhNDEzOTNjODU0MWVjYzQwZTdjMzZiM2Y2MmQ0YTFmMDY5MWRmOTI3NzZkM2E1YzRhYWU2OWQ4OWFhYTZhOWIiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjNkMjA3ZTk0N2E5MzBlOTQxMjYxODkwNGIzNzBhOGYyYzM5Yzk3ZDY2ZjJjYTVmZGJhMWE4Njg2MTZmNDYxZjUifQ" + "resource": "repo://Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift", + "version": "repo-file-v1:sha256:c8a0385467103bef43d7a2d3bbf334701870656e54e06a960eb49ddbafbdb667" }, { - "resource": "repo://Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift#L8-L47", - "version": "repo-lines-v1:sha256:6a5d73baefcfb01c124535605a32b754d8384c96d82b74e41868a4a48796be6f:eyJzZWxlY3RlZExpbmVDb3VudCI6NDAsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjJlNDhhMTRlZDczN2IwNzhiZDNkZWM5ODJlYzE2NmIxZWM2ODMyZmM2MGMyYmIzZjA1M2IzMWYxMjdlZDUxNzAiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImExYTNjYmZjYmE3ZGRiYjZkMDNlY2EwNmU0ZGM1NTYzZmUwMDFiZDJiYTFiM2UxYzY4YzNiYmM3YjRiYWI0MGEiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjNhNDEzOTNjODU0MWVjYzQwZTdjMzZiM2Y2MmQ0YTFmMDY5MWRmOTI3NzZkM2E1YzRhYWU2OWQ4OWFhYTZhOWIiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjJlYjQyODNjMzZhOWQ2NWRkMzMzN2NkZmIwMTE3M2M0ZjhiNGUzNDZlNGY5ZTMwY2U4ZDlkMDk4OTg3YWFhN2EifQ" - }, - { - "resource": "repo://Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift#L7-L38", - "version": "repo-lines-v1:sha256:9b6580d507dd73723bcc4d7d617dbada5f461e81ceaf4c5f36d50b1f24d1deab:eyJzZWxlY3RlZExpbmVDb3VudCI6MzIsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjAxYmE0NzE5YzgwYjZmZTkxMWIwOTFhN2MwNTEyNGI2NGVlZWNlOTY0ZTA5YzA1OGVmOGY5ODA1ZGFjYTU0NmIiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImQzOGUyMDA0NWU0OTlmMDI0ODI1NDE4OWIwZGZjYjdmYmFiNzNlMzhmZDdmY2RiYzNiZDllYTYzZTczZmQ1MzgiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjViN2UyZjcwMjk4NGM5OTNlNzcwOGRlZmJlYmE3N2VkNDUwOTJiNGE1M2UwZmQzYjRjNDMyOTE3ZDc5ZTcwYWIiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImFiNzkwNzdlNmI3NGY4NWQwN2U2ZjdkN2I4ZDhmOWQ0NGRmNDMzNDQ5MmE4OTk1MzRkMzc2YWNiYWNlYWI5MDUifQ" - }, - { - "resource": "repo://Frameworks/TermindPersistence/HostPersistence.swift#L769-L791", - "version": "repo-lines-v1:sha256:12dfe89390d935ca5262da4398080e1ad2a93bf92386883f3212c281aee22008:eyJzZWxlY3RlZExpbmVDb3VudCI6MjMsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjFlZDM0NzdlZDA1NjVjZTMyMjU4MzI5MmFlZDlhYzljYWI3MGM1NWQ5ZTQwYjQxM2MyM2NlYTE0ZTFmZmY4MzUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjJhNmFkMmJjZWI3ZDViYTc4MmIyZmVjOGIxZWUzN2Y1MzhhNzAzZDhlZWUzMTA3NmY2YmE1NWViOTg0OGYyM2MiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjYyZjBlYTc4MzM2NDUzNzYxZWYwMzk2ODA0NjBjOGU0YWQ4MGU3MDI0YmY4YWEzNGRjZWUxYTI1ZTY2NTlhZDIiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjNlZjQ2YjVlZWQ3MzFmODlkNTAwOTJhNTc2MDBkYjk0MTM0OThmYzEwZTA2ZTlkN2QyMWZlMTliMjU2ZWUzNDMifQ" + "resource": "repo://Frameworks/TermindPersistence/HostPersistence.swift", + "version": "repo-file-v1:sha256:4e71ea8935acb91019318067495b9123e1540ea7c74c407f49ed804648ecb77d" } ] }, @@ -154,7 +138,7 @@ }, { "resource": "repo://FrameworkTests/CloudSyncSafetyPersistenceTests.swift", - "version": "repo-file-v1:sha256:fe8c66a47936a9120eb3b71def8c148801e215d53fda47a326349388b30f9c1d" + "version": "repo-file-v1:sha256:46b7af81fc4ee722076d51522aca8b1c8dd5738c704da39d95034c734aaf851b" }, { "resource": "repo://FrameworkTests/CloudSyncSafetyTests.swift", @@ -187,10 +171,28 @@ "version": "repo-file-v1:sha256:39095bc585820f77c2b1893c684c2a81cfed675ae5f406fbe996bdbde4cdf2fe" } ] + }, + { + "id": "claim_f279371635eb47f6af4ea58a4b6f3bb8", + "statement": "Connection events and reusable device-name records use separate connectionHistory and device payload kinds in the existing VaultItem schema; release validation must cover event end updates, tombstones and newest-300 convergence as well as device-rename propagation and independence from history retention or clearing on signed devices.", + "evidence": [ + { + "resource": "repo://Frameworks/TermindDomain/Vault/VaultSyncTypes.swift", + "version": "repo-file-v1:sha256:5186b2e30bb07fa6c53dd4def6a5cfba285e103690004e7767bbd40f9f3fe18c" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift", + "version": "repo-file-v1:sha256:c727e97334d7ac3d7e5a820afe328a8c1809c08d73abd9c03e91287599d66fb0" + }, + { + "resource": "repo://TFMTests/VaultSyncTests.swift", + "version": "repo-file-v1:sha256:5098275a86373a8299364cb1f487ac69b07a22a712da9644bd7dc86bd76b0941" + } + ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T05:22:55.650Z" + "at": "2026-09-22T09:51:39.958Z" } } diff --git a/openwiki/.claims/operations/marketing-assets-and-app-icons.json b/openwiki/.claims/operations/marketing-assets-and-app-icons.json index 9c3f8923..bdfadd72 100644 --- a/openwiki/.claims/operations/marketing-assets-and-app-icons.json +++ b/openwiki/.claims/operations/marketing-assets-and-app-icons.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:e6a8ef81cbf7d5ffb6373e73605d753866c3eebed9436ce6cca2345caa77b629", + "pageVersion": "sha256:6b1311e49539498e542011db47be6e7f45947438b9f3f71d33c129340e76050a", "claims": [ { "id": "claim_bf6e7f1d333040099a25edc9d0d96b84", @@ -132,7 +132,7 @@ "evidence": [ { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" } ] }, @@ -153,6 +153,6 @@ ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T03:23:14.035Z" + "at": "2026-09-22T10:02:02.985Z" } } diff --git a/openwiki/.claims/operations/port-forwarding-benchmarks.json b/openwiki/.claims/operations/port-forwarding-benchmarks.json index 8999488d..b4c95c16 100644 --- a/openwiki/.claims/operations/port-forwarding-benchmarks.json +++ b/openwiki/.claims/operations/port-forwarding-benchmarks.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:7b9983afed4d964330e5d0357dbd1f4e32b5d0c9bbfd5877f550fd42b994924b", + "pageVersion": "sha256:eb2a96781d38300f76e05d6857eca063afedce152263ef8cae2d940364c64c54", "claims": [ { "id": "claim_9f166728e8ab4376a5da23e8b49d166d", @@ -12,7 +12,7 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" } ] }, @@ -67,6 +67,6 @@ ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T03:23:14.035Z" + "at": "2026-09-22T10:02:02.985Z" } } diff --git a/openwiki/.claims/quickstart.json b/openwiki/.claims/quickstart.json index 0a02eab4..876da28b 100644 --- a/openwiki/.claims/quickstart.json +++ b/openwiki/.claims/quickstart.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:214b58c099e361b83e4b7b8554a076e76bfe335f8fb73225bd6bb33c8c6bca26", + "pageVersion": "sha256:f7261be8dcaff5ed990cd93a09fadd3f4b87b2a51c94e0c16a09efce6cf12bb0", "claims": [ { "id": "claim_83870f4f1ad6403ea0d8bd8775729a76", @@ -16,39 +16,39 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" } ] }, { "id": "claim_f82e18c98f0b4638a0f5284b0a2bee24", - "statement": "A safe change must preserve the lifetime split: durable app services are app-wide, desktop navigation/workspaces are window-local, mobile navigation is scene-local, and live transports belong to the workspace or session that opened them.", + "statement": "A safe change must preserve the lifetime split: durable app services are app-wide, desktop navigation/workspaces are window-local, mobile navigation is scene-local, and live transports plus connection-history end events belong to the workspace or session that opened them.", "evidence": [ { "resource": "repo://TFI/App/MobileRootView.swift", "version": "repo-file-v1:sha256:76606b826613d31aedf1d7f1be21ff60e59bf09c36dd2c18ec59b24fe705441e" }, { - "resource": "repo://TFI/App/TermindIOSApp.swift#L19-L33", - "version": "repo-lines-v1:sha256:78cdd316a915930837af24416a90eddddd5619a5c74f1c6ea36d13500cec5f90:eyJzZWxlY3RlZExpbmVDb3VudCI6MTUsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImNmODI0YzlmYjRjY2M1MWYzMjAwNzJiNmNjNzQ2YWVkZWU2MmQ3YmNhZGY5NDI4MmJjMjdjZjNkMjc5ZWQ4YTEiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6Ijk5OWJkZWZlZWFjYTYzNDBlMDI5MGRjODZlODg5Yzg5ZDVlMTg5YWE1YmE0NGI1ODViM2I3MDczNGY0YTYyZDgiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImQwOWRlZDA1YTMwZTBiMDk1ZjI3NWRkNTcwMzRmNzU2NTgzNjBjOWQ4ZWIyNTg3NjNjNGI0MDMwYmM5ZGRjYjEifQ" + "resource": "repo://TFI/App/TermindIOSApp.swift", + "version": "repo-file-v1:sha256:9daf1c0933fdbcff826165a8edc171876c54faa343a184348284e63f736e86bf" }, { - "resource": "repo://TFM/App/TermindApp.swift#L155-L190", - "version": "repo-lines-v1:sha256:1f72d15f0dcd1608fbb34ebbfc5845f177dafab50c5667294536a17e3cd7f0f6:eyJzZWxlY3RlZExpbmVDb3VudCI6MzYsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjcwNTgzMDJjZDFmMDQ4ZTE3MzU1MTUxODA0NzRjYmI4M2YwZjhkZDNmN2I3ZjUyODI4ZDU1ZmUxYjg1MmIxOWEiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjQ0MTA0N2ZkYjNiOWIzMTYwZTU0NWEwOThlOGJhY2E1ODA1YTJlZmFjNGI3YWRkNmYxM2IyNDg4NWJjNmY4ZGIiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjdjZGEyMmE4ZWYyODNjOGFmOTJjZDlmMmY2ZTA4MzdkODg4MjdjYjBkZGM2OWNlNDFkMTAwZDFiOTdjYWM5MmMifQ" + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" }, { "resource": "repo://TFM/Features/Workspaces/WindowModel.swift", - "version": "repo-file-v1:sha256:99ad7526d51924fd3797629c0d56417704fcd49b6bc416e924c13d6283cf80d1" + "version": "repo-file-v1:sha256:005db3b60e96b760cf9e6ebed4cfdd0477f4c15db69205924901e47103c9949a" } ] }, { "id": "claim_883b0f81c5004c90949c5300c242dce2", - "statement": "`TermindApp` is the macOS composition root: it creates the SwiftData container and app-wide services, uses in-memory storage for tests or screenshot demos, and fails startup rather than silently replacing an unavailable local database.", + "statement": "TermindApp is the macOS composition root: it creates the SwiftData container and app-wide services, uses in-memory storage for tests or screenshot demos, and fails startup rather than silently replacing an unavailable local database.", "evidence": [ { - "resource": "repo://TFM/App/TermindApp.swift#L21-L139", - "version": "repo-lines-v1:sha256:d7c034ab508974511acbdcd459b5cb2869b11ad59663723ae8f36b41cf39cdd7:eyJzZWxlY3RlZExpbmVDb3VudCI6MTE5LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiI4YmNjNjExYWYyM2E5MWMzYjcyN2QwYWNlNGRlMjIyNDJmYzQ5MDZjNzE5NGE2MzQ5MTliNjVkNzAzZGQwMGIwIiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiJlNmIwNDQ5MTQ2OTcyMmU4MzYzYTk0ZTQwNDQ1MzhlMDEzYTJhMjE3ZTM2YjFlNTY4YmE2MGFjYjdiNGM4ZjJlIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiI5OGVhYmQ5MmNkYjZlMGVhMmViNTIxZDAzNGY0MjllZmZiOTlkYWMxMjM4NzI3MzJlZjhlODU1MTMzN2Q5ZTdmIiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiJlYThjMTliZTVkODNlNWE4Njc3NjVlN2JmYmZkODdhMDY0MGJiNGM5OThiODYxMjFjZTc3ZTVkYjE3NTA0MDExIn0" + "resource": "repo://TFM/App/TermindApp.swift", + "version": "repo-file-v1:sha256:38cd92e23d3ede988a474a1bc7ce819702e822216227cc5cb253ce7c1790b3d8" } ] }, @@ -94,11 +94,11 @@ }, { "resource": "repo://Termind.xcodeproj/project.pbxproj", - "version": "repo-file-v1:sha256:7fd62e9ff166991e2fd7fc844e3b6b2b91905af211a8fe1fb910608731440746" + "version": "repo-file-v1:sha256:a4bc24344483a6c4041dcb6a6379abd33f1af97231f811c92422e13e956153fa" }, { "resource": "repo://Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", - "version": "repo-file-v1:sha256:02109cb8656598ce20a6c8f9a527e97630ffd1b5f00ea9536425abc0417187fd" + "version": "repo-file-v1:sha256:4a5a5f330b29137020004d3059eaf48e080a45eea8460e1a74fa434565200e13" } ] }, @@ -129,6 +129,6 @@ ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T05:22:55.650Z" + "at": "2026-09-22T10:02:02.985Z" } } diff --git a/openwiki/.claims/testing/behavioral-boundaries-and-fixtures.json b/openwiki/.claims/testing/behavioral-boundaries-and-fixtures.json index 9852d6fe..e0b2b07a 100644 --- a/openwiki/.claims/testing/behavioral-boundaries-and-fixtures.json +++ b/openwiki/.claims/testing/behavioral-boundaries-and-fixtures.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:7af6d060ce1acf8f2c7a21e69ae27cfccf8932ab35f6f892944db9a5ffe45f69", + "pageVersion": "sha256:4d35b994301024766ef57f8f9efd46cc6b838886c341a3c18e9bec652aae0af7", "claims": [ { "id": "claim_b7fbd64158554b8b8ca80a0a613b3bc3", @@ -150,15 +150,11 @@ }, { "id": "claim_8d0709b664d94ebb8abcf7d7a20a7418", - "statement": "Persistence compatibility tests open historical SwiftData fixtures through the shared container factory, assert retained records and migration outcomes, and verify independent in-memory containers expose the full schema.", + "statement": "Persistence compatibility tests open historical SwiftData fixtures through the shared container factory, assert retained records and migration outcomes through V32, and verify independent in-memory containers expose the full schema.", "evidence": [ { - "resource": "repo://FrameworkTests/PersistenceCompatibilityTests.swift#L22-L89", - "version": "repo-lines-v1:sha256:86e0ea6e63d5624d21afc6b082e4d62104188200b4b484e2ef92513b8a3fcf68:eyJzZWxlY3RlZExpbmVDb3VudCI6NjgsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQwY2NlMjYyMzJlNGRmZGY0MzdkMDRjNDAxN2I1MWQ5ZGY0ZGYyODVjYmFkNTI4YzcxMDY5ZTA5NGRkNjNhMTYiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjljZDY0Y2U3MWNmNThiZmMxOGRjOGNlOTc3N2RlNjVhMGU4MjcyNDc2ZDUzMDUzNDdiZDYyNmI3YjFiZTJjMmIiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImJmNjM3YTI0NzMyOGUzMTA0MWU4Y2I4MjE3NzhiYTVkNjE3ZjNlYzIyNzE2ZTgzMTdjMTU2NTU4YTk0Mjg4ZTIifQ" - }, - { - "resource": "repo://FrameworkTests/PersistenceCompatibilityTests.swift#L92-L175", - "version": "repo-lines-v1:sha256:350be9cb654f9d715e6fbcb7c7542f0ac949ed2b97a8573811e5a3219a3ca810:eyJzZWxlY3RlZExpbmVDb3VudCI6ODQsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjI0MTI4ZTcwN2VhNWRkMTgxNDgzNzY5MTdiMzFiODgxZjcwYjdmYTlmN2VmM2U3NWI3Y2U1ZmU1MTRmNWRlNDMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ4OTNmNDBlMzc4MWViODk2YzgyZDA0NmEwODA2N2ExMWUzNWNkZjJmMWRmYWYyNmZlYTkwYjhkYjY2NzY0MTMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6ImU4ZWFmM2UxZGIyODRmNmE2ZmQ0ZTA2NmFlMmZiNjU0YWU2OGZkZmNmM2U3ZTRkY2E0YzJkZDIxNGE0OWI5ZDAiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjI3YzZkZjQxNTJiNzdkYWM0ODJmZmQ5M2Y3NWUyYTA0MTJkZTk5NDVhNWFiMmI3ZDY5ZDc2YjAyZGQzNTRiMmYifQ" + "resource": "repo://FrameworkTests/PersistenceCompatibilityTests.swift", + "version": "repo-file-v1:sha256:0bc3fd0f5052bbc3ae57a157adf11e614d7a228aca3b7517b2996cdd52e3b7a0" } ] }, @@ -214,13 +210,39 @@ }, { "resource": "repo://FrameworkTests/CloudSyncSafetyPersistenceTests.swift", - "version": "repo-file-v1:sha256:fe8c66a47936a9120eb3b71def8c148801e215d53fda47a326349388b30f9c1d" + "version": "repo-file-v1:sha256:46b7af81fc4ee722076d51522aca8b1c8dd5738c704da39d95034c734aaf851b" + } + ] + }, + { + "id": "claim_fd10d6f20c88477691425ceeb1241a6c", + "statement": "Focused connection-history tests cover V31-to-V32 migration, the independent generic device record and rename propagation, synchronized event lifecycle/tombstones, stable local-device filtering, newest-300 storage, 100-event presentation, preferred-protocol quick actions and multilingual mobile rendering while excluding Serial.", + "evidence": [ + { + "resource": "repo://FrameworkTests/PersistenceCompatibilityTests.swift", + "version": "repo-file-v1:sha256:0bc3fd0f5052bbc3ae57a157adf11e614d7a228aca3b7517b2996cdd52e3b7a0" + }, + { + "resource": "repo://TFITests/MobileCardRenderingTests.swift", + "version": "repo-file-v1:sha256:407d0032835320626af2fa6ba04922430189a6d82855d394c0f1bfcc02cb1147" + }, + { + "resource": "repo://TFITests/MobileConnectionPickerTests.swift", + "version": "repo-file-v1:sha256:130622c416a1e6180c210594ac540fd1213991276c062a4efcba65905daa537e" + }, + { + "resource": "repo://TFMTests/ConnectionHistoryTests.swift", + "version": "repo-file-v1:sha256:ad26db9ba0c341ffb62052b57f42f82fd1296db1d81a17fedf795752e247e019" + }, + { + "resource": "repo://TFMTests/VaultSyncTests.swift", + "version": "repo-file-v1:sha256:5098275a86373a8299364cb1f487ac69b07a22a712da9644bd7dc86bd76b0941" } ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T05:22:55.650Z" + "at": "2026-09-22T09:51:39.958Z" } } diff --git a/openwiki/.claims/workflows/settings-and-cloud-sync.json b/openwiki/.claims/workflows/settings-and-cloud-sync.json index 9315575f..82d5fe21 100644 --- a/openwiki/.claims/workflows/settings-and-cloud-sync.json +++ b/openwiki/.claims/workflows/settings-and-cloud-sync.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "pageVersion": "sha256:6f79c9be2a3114480fd65a8ef8af40f5fd3076ed721281e826cd4fbbf11ddcbf", + "pageVersion": "sha256:4c76e612db1b0e6ac951727a79725e55ece21dac8f56cf59871859bbb2e8edb1", "claims": [ { "id": "claim_dc6281a3ccb7415ea961d1a954248647", @@ -102,23 +102,19 @@ "evidence": [ { "resource": "repo://FrameworkTests/CloudSyncSafetyPersistenceTests.swift", - "version": "repo-file-v1:sha256:fe8c66a47936a9120eb3b71def8c148801e215d53fda47a326349388b30f9c1d" + "version": "repo-file-v1:sha256:46b7af81fc4ee722076d51522aca8b1c8dd5738c704da39d95034c734aaf851b" }, { - "resource": "repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift#L195-L280", - "version": "repo-lines-v1:sha256:7a3f070eee08492af341ea45b1d42562df2820085721adfeb409d9bf5a1adf4a:eyJzZWxlY3RlZExpbmVDb3VudCI6ODYsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImQ4ZjY4OWI0YjBhZGE0NTE4MTY0NjEyZDg2OGMwMTc0MjE1NDNlNTI0MjJmNjE4NTQxMTg0NDk5MmE0YWJiNjUiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjQ2NmYyODA5M2E3ZDVmNzRjNDlkYjc3ZjY0NGVjZWZiYmFkMmI4OTk4YmZjYTU1Zjg3MGRmZjliNzYwOTk4NzMiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjExNmEwZjUwNmQzNTQyODQxN2FiMDc3OGQ1MzBkMjdhNTk5ZjQ3NThmYzBjN2NhMTYxYWIwMjA3NjA4ZTVkOWEiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6ImJhMzc3ZmQ0N2U2MzAxMWU0OWU4NzQxYjZkNmRjYTg1OGIzMzM2OTY3ZmUwYjBjNDRmYmNmNTRjOGNmYjcwOGYifQ" - }, - { - "resource": "repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift#L435-L490", - "version": "repo-lines-v1:sha256:068788c6da350e18f9abd632d6c4c8c00136363fd04673649637a07db84c022a:eyJzZWxlY3RlZExpbmVDb3VudCI6NTYsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6IjgzN2IzMDhlZWM0OTJlNzU2MzNlYjIyY2VlY2Y1ZGQ0MTQ3MDdhODJiYWQ2NTg1YzlmNjI1ODMxMDY3NzhiNmMiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6IjAxYmE0NzE5YzgwYjZmZTkxMWIwOTFhN2MwNTEyNGI2NGVlZWNlOTY0ZTA5YzA1OGVmOGY5ODA1ZGFjYTU0NmIiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6Ijk5OWJkZWZlZWFjYTYzNDBlMDI5MGRjODZlODg5Yzg5ZDVlMTg5YWE1YmE0NGI1ODViM2I3MDczNGY0YTYyZDgiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjFlMDNlMTk2NTg3OGNiN2ExOTQ4Y2Q5MzBlYTdkNTg1NzdjMDU2OTlmY2UyODQwYTlmZDMxYmYyNGFhYmFhMDcifQ" + "resource": "repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift", + "version": "repo-file-v1:sha256:2c8baddd408651cfaaec231be95b0af95c4b93b8e264d1733cbffbba7bad48f7" }, { "resource": "repo://Frameworks/TermindPersistence/CloudSyncSafetyStore.swift", "version": "repo-file-v1:sha256:c028e57ece449c591c32f8ac849fda2f6a82cda00974acc87f832295bb3acc3d" }, { - "resource": "repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift#L391-L432", - "version": "repo-lines-v1:sha256:e8259d7744aa274446f61f09639edc61c5ff08dcf481c298f0ffab4c54fcbecc:eyJzZWxlY3RlZExpbmVDb3VudCI6NDIsImZpcnN0U2VsZWN0ZWRMaW5lSGFzaCI6ImU4ZmE1ZDNkMmQ5NDU3MDM1OTZhMmRmMGNkYWRhYzY4N2IyYmE4OWI5MDI5N2MzOThiYzY0MGI2MDdmZWRlZWIiLCJsYXN0U2VsZWN0ZWRMaW5lSGFzaCI6ImNhYzc3MzU0ZjUwMWQzODI5ZjFhYmZhMDY3OTE1OWJmY2NkMzcwYzQ3ZmIzM2E0MjU4NWJlOGE4NWE5NThjN2MiLCJwcmVjZWRpbmdDb250ZXh0TGluZUNvdW50IjozLCJwcmVjZWRpbmdDb250ZXh0SGFzaCI6IjE3ZjQzNWMyOWUxZGI3MTM5MzBmMWNlZGU0YmRmMzNmZDM1Y2E1ZjkwZWYwM2M1NWEzMDkwMzAyNGQ1ZDJmNTYiLCJmb2xsb3dpbmdDb250ZXh0TGluZUNvdW50IjozLCJmb2xsb3dpbmdDb250ZXh0SGFzaCI6IjZkMWYxMDliNjM1MzY0M2YxMDUzYjQ0M2MxYTA4ZjRjZDdjMzMzNmQ0OTgzMjg0M2E5ZTIwODZkOGRiMzc0ZTQifQ" + "resource": "repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift", + "version": "repo-file-v1:sha256:c727e97334d7ac3d7e5a820afe328a8c1809c08d73abd9c03e91287599d66fb0" } ] }, @@ -248,7 +244,7 @@ }, { "id": "claim_c0648936d39d4e64815cde3d07152360", - "statement": "V31 safety storage scopes restrictions and recovery by container, environment, account, zone and owner. A new or interrupted scope requires a fresh cursor while preserving local edits; a recovery marker is saved before automatically syncing engine creation or network work and remains across overlapping operations until safe completion.", + "statement": "V31 safety storage scopes restrictions and recovery by container, environment, account, zone and owner. A new or interrupted scope requires a fresh cursor while preserving local edits; recovery is saved before engine/network work and remains until safe completion.", "evidence": [ { "resource": "repo://FrameworkTests/CloudSyncLifecycleTests.swift", @@ -256,19 +252,15 @@ }, { "resource": "repo://FrameworkTests/CloudSyncSafetyPersistenceTests.swift", - "version": "repo-file-v1:sha256:fe8c66a47936a9120eb3b71def8c148801e215d53fda47a326349388b30f9c1d" + "version": "repo-file-v1:sha256:46b7af81fc4ee722076d51522aca8b1c8dd5738c704da39d95034c734aaf851b" }, { "resource": "repo://Frameworks/TermindDomain/Vault/CloudSyncSafety.swift", "version": "repo-file-v1:sha256:a66b5df76f386d4370e6b7df14cf7b00942b48c03bccebbed010343a30667f7f" }, { - "resource": "repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift#L239-L366", - "version": "repo-lines-v1:sha256:7dd72645fc1f16350eb03d24b975826bd96d4f764fdd97d949f50c5b7805105b:eyJzZWxlY3RlZExpbmVDb3VudCI6MTI4LCJmaXJzdFNlbGVjdGVkTGluZUhhc2giOiI3ZmY2Y2Q1NDM0ZjdhYzkwMmUxNDJiOWNmYjdmNDJkN2ZlOWM5YWE1MWU1YzZiZjM4OWQwNTdhOWZiZjQ3NzU5IiwibGFzdFNlbGVjdGVkTGluZUhhc2giOiJhNzEyYzJhMTYyN2ZlZTFhMDM1YmE1ZjI2OGU5NzNiMWEzMDczOTYzYTg2Y2NhMDUxZjU3MWMzNWRkMzU0MmMxIiwicHJlY2VkaW5nQ29udGV4dExpbmVDb3VudCI6MywicHJlY2VkaW5nQ29udGV4dEhhc2giOiI5OTliZGVmZWVhY2E2MzQwZTAyOTBkYzg2ZTg4OWM4OWQ1ZTE4OWFhNWJhNDRiNTg1YjNiNzA3MzRmNGE2MmQ4IiwiZm9sbG93aW5nQ29udGV4dExpbmVDb3VudCI6MywiZm9sbG93aW5nQ29udGV4dEhhc2giOiIwZTcwZWY0MGNjYTYyYmE3MTM2OWFiMmMzNWZlMTRkZmJhZDEyZDdiZTcxNDFjMjUwMjcwNzMxNDBjYTI4MmRkIn0" - }, - { - "resource": "repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncPersistenceSafety.swift", - "version": "repo-file-v1:sha256:e6fbb3f62e917da272cb0ea54790a212b235c044ed6e414883f0904a14b2ef0b" + "resource": "repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift", + "version": "repo-file-v1:sha256:2c8baddd408651cfaaec231be95b0af95c4b93b8e264d1733cbffbba7bad48f7" }, { "resource": "repo://Frameworks/TermindPersistence/CloudSyncSafetyStore.swift", @@ -327,10 +319,36 @@ "version": "repo-file-v1:sha256:39095bc585820f77c2b1893c684c2a81cfed675ae5f406fbe996bdbde4cdf2fe" } ] + }, + { + "id": "claim_12e52cd084bd42fb9fc004e10abddf17", + "statement": "Each connection event is a VaultItem connectionHistory key whose local row and journal change commit together; remote events can precede Hosts and deterministic overflow tombstones converge devices to the newest 300. Separately, each reusable DeviceRecord is a VaultItem device key whose name upsert commits with its own journal envelope and is not deleted by event retention or History clearing; neither payload kind requires a new CloudKit schema.", + "evidence": [ + { + "resource": "repo://Frameworks/TermindDomain/Vault/VaultSyncTypes.swift", + "version": "repo-file-v1:sha256:5186b2e30bb07fa6c53dd4def6a5cfba285e103690004e7767bbd40f9f3fe18c" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataConnectionHistoryRepository.swift", + "version": "repo-file-v1:sha256:e0468dec12004d9d8552b2f0d7745008a17b0271f20b65c1b425d9b917e0319a" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift", + "version": "repo-file-v1:sha256:9a3e7296fd09c4b397c90c7b15c20775a0950a145ec1c506ac431c04fc5ea020" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift", + "version": "repo-file-v1:sha256:c727e97334d7ac3d7e5a820afe328a8c1809c08d73abd9c03e91287599d66fb0" + }, + { + "resource": "repo://Frameworks/TermindPersistence/SwiftDataVaultStore.swift", + "version": "repo-file-v1:sha256:19a4f6918e448a30a33daa7390989a96e4340e6c17e03534e9bd17e229439de9" + } + ] } ], "verification": { "by": "openwiki/0.5.2", - "at": "2026-09-22T05:22:55.650Z" + "at": "2026-09-22T09:17:11.491Z" } } diff --git a/openwiki/.last-update.json b/openwiki/.last-update.json index 794daedc..fdb58447 100644 --- a/openwiki/.last-update.json +++ b/openwiki/.last-update.json @@ -1,8 +1,8 @@ { - "updatedAt": "2026-09-22T05:52:08.373Z", + "updatedAt": "2026-09-22T10:06:08.843Z", "command": "update", - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "model": "host-agent/omp", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "model": "host-agent/codex", "status": "complete", "language": "en" } diff --git a/openwiki/.page-manifest.json b/openwiki/.page-manifest.json index a0646ff4..95470ec1 100644 --- a/openwiki/.page-manifest.json +++ b/openwiki/.page-manifest.json @@ -2,67 +2,67 @@ "schemaVersion": 1, "pages": { "/openwiki/architecture/desktop-ui.md": { - "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", - "sourceFingerprint": "sha256:8456f71ffdd978736ff6bf9aed042724573fd633b1d40f6d2b74fec0acb445f6", - "pageVersion": "sha256:bb94da4e49901db707f0db54a47d7303c3a320729b5d4ccdf12b7a2a87a38d4f", - "completedBy": "omp", - "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:bb9e6ab2370a6da5d36671f148615ef16040004c9923a517f7ce5b86c27a028a", + "pageVersion": "sha256:e54867652d0a9b222655bb9e8dd43735a96cf9bf358c9a9f589e5c05b3b2a3b0", + "completedBy": "codex", + "completedRunId": "10c79875-8fc3-4267-9696-e221166a064d" }, "/openwiki/architecture/engineering-invariants.md": { - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "sourceFingerprint": "sha256:9c916a68624be8df021ba6227626ac48ca476b8127537998601e1b1845a884fc", - "pageVersion": "sha256:2650185619c739fcdeb85d549047e17a13b75073a4e6d6ec380afaa8e6f986c8", - "completedBy": "omp", - "completedRunId": "d3840686-2c1c-4420-b87a-1f7ea0188e14" + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:9fdb52819a900696d202afba4b9b9a96e2501b945f17df7f029db20b595431ac", + "pageVersion": "sha256:abc66b27a943dab3394bc6d58bd9353a336a69a9e76afa6a3df35739c803e564", + "completedBy": "codex", + "completedRunId": "dc9c6d50-8538-42e9-8a0f-bba67cb704f7" }, "/openwiki/architecture/mobile-ui.md": { - "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", - "sourceFingerprint": "sha256:8456f71ffdd978736ff6bf9aed042724573fd633b1d40f6d2b74fec0acb445f6", - "pageVersion": "sha256:ff129fd4d82995b518da4a70a87e87f8f3bf920a0f4425d866ba0f36aa86ec79", - "completedBy": "omp", - "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:9fdb52819a900696d202afba4b9b9a96e2501b945f17df7f029db20b595431ac", + "pageVersion": "sha256:eb1d8cc2a67d82c77cf8a81e2ed3c32d502195a66d6e8c942daaab33dd0ee51c", + "completedBy": "codex", + "completedRunId": "dc9c6d50-8538-42e9-8a0f-bba67cb704f7" }, "/openwiki/architecture/module-map.md": { - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "sourceFingerprint": "sha256:9c916a68624be8df021ba6227626ac48ca476b8127537998601e1b1845a884fc", - "pageVersion": "sha256:e707ee9af65f4140151b9354f42ab37041d2c10b718d0f4bc934c7a158ff0281", - "completedBy": "omp", - "completedRunId": "d3840686-2c1c-4420-b87a-1f7ea0188e14" + "pageVersion": "sha256:e7d976e0a8d7e48d3895759656fa2444706f867074efa93eed6d990f409992a7", + "completedBy": "codex", + "completedRunId": "8bdb277a-cf85-4174-a91f-b6a4f89a046d", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:c257a6e01df7463636d1e57348f233f373c8ca070007bc8d15c824601c77538f" }, "/openwiki/architecture/native-app-directories.md": { - "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", - "sourceFingerprint": "sha256:8456f71ffdd978736ff6bf9aed042724573fd633b1d40f6d2b74fec0acb445f6", - "pageVersion": "sha256:b80a3e4c39b80b3b1b701983db69b57bc48fa869ab0b4e6b6955c7150766f692", - "completedBy": "omp", - "completedRunId": "805ed659-4a16-4cbd-b7e1-db39328cb9c4" + "pageVersion": "sha256:766867bc49c356007fb41d79eb8444693a2cd016fed01a7a1631d4a59ab13867", + "completedBy": "codex", + "completedRunId": "8bdb277a-cf85-4174-a91f-b6a4f89a046d", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:c257a6e01df7463636d1e57348f233f373c8ca070007bc8d15c824601c77538f" }, "/openwiki/architecture/platform-runtime.md": { - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "sourceFingerprint": "sha256:9c916a68624be8df021ba6227626ac48ca476b8127537998601e1b1845a884fc", - "pageVersion": "sha256:8fbc2ace1837fd30267a65e459fd76473f9ea6b2d3ed561f824fc1d288372ea8", - "completedBy": "omp", - "completedRunId": "d3840686-2c1c-4420-b87a-1f7ea0188e14" + "pageVersion": "sha256:89221b450455e43209b8cf3fd10ebdd9baf9642dbe7d84c8fdbce1816c91c714", + "completedBy": "codex", + "completedRunId": "8bdb277a-cf85-4174-a91f-b6a4f89a046d", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:c257a6e01df7463636d1e57348f233f373c8ca070007bc8d15c824601c77538f" }, "/openwiki/architecture/shared-module-directories.md": { - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "sourceFingerprint": "sha256:9c916a68624be8df021ba6227626ac48ca476b8127537998601e1b1845a884fc", - "pageVersion": "sha256:46da12757deeb46a90ef6fb1b732fb70c55f422f008a7a43e317c7f5f31e1681", - "completedBy": "omp", - "completedRunId": "d3840686-2c1c-4420-b87a-1f7ea0188e14" + "pageVersion": "sha256:8f4fafaa7a5c62b52e73921856983f69fcafed01d18b9f2903aa53d8b883eec5", + "completedBy": "codex", + "completedRunId": "8bdb277a-cf85-4174-a91f-b6a4f89a046d", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:c257a6e01df7463636d1e57348f233f373c8ca070007bc8d15c824601c77538f" }, "/openwiki/architecture/supporting-system-directories.md": { - "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", - "sourceFingerprint": "sha256:8456f71ffdd978736ff6bf9aed042724573fd633b1d40f6d2b74fec0acb445f6", - "pageVersion": "sha256:d571512d2bbb036fb97369a80a51af74729053b8d7d4164b178ba7b1e0919d80", - "completedBy": "omp", - "completedRunId": "805ed659-4a16-4cbd-b7e1-db39328cb9c4" + "pageVersion": "sha256:fa243e662bcccf538fa4c830c781851a26ec9aea0c86163cfc99644ac7364159", + "completedBy": "codex", + "completedRunId": "8bdb277a-cf85-4174-a91f-b6a4f89a046d", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:c257a6e01df7463636d1e57348f233f373c8ca070007bc8d15c824601c77538f" }, "/openwiki/architecture/system-boundaries.md": { - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "sourceFingerprint": "sha256:9c916a68624be8df021ba6227626ac48ca476b8127537998601e1b1845a884fc", - "pageVersion": "sha256:52ee85c8226f34cc3e7f73201ee744ae289dce13814ec7f5f2035f3e66f38f84", - "completedBy": "omp", - "completedRunId": "d3840686-2c1c-4420-b87a-1f7ea0188e14" + "pageVersion": "sha256:17dc0f2b25ec8c65104d2cb491b9afc63b341e639478ff0bcfdfcf583e74a55f", + "completedBy": "codex", + "completedRunId": "8bdb277a-cf85-4174-a91f-b6a4f89a046d", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:c257a6e01df7463636d1e57348f233f373c8ca070007bc8d15c824601c77538f" }, "/openwiki/concepts/agent-runtime-and-assistant.md": { "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", @@ -79,25 +79,25 @@ "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" }, "/openwiki/concepts/persistence-and-secrets.md": { - "pageVersion": "sha256:8e063637cd5085c209089a4ce3cbbb163b175213e719bae65342a1a5598f9b8e", - "completedBy": "omp", - "completedRunId": "5cf007c3-f50b-4724-9780-c66a5f73541a", - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "sourceFingerprint": "sha256:9c916a68624be8df021ba6227626ac48ca476b8127537998601e1b1845a884fc" + "pageVersion": "sha256:c7ce6e8151265464db5b835c9233fca3a6d9321c828b252363e3d7d0552da05c", + "completedBy": "codex", + "completedRunId": "8bdb277a-cf85-4174-a91f-b6a4f89a046d", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:c257a6e01df7463636d1e57348f233f373c8ca070007bc8d15c824601c77538f" }, "/openwiki/concepts/remote-services-and-forwarding.md": { - "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", - "sourceFingerprint": "sha256:8456f71ffdd978736ff6bf9aed042724573fd633b1d40f6d2b74fec0acb445f6", - "pageVersion": "sha256:e8db6dcac9048304c352ba94dabfb8b5085a83d1526e3ded743e49ba354dee43", - "completedBy": "omp", - "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:887fa03c43aeaff3e10308e430007f332dc25e8654693bbf9191c647f50e3e1e", + "pageVersion": "sha256:0ad48d7eea0ecc929457a1bca2df7de4bef0220eab49611c612e4fe607d8f241", + "completedBy": "codex", + "completedRunId": "1928445d-30c0-4dcb-b85b-38ecfc74a433" }, "/openwiki/concepts/shell-integration.md": { - "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", - "sourceFingerprint": "sha256:8456f71ffdd978736ff6bf9aed042724573fd633b1d40f6d2b74fec0acb445f6", - "pageVersion": "sha256:e27880aa34f40deedddffbc3737c872c8b3018d93003f868cd5fa14e7ddf78e0", - "completedBy": "omp", - "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:887fa03c43aeaff3e10308e430007f332dc25e8654693bbf9191c647f50e3e1e", + "pageVersion": "sha256:7969689131767daee6522896f5d6a8237a9f4931eea0e640c17a5fa1d1c58bba", + "completedBy": "codex", + "completedRunId": "1928445d-30c0-4dcb-b85b-38ecfc74a433" }, "/openwiki/concepts/ssh-multiplexing-and-scheduling.md": { "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", @@ -107,18 +107,18 @@ "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" }, "/openwiki/concepts/ui-design-principles.md": { - "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", - "sourceFingerprint": "sha256:8456f71ffdd978736ff6bf9aed042724573fd633b1d40f6d2b74fec0acb445f6", - "pageVersion": "sha256:0ef048b57e0be46c11bece53519653eaa7e6e4106f7a43d19b73af10bf2d560a", - "completedBy": "omp", - "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:9fdb52819a900696d202afba4b9b9a96e2501b945f17df7f029db20b595431ac", + "pageVersion": "sha256:3c9dd812481bbfb7ac06fdb0073a97ba6a5d2d2966f90d7619e1c7d1d5fc6319", + "completedBy": "codex", + "completedRunId": "dc9c6d50-8538-42e9-8a0f-bba67cb704f7" }, "/openwiki/integrations/mcp-server-and-bridge.md": { - "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", - "sourceFingerprint": "sha256:8456f71ffdd978736ff6bf9aed042724573fd633b1d40f6d2b74fec0acb445f6", - "pageVersion": "sha256:0dd44becda4b103f8f913c5fb5293ffc1b0a87287a43708514270a266cafd70a", - "completedBy": "omp", - "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:9fdb52819a900696d202afba4b9b9a96e2501b945f17df7f029db20b595431ac", + "pageVersion": "sha256:a43999af9e328429d4b484cd413c52e5db3fd5c0ad110ae4fb0a86922769d48a", + "completedBy": "codex", + "completedRunId": "dc9c6d50-8538-42e9-8a0f-bba67cb704f7" }, "/openwiki/operations/build-test-and-release.md": { "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", @@ -128,25 +128,25 @@ "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" }, "/openwiki/operations/cloudkit-release-readiness.md": { - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "sourceFingerprint": "sha256:9c916a68624be8df021ba6227626ac48ca476b8127537998601e1b1845a884fc", - "pageVersion": "sha256:bce6dd2e986f7546e50a3b4acdc5b4a482f438c4763e19ffdb999ba9b26d3b5e", - "completedBy": "omp", - "completedRunId": "d3840686-2c1c-4420-b87a-1f7ea0188e14" + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:9fdb52819a900696d202afba4b9b9a96e2501b945f17df7f029db20b595431ac", + "pageVersion": "sha256:acb6af63437610432dc40d7788ee89b512c3d0687c32b3088fe72c176c5a6bfd", + "completedBy": "codex", + "completedRunId": "dc9c6d50-8538-42e9-8a0f-bba67cb704f7" }, "/openwiki/operations/marketing-assets-and-app-icons.md": { - "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", - "sourceFingerprint": "sha256:8456f71ffdd978736ff6bf9aed042724573fd633b1d40f6d2b74fec0acb445f6", - "pageVersion": "sha256:e6a8ef81cbf7d5ffb6373e73605d753866c3eebed9436ce6cca2345caa77b629", - "completedBy": "omp", - "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" + "pageVersion": "sha256:6b1311e49539498e542011db47be6e7f45947438b9f3f71d33c129340e76050a", + "completedBy": "codex", + "completedRunId": "8bdb277a-cf85-4174-a91f-b6a4f89a046d", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:c257a6e01df7463636d1e57348f233f373c8ca070007bc8d15c824601c77538f" }, "/openwiki/operations/port-forwarding-benchmarks.md": { - "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", - "sourceFingerprint": "sha256:8456f71ffdd978736ff6bf9aed042724573fd633b1d40f6d2b74fec0acb445f6", - "pageVersion": "sha256:7b9983afed4d964330e5d0357dbd1f4e32b5d0c9bbfd5877f550fd42b994924b", - "completedBy": "omp", - "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" + "pageVersion": "sha256:eb2a96781d38300f76e05d6857eca063afedce152263ef8cae2d940364c64c54", + "completedBy": "codex", + "completedRunId": "8bdb277a-cf85-4174-a91f-b6a4f89a046d", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:c257a6e01df7463636d1e57348f233f373c8ca070007bc8d15c824601c77538f" }, "/openwiki/operations/storefront-commerce-release.md": { "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", @@ -156,18 +156,18 @@ "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" }, "/openwiki/quickstart.md": { - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "sourceFingerprint": "sha256:9c916a68624be8df021ba6227626ac48ca476b8127537998601e1b1845a884fc", - "pageVersion": "sha256:214b58c099e361b83e4b7b8554a076e76bfe335f8fb73225bd6bb33c8c6bca26", - "completedBy": "omp", - "completedRunId": "d3840686-2c1c-4420-b87a-1f7ea0188e14" + "pageVersion": "sha256:f7261be8dcaff5ed990cd93a09fadd3f4b87b2a51c94e0c16a09efce6cf12bb0", + "completedBy": "codex", + "completedRunId": "8bdb277a-cf85-4174-a91f-b6a4f89a046d", + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:c257a6e01df7463636d1e57348f233f373c8ca070007bc8d15c824601c77538f" }, "/openwiki/testing/behavioral-boundaries-and-fixtures.md": { - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "sourceFingerprint": "sha256:9c916a68624be8df021ba6227626ac48ca476b8127537998601e1b1845a884fc", - "pageVersion": "sha256:7af6d060ce1acf8f2c7a21e69ae27cfccf8932ab35f6f892944db9a5ffe45f69", - "completedBy": "omp", - "completedRunId": "d3840686-2c1c-4420-b87a-1f7ea0188e14" + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:9fdb52819a900696d202afba4b9b9a96e2501b945f17df7f029db20b595431ac", + "pageVersion": "sha256:4d35b994301024766ef57f8f9efd46cc6b838886c341a3c18e9bec652aae0af7", + "completedBy": "codex", + "completedRunId": "dc9c6d50-8538-42e9-8a0f-bba67cb704f7" }, "/openwiki/workflows/port-forwarding-pool-and-dispatch.md": { "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", @@ -177,11 +177,11 @@ "completedRunId": "691ddd1f-2966-4a5b-b437-127adf28ff38" }, "/openwiki/workflows/settings-and-cloud-sync.md": { - "gitHead": "ba156c2827c856204ecb450c70500b3dff3e8fc9", - "sourceFingerprint": "sha256:9c916a68624be8df021ba6227626ac48ca476b8127537998601e1b1845a884fc", - "pageVersion": "sha256:6f79c9be2a3114480fd65a8ef8af40f5fd3076ed721281e826cd4fbbf11ddcbf", - "completedBy": "omp", - "completedRunId": "d3840686-2c1c-4420-b87a-1f7ea0188e14" + "gitHead": "cca15abd7682f33404485806d15c99541ef98c5e", + "sourceFingerprint": "sha256:bb9e6ab2370a6da5d36671f148615ef16040004c9923a517f7ce5b86c27a028a", + "pageVersion": "sha256:4c76e612db1b0e6ac951727a79725e55ece21dac8f56cf59871859bbb2e8edb1", + "completedBy": "codex", + "completedRunId": "10c79875-8fc3-4267-9696-e221166a064d" }, "/openwiki/workflows/sftp-transfer-and-external-edit.md": { "gitHead": "58a0cfd1bf9d08057801c740e79bbcb3c1fcc37e", diff --git a/openwiki/architecture/desktop-ui.md b/openwiki/architecture/desktop-ui.md index f1231f8b..595e0975 100644 --- a/openwiki/architecture/desktop-ui.md +++ b/openwiki/architecture/desktop-ui.md @@ -5,8 +5,12 @@ description: "How the macOS app composes its native window shell, persistent wor tags: [macos, tfm, ui, navigation, workspaces, swiftui, appkit, design] verified: - by: openwiki/0.5.2 - at: 2026-09-22T03:23:14.035Z + at: 2026-09-22T09:17:11.491Z sources: + - id: openwiki-source-e09fb7bc8e7ca0579f6d446e + resource: repo://Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift + - id: openwiki-source-4dbea0ff2808163cb4ecdd8f + resource: repo://TFM/App/AppModel.swift - id: openwiki-source-873894299539cce9a737a860 resource: repo://TFM/App/RootView.swift - id: openwiki-source-6570374eaac2e0278fa84640 @@ -19,6 +23,8 @@ sources: resource: repo://TFM/Features/Sidebar/WindowSidebarView.swift - id: openwiki-source-9cfd8531aa60dcfb31f04e04 resource: repo://TFM/Features/Vault/VaultWorkspaceView.swift + - id: openwiki-source-ee09ece1fddff7d72a2b5cf4 + resource: repo://TFM/Features/Workspaces/Pane.swift - id: openwiki-source-ba160208435f3410138a3539 resource: repo://TFM/Features/Workspaces/PersistentWorkspaceDeckLayout.swift - id: openwiki-source-22b9fca540c3029c5ace451e @@ -31,7 +37,7 @@ sources: resource: repo://TFMTests/PersistentWorkspaceDeckLayoutTests.swift - id: openwiki-source-dceb8d7e7c5bb09359e70052 resource: repo://TFMTests/ResponsivePageLayoutTests.swift -generated: { by: "omp", at: "2026-09-22T02:51:54.212Z" } +generated: { by: "codex", at: "2026-09-22T09:17:11.491Z" } --- # TFM Desktop UI Architecture @@ -171,6 +177,12 @@ Do not merge this cache with the persistent workspace deck. Both preserve contin Escape handling is also scoped. In the compact Assistant, an approval or user-interaction form can own Escape; resolving it must not let the same key event fall through and close the conversation. The view captures the blocking-interaction state for that render before applying its back policy. +## Connection history projections and pane lifetime + +Desktop connection history is shared app data with two deliberately different projections. Welcome shows recent Hosts derived only from events whose stable `sourceDeviceID` matches this installation, so synchronized activity from another Mac does not reorder the local launch surface. Its row timestamp is likewise the newest local event. Deleting a Welcome recent clears every connection-history event for that Host, across protocols and synchronized devices, without deleting the Host. The contextual History sidebar instead shows the newest 100 matching events from the complete synchronized timeline. Each row keeps the protocol in its leading symbol and resolves the source device's current display name through the app-wide `DeviceDirectoryModel`; the event does not capture a hostname. Removing a row deletes only that event, while **Clear History** clears the complete timeline. + +An event follows the pane's usage lifetime rather than every physical reconnect. `WindowModel` assigns history tracking only to saved-host SSH, Telnet and SFTP panes; local Serial panes and unsaved external targets never enter this timeline. Closing a pane or workspace records `endedAt` before disconnecting its transport. Moving a live pane between workspaces preserves the same event, and reopening a closed workspace creates a new event. A process crash can leave `endedAt` absent rather than inventing an inaccurate end time. + ## Desktop design principles and change checklist The implemented patterns suggest these rules for extending TFM: @@ -194,5 +206,6 @@ Before changing a UI owner, trace both what happens when its view disappears and | Managed selection/reordering | `ManagedCardDropResolver`, selection and marquee tests; resource-specific move tests. | | Transcript persistence, incremental updates or mounting | `TranscriptListViewTests`, rendering and performance suites. | | Focused toolbar and terminal commands | `WindowToolbarFindTests`, `TerminalFindTests`, `UserInputModalityTests`. | +| Welcome Recents, connection-history limits or pane close tracking | `ConnectionHistoryTests`, `VaultSyncTests`, and workspace close/reopen tests. | Some rendering tests explicitly require a visible window; routine checks should not enable those globally. The narrow-editor rendering cases cover English, Simplified Chinese and Traditional Chinese. This guide was derived from implementation and tests; it does not report a new test run or visual approval. Use [Build, Test, and Release Operations](/openwiki/operations/build-test-and-release.md) for the appropriate verification commands. diff --git a/openwiki/architecture/engineering-invariants.md b/openwiki/architecture/engineering-invariants.md index 5f2f2455..0dc43d6f 100644 --- a/openwiki/architecture/engineering-invariants.md +++ b/openwiki/architecture/engineering-invariants.md @@ -3,16 +3,21 @@ type: architecture-contract title: Cross-Cutting Engineering Invariants description: Exhaustive architecture, ownership, lifecycle, security, persistence, remote-session, agent, sync, and mobile safety contracts that span Termind's specialized wiki pages. tags: [architecture, invariants, lifecycle, security, persistence, remote-sessions, agent, mobile] -verified: - - by: openwiki/0.5.2 - at: 2026-09-22T05:22:55.650Z sources: - id: openwiki-source-8037e2358a2c4f9b2c722a11 resource: repo://AGENTS.md + - id: openwiki-source-dc7b363138921b0923d2dc2f + resource: repo://Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift + - id: openwiki-source-e09fb7bc8e7ca0579f6d446e + resource: repo://Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift - id: openwiki-source-4f78037bc69b6dfc293aca3d resource: repo://Frameworks/TermindApplication/SSHConnectionTypes.swift - id: openwiki-source-cd3e43e60522274ce1f1908b resource: repo://Frameworks/TermindApplication/SSHSecret.swift + - id: openwiki-source-e4f5ea9b30063cdc7d027956 + resource: repo://Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift + - id: openwiki-source-7367aa06bc3b7ce470e78113 + resource: repo://Frameworks/TermindDomain/DeviceRecord.swift - id: openwiki-source-1d1f503a260f304a969fcee4 resource: repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift - id: openwiki-source-4957f13c836c497e7ebe4656 @@ -27,6 +32,8 @@ sources: resource: repo://Frameworks/TermindPersistence/CloudSyncSafetyStore.swift - id: openwiki-source-fe1dc993163bb9ff61397be7 resource: repo://Frameworks/TermindPersistence/HostPersistence.swift + - id: openwiki-source-bc351b6c699ff4bbf9f8c2b1 + resource: repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift - id: openwiki-source-7a9c6d90cba4ebd1d1398673 resource: repo://Frameworks/TermindPersistence/TermindPersistence.swift - id: openwiki-source-87b90af0bd504e35083a8414 @@ -49,6 +56,8 @@ sources: resource: repo://FrameworkTests/CloudSyncSafetyPersistenceTests.swift - id: openwiki-source-eb9518e5c7eb3782fa0cccc0 resource: repo://FrameworkTests/CloudSyncSafetyTests.swift + - id: openwiki-source-8428b9f5de49b8a561d93fa5 + resource: repo://FrameworkTests/PersistenceCompatibilityTests.swift - id: openwiki-source-db40aa993661b07f1a9aff6d resource: repo://FrameworkTests/SSHInteractionPolicyTests.swift - id: openwiki-source-906678adb91c2fce2d3fcaed @@ -73,7 +82,10 @@ sources: resource: repo://TFM/Features/Workspaces/WindowModel.swift - id: openwiki-source-d3d065330c730839f5cc6112 resource: repo://TFMTests/ArchitectureBoundaryTests.swift -generated: { by: "omp", at: "2026-09-22T05:22:55.650Z" } +generated: { by: "codex", at: "2026-09-22T09:17:11.491Z" } +verified: + - by: openwiki/0.5.2 + at: 2026-09-22T09:51:39.958Z --- # Cross-Cutting Engineering Invariants @@ -96,7 +108,7 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r | TermindInfrastructure | In-memory repositories, metrics parsing, StoreKit, Vault/Settings/Metrics CloudKit; Application, Domain, Persistence. | | TermindRemoteInfrastructure | SSH/SFTP/Telnet, Keychain, keys, sampling, forwarding, transfers, scoped imports; Infrastructure, Application, Domain. C libraries stay outside portable framework tests. | | TermindPresentation | Reusable SwiftUI policy/content, Ghostty themes, `TerminalPreferences` bridge; Application and Domain only. | -| TermindPersistence | All SwiftData models, frozen V1–V30 history, additive V31 sync-safety models, migrations and container construction; no app dependency. | +| TermindPersistence | All SwiftData models, frozen V1–V31 history, V32 live models, migrations and container construction; no app dependency. | | TFM / TFI | Native UI/adapters/lifetimes; shared modules never import apps. | - Use direct owner imports/public APIs, not re-exports, forwarding aliases, `Shared` types or a generic Core module. @@ -580,6 +592,12 @@ factories/Sendable buffer handlers. Timers/workers enter explicit short MainActo failure needs explicit retry. Save retains unopened entries/order. Read legacy arrays/v1, assign missing lowest-free indices and write envelope; only successful loads permit save. Corrupt/unknown bytes remain/write-blocked. Prune missing Hosts; close/exit/delete removes live+Recent entries; Close All preserves connection history. Titles reserve indices without renumbering. Host-delete outbox cancels related runtime/local history. +- Connection history is a synchronized Vault event stream for saved-host SSH/Telnet/SFTP only; Serial and temporary targets never enter it. Each + event carries a stable `sourceDeviceID`, start time and optional end time, but no device name. The reusable synchronized device directory maps + installation IDs to mutable display names, so a rename updates every feature that resolves that ID without rewriting history events. Closing a + pane/workspace ends its event; moving preserves it. Stores retain the newest 300 deterministically. Welcome/mobile Recents use local events and + aggregate by Host, while desktop/mobile History presents at most 100 all-device events. Aggregated Delete clears that Host's events across + devices; Clear History clears the complete timeline. - One serialized Live Activity for current Workspaces only. Secret/command-free validated links select ID/list without restoring unrelated sessions; cold launch ends leftover activities but links may open saved entries. Explicit start intent waits for foreground; last current close clears it even with Recent. Foreground alone never recreates dismissed activity. Snapshot/queue is separate from observed stores and cannot keep SSH/Agent diff --git a/openwiki/architecture/mobile-ui.md b/openwiki/architecture/mobile-ui.md index 4195f258..235efc9e 100644 --- a/openwiki/architecture/mobile-ui.md +++ b/openwiki/architecture/mobile-ui.md @@ -3,9 +3,6 @@ type: architecture guide title: TFI Mobile UI Architecture description: "How the iPhone/iPad app composes registered feature stacks, scene-local navigation, shared runtimes, adaptive content regions, native terminal/transcript surfaces and mobile interaction policies." tags: [ios, ipados, tfi, ui, navigation, adaptive-layout, swiftui, uikit] -verified: - - by: openwiki/0.5.2 - at: 2026-09-22T03:23:14.035Z sources: - id: openwiki-source-906678adb91c2fce2d3fcaed resource: repo://TFI/App/MobileAppModel.swift @@ -29,17 +26,26 @@ sources: resource: repo://TFI/Features/AI/MobileAssistantConversation.swift - id: openwiki-source-d43ef236ba4bbb23d8dab797 resource: repo://TFI/Features/Assistant/MobileAssistantView.swift + - id: openwiki-source-20a5e70f2b015b63e86a0c0d + resource: repo://TFI/Features/HostChooser/MobileConnectionPicker.swift + - id: openwiki-source-3f37062f55fb57a9f2e97f35 + resource: repo://TFI/Features/Vault/MobileVaultView.swift - id: openwiki-source-a337b6f93e72977e59faaf14 resource: repo://TFI/Features/Workspaces/MobileWorkspaceListView.swift - id: openwiki-source-4c2de67bfdb2238d765a642a resource: repo://TFI/Features/Workspaces/MobileWorkspaceView.swift + - id: openwiki-source-d34ac3e852773901d0566033 + resource: repo://TFI/Platform/MobileHomeScreenQuickActions.swift - id: openwiki-source-ca0b3e6e839587396cdeaf54 resource: repo://TFI/Platform/MobileTerminalSurface.swift - id: openwiki-source-c99ab42abad536ed4396933c resource: repo://TFI/UI/Managed/MobileCards.swift - id: openwiki-source-fffc181d864b6caea779a6f5 resource: repo://TFITests/MobileAdaptiveLayoutTests.swift -generated: { by: "omp", at: "2026-09-22T02:51:54.212Z" } +generated: { by: "codex", at: "2026-09-22T09:17:11.491Z" } +verified: + - by: openwiki/0.5.2 + at: 2026-09-22T09:51:39.958Z --- # TFI Mobile UI Architecture @@ -117,6 +123,18 @@ Host activation and explicit connection actions are different: Unavailable saved workspace IDs produce an explicit unavailable state; a stale route does not silently open some other Host. Root-level deep links, quick actions and focused scene commands dispatch through that scene's model. +### Local Recents versus synchronized History + +The Workspaces destination and the Vault expose different projections over the shared synchronized event stream: + +- Workspaces Recents uses only events from this installation, aggregates every protocol and attempt into one row per Host, and keeps the newest five Hosts. A primary tap uses current Host capability preference (SSH before Telnet); protocol context and swipe actions keep SSH, Telnet and SFTP visible while disabling unavailable choices. Deleting the aggregate clears that Host's events across devices. +- Home Screen quick actions use the same local per-Host projection, publish at most three host-only `server.rack` items, and resolve the current preferred terminal protocol at activation. Older payloads that carried a protocol still decode, but that stale protocol is ignored. +- Vault's History page follows Known Hosts and displays up to 100 individual events from every synchronized device. The leading symbol remains protocol-specific, while the right column and search resolve the event's `sourceDeviceID` through the reusable synchronized device directory. Row deletion removes one event; **Clear History** clears the complete timeline. + +Workspaces, Recent Workspaces and local Recents align their text from the same 36-point Host distribution icon slot and 12-point gap. Live Workspaces overlay an eight-point status dot inside that slot; recent/restored rows have no live status dot. Workspaces and Recent Workspaces show protocol at the common right edge, while aggregated Recents deliberately omits it. TFI exposes no Serial workspace or connection-history kind. + +The Workspace title switcher uses a separate compact icon treatment: an unselected item shows its Host distribution icon with a connection-state dot, while the selected item replaces that composite with the ordinary checkmark and therefore carries no status dot. + ## Adaptation has three inputs, not one device check | Input | Mechanism | What it controls | diff --git a/openwiki/architecture/module-map.md b/openwiki/architecture/module-map.md index fbe3b7d4..d94ca98b 100644 --- a/openwiki/architecture/module-map.md +++ b/openwiki/architecture/module-map.md @@ -12,10 +12,18 @@ sources: resource: repo://Benchmarks/PortForwarding/main.swift - id: openwiki-source-5f99e8af445e073ffe9bc257 resource: repo://Frameworks/TermindApplication/Agent/AssistantExecutionController.swift + - id: openwiki-source-e09fb7bc8e7ca0579f6d446e + resource: repo://Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift + - id: openwiki-source-6f388236eaa573c3c2ba7706 + resource: repo://Frameworks/TermindApplication/Repositories/DeviceDirectoryRepository.swift - id: openwiki-source-d593fb9c62c3736497a39486 resource: repo://Frameworks/TermindApplication/Transfers/RemoteFileSystem.swift + - id: openwiki-source-7367aa06bc3b7ce470e78113 + resource: repo://Frameworks/TermindDomain/DeviceRecord.swift - id: openwiki-source-eaa9d39ab551840299164d96 resource: repo://Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift + - id: openwiki-source-0b6ab59ec6a684de8101ff8e + resource: repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift - id: openwiki-source-d2d4c06f734241db3badc1f5 resource: repo://Frameworks/TermindRemoteInfrastructure/Agent/TermindAgentTools.swift - id: openwiki-source-c4b6e224fdbea6dcd393344d @@ -38,10 +46,10 @@ sources: resource: repo://TFMTests/ArchitectureBoundaryTests.swift - id: openwiki-source-0f8d0f9d08d6674698dd054c resource: repo://WorkspaceActivitySupport/WorkspaceActivityAttributes.swift -generated: { by: "omp", at: "2026-09-22T03:23:14.035Z" } +generated: { by: "codex", at: "2026-09-22T09:17:11.491Z" } verified: - by: openwiki/0.5.2 - at: 2026-09-22T05:22:55.650Z + at: 2026-09-22T10:02:02.985Z --- # Module and Directory Architecture Map @@ -124,6 +132,7 @@ The MCP bridge translates stdio requests into authenticated HTTP calls to the de | Browse or transfer files | Domain remote-file values; Application remote-filesystem and edit model. | Remote Infrastructure SFTP channels, transfer scheduler, planners, and edit service. | Native browser, conflict UI, local file access, and Open With presentation. | | Run an Assistant turn | Application conversation/execution coordination; AgentKit generic runtime. | Termind host tools in Remote Infrastructure; persistence and memory stores. | Native composer, approval/input presentation, transcript mounting, and navigation. | | Edit and synchronize Vault data | Domain snapshots and mutations; Application library/use-case state. | Persistence local stores and journals; Infrastructure CloudKit coordinators; Keychain secret implementation. | Platform editors and app-level service assembly. | +| Resolve this or another installation's display name | Domain `DeviceRecord`; Application device-directory port and observable model. | Persistence-owned SwiftData device records and Vault journal payloads. | Platform composition supplies the stable installation ID and system display name. | | Display remote metrics | Domain metrics; Application sampling/snapshot interfaces and model. | Remote SSH sampler plus Infrastructure output parsers. | Native dashboards and desktop menu-bar integration. | This split is also a placement rule: shared behavior belongs with its existing owner; native lifetime or platform API usage stays in the app. Similar names such as `Agent`, `Transfers`, `Terminal`, or `Metrics` in different modules do not imply duplicated responsibilities. diff --git a/openwiki/architecture/native-app-directories.md b/openwiki/architecture/native-app-directories.md index 670099e4..ef2fa699 100644 --- a/openwiki/architecture/native-app-directories.md +++ b/openwiki/architecture/native-app-directories.md @@ -3,14 +3,13 @@ type: architecture directory guide title: Native App Directory Responsibilities description: Second-level directory responsibilities in TFM and TFI, including every feature area, platform adapters, reusable native UI, and the different window and scene ownership models. tags: [architecture, directories, macos, ios, ipados, navigation, lifecycle] -verified: - - by: openwiki/0.5.2 - at: 2026-09-22T03:45:03.442Z sources: - id: openwiki-source-c4b6e224fdbea6dcd393344d resource: repo://Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift - id: openwiki-source-cd0be716a2446029f345525a resource: repo://Termind.xcodeproj/project.pbxproj + - id: openwiki-source-906678adb91c2fce2d3fcaed + resource: repo://TFI/App/MobileAppModel.swift - id: openwiki-source-e43a0da5ab77c2fc3554244d resource: repo://TFI/App/MobileSceneModel.swift - id: openwiki-source-83cf4f34941d19114f080a3a @@ -83,7 +82,10 @@ sources: resource: repo://TFM/UI/Managed/ManagedCardPage.swift - id: openwiki-source-d3d065330c730839f5cc6112 resource: repo://TFMTests/ArchitectureBoundaryTests.swift -generated: { by: "omp", at: "2026-09-22T03:45:03.442Z" } +generated: { by: "codex", at: "2026-09-22T10:02:02.985Z" } +verified: + - by: openwiki/0.5.2 + at: 2026-09-22T10:02:02.985Z --- # Native App Directory Responsibilities @@ -107,6 +109,8 @@ generated: { by: "omp", at: "2026-09-22T03:45:03.442Z" } `App`, `Platform`, `Design`, and `DesignSystem` are currently flat source directories. Their multiple concerns are listed above rather than presented as nonexistent child folders. +The app composition roots also adapt platform device naming into the shared device directory. Both TFM and TFI read DeviceKit's `Device.current.safeDescription`; TFM refreshes it when the application becomes active, and TFI refreshes it when a scene becomes active. The portable `DeviceRecord`, directory model and repositories remain in shared frameworks rather than either native app directory. + ## Application subdirectories ### TFM/Application diff --git a/openwiki/architecture/platform-runtime.md b/openwiki/architecture/platform-runtime.md index f09bac37..fb5e5f3e 100644 --- a/openwiki/architecture/platform-runtime.md +++ b/openwiki/architecture/platform-runtime.md @@ -26,16 +26,20 @@ sources: resource: repo://TFM/App/RootView.swift - id: openwiki-source-6570374eaac2e0278fa84640 resource: repo://TFM/App/TermindApp.swift + - id: openwiki-source-ee09ece1fddff7d72a2b5cf4 + resource: repo://TFM/Features/Workspaces/Pane.swift + - id: openwiki-source-22b9fca540c3029c5ace451e + resource: repo://TFM/Features/Workspaces/WindowModel.swift - id: openwiki-source-22e92f96394f292a731fc6fb resource: repo://TFM/Platform/MainWindowCoordinator.swift - id: openwiki-source-d3d065330c730839f5cc6112 resource: repo://TFMTests/ArchitectureBoundaryTests.swift - id: openwiki-source-531f6567ecf96fd4b0c5e97a resource: repo://TFMTests/ExternalConnectionURLTests.swift -generated: { by: "omp", at: "2026-09-22T02:51:54.212Z" } +generated: { by: "codex", at: "2026-09-22T10:02:02.985Z" } verified: - by: openwiki/0.5.2 - at: 2026-09-22T05:22:55.650Z + at: 2026-09-22T10:02:02.985Z --- # Platform Runtime, App Composition, and Lifetimes @@ -66,7 +70,7 @@ A useful consequence is that sharing a desktop `AppModel` does **not** make its ## macOS composition -`TermindApp.init()` is the desktop composition root. It determines whether it is running tests or a debug screenshot demo, constructs one SwiftData `ModelContainer`, prepares settings and metrics persistence, makes the Keychain-backed secret store, and assembles `AppModel` with repositories, sync coordinators, logging, histories, and the common `RemoteConnectionCoordinator`. Startup fails closed: an inability to open the local database calls `fatalError` rather than substituting an empty store. +`TermindApp.init()` is the desktop composition root. It determines whether it is running tests or a debug screenshot demo, constructs one SwiftData `ModelContainer`, prepares settings and metrics persistence, makes the Keychain-backed secret store, and assembles `AppModel` with repositories, sync coordinators, logging, histories, the reusable device directory, and the common `RemoteConnectionCoordinator`. The directory uses the Vault journal's stable installation ID and DeviceKit's `Device.current.safeDescription`. Startup fails closed: an inability to open the local database calls `fatalError` rather than substituting an empty store. ```mermaid flowchart TD @@ -103,13 +107,13 @@ The model keeps only weak registrations for open `WindowModel`s. Registration in `WindowModel` contains the state that may legitimately differ in another desktop window: navigator destination, dynamic workspaces and panes, sidebar and search behavior, workspace selection, local presentations, and window-specific assistant surfaces. `RootView` preserves mounted workspace-native views when a different destination becomes visible so terminal grids, scrollback, and SFTP UI state survive navigation. -A host-opening action is centralized in `AppModel`, which records connection history and chooses the requested protocol, but it opens into the caller's `WindowModel`. A batch can deliberately create separate window-local workspaces or one workspace with several panes. External SSH/SFTP/Telnet URLs likewise create a temporary workspace in the target window without adding a saved Host; Telnet URLs containing an advisory password require explicit confirmation first. +A host-opening action is centralized in `AppModel`, which chooses the requested protocol, but it opens into the caller's `WindowModel`. That window assigns one connection-history event to each saved-host SSH, Telnet or SFTP pane. Closing the pane/workspace ends the event before disconnecting, while moving a live pane preserves it and reopening creates a new event. External temporary and Serial panes are not recorded; a crash may leave `endedAt` absent. A batch can deliberately create separate window-local workspaces or one workspace with several panes. External SSH/SFTP/Telnet URLs likewise create a temporary workspace in the target window without adding a saved Host; Telnet URLs containing an advisory password require explicit confirmation first. `MainWindowCoordinator` binds native `NSWindow`s to `WindowModel`s. It processes new-window and workspace-move requests, preserves system-restored frames, saves normal window size only at appropriate boundaries, and unregisters then closes a model when its native window closes. The AppKit delegate handles Dock reopen/new-window behavior and may retain the process after the last window according to menu-bar metrics configuration. ### Desktop lifecycle and termination -The AppKit delegate starts native appearance/login handling and, outside tests, starts metrics activity and the metrics menu once `AppModel` is available. On app activation it refreshes entitlement state. On termination it first checks for pending external edits; after user confirmation, it saves window sizing and invokes `AppModel.shutdownForTermination()`. +The AppKit delegate starts native appearance/login handling and, outside tests, starts metrics activity and the metrics menu once `AppModel` is available. On app activation it refreshes both entitlement state and the current `DeviceRecord` display name from DeviceKit's `Device.current.safeDescription`; the synchronized directory can therefore update without rewriting connection events. TFI performs the equivalent DeviceKit refresh on active-scene transitions. On termination the desktop app first checks for pending external edits; after user confirmation, it saves window sizing and invokes `AppModel.shutdownForTermination()`. Shutdown is deliberately idempotent and bounded by the delegate. It suspends memory and edit monitoring, stops metrics, speech, windows, SFTP work, external authentication, forwarding, and the outbox; concurrently awaits MCP and SFTP cancellation, then closes agent background commands and connection pools. The delegate replies to macOS when cleanup completes or after a three-second fallback, preventing an unbounded quit wait. @@ -141,7 +145,7 @@ This diagram shows that mobile scenes have independent navigation but select fro `MobileAppDependencies` is the mobile assembly seam. Live dependencies create a launch file logger and a single `RemoteConnectionCoordinator`, which is passed to both mobile workspace connections and metrics; the same logger is passed to forwarding. Test dependencies use in-memory mode and permit replacement of the host repository, defaults, background-task manager, workspace connection services, metrics services, review controller, container, workspace store, live-activity controller, and forwarding factory. -`MobileAppModel` creates or receives its `ModelContainer`, then builds the host repository, outbox, Keychain or in-memory secret store, settings/metrics persistence and sync coordinators, host and credential libraries, AI/skills/memory, assistant, metrics, transfers and SFTP edits, forwarding, histories, and live activities. Production uses `Termind.store` under Application Support; tests or supplied repositories use an in-memory container and do not enable production live activities or sync coordinators. +`MobileAppModel` creates or receives its `ModelContainer`, then builds the host repository, outbox, Keychain or in-memory secret store, settings/metrics persistence and sync coordinators, host and credential libraries, AI/skills/memory, assistant, metrics, transfers and SFTP edits, forwarding, histories, the generic device directory, and live activities. The device directory and connection history use separate repositories on the same Vault context and share the stable installation ID; each repository journals its own payload kind transactionally. Workspace publication starts an event and explicit close records its end. Production uses `Termind.store` under Application Support; tests or supplied repositories use an in-memory container and do not enable production live activities or sync coordinators. The app model wires cross-service invalidation at construction. Host deletion and route changes invalidate edits, cancel transfers, stop/reconfigure forwarding, remove relevant metrics/history, and disconnect or close live workspaces as appropriate. Remote sync application similarly computes a change plan before replacing the local projection, so route-affecting changes retire affected runtime work rather than leaving it attached to stale configuration. @@ -157,7 +161,7 @@ Mobile persistence deliberately restores **descriptors, not sessions**. On cold Scene phases are aggregated by scene ID. The app only enters global background behavior when every tracked scene is backgrounded, so one backgrounded scene cannot suspend work still visible in another. On that transition it stops edit monitoring, suspends memory and sync, pauses metrics and outbox work, suspends recording, and starts a finite platform background task. If the task expires, it asks each live workspace to handle background expiration, stops forwarding, pauses transfers, and expires assistant work. It updates Live Activities but does not use them to retain connections. -When any scene becomes active, mobile reenables edit monitoring, finishes the background task, asks existing workspaces to resume, reconciles sync according to foreground entitlement, restarts the outbox and metrics, refreshes entitlement asynchronously, and updates activities. Returning before expiration therefore preserves healthy connections; after expiration, workspace recovery follows the workspace's reconnection journey. Expired forwarding and pending interactive authentication are stopped rather than silently restarted on foreground. +When any scene becomes active, mobile refreshes its current device-directory record, reenables edit monitoring, finishes the background task, asks existing workspaces to resume, reconciles sync according to foreground entitlement, restarts the outbox and metrics, refreshes entitlement asynchronously, and updates activities. Returning before expiration therefore preserves healthy connections; after expiration, workspace recovery follows the workspace's reconnection journey. Expired forwarding and pending interactive authentication are stopped rather than silently restarted on foreground. ## Ownership invariants and safe extension points diff --git a/openwiki/architecture/shared-module-directories.md b/openwiki/architecture/shared-module-directories.md index b82a91ca..08e06b7a 100644 --- a/openwiki/architecture/shared-module-directories.md +++ b/openwiki/architecture/shared-module-directories.md @@ -16,14 +16,20 @@ sources: resource: repo://Frameworks/TermindApplication/Agent/AssistantExecutionController.swift - id: openwiki-source-cb178f0d078ab3218679c80e resource: repo://Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift + - id: openwiki-source-e09fb7bc8e7ca0579f6d446e + resource: repo://Frameworks/TermindApplication/FeatureModels/DeviceDirectoryModel.swift - id: openwiki-source-4143deeb06ebc3198de62e2b resource: repo://Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift + - id: openwiki-source-6f388236eaa573c3c2ba7706 + resource: repo://Frameworks/TermindApplication/Repositories/DeviceDirectoryRepository.swift - id: openwiki-source-ced5c103bc80a375eeb8bcef resource: repo://Frameworks/TermindApplication/Repositories/VaultStore.swift - id: openwiki-source-d593fb9c62c3736497a39486 resource: repo://Frameworks/TermindApplication/Transfers/RemoteFileSystem.swift - id: openwiki-source-f402125a513db26af8c36b3e resource: repo://Frameworks/TermindDomain/Credentials/Identity.swift + - id: openwiki-source-7367aa06bc3b7ce470e78113 + resource: repo://Frameworks/TermindDomain/DeviceRecord.swift - id: openwiki-source-9bd2b93db78747d6956c2405 resource: repo://Frameworks/TermindDomain/Hosts/Host.swift - id: openwiki-source-3626532038772ef56874c17e @@ -60,6 +66,8 @@ sources: resource: repo://Frameworks/TermindPersistence/GRDBMemoryStore.swift - id: openwiki-source-fe1dc993163bb9ff61397be7 resource: repo://Frameworks/TermindPersistence/HostPersistence.swift + - id: openwiki-source-0b6ab59ec6a684de8101ff8e + resource: repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift - id: openwiki-source-7a9c6d90cba4ebd1d1398673 resource: repo://Frameworks/TermindPersistence/TermindPersistence.swift - id: openwiki-source-2c20dfd15c3c8143bab58304 @@ -98,10 +106,10 @@ sources: resource: repo://Termind.xcodeproj/project.pbxproj - id: openwiki-source-d3d065330c730839f5cc6112 resource: repo://TFMTests/ArchitectureBoundaryTests.swift -generated: { by: "omp", at: "2026-09-22T05:22:55.650Z" } +generated: { by: "codex", at: "2026-09-22T09:17:11.491Z" } verified: - by: openwiki/0.5.2 - at: 2026-09-22T05:22:55.650Z + at: 2026-09-22T10:02:02.985Z --- # Shared Framework Directory Responsibilities @@ -116,7 +124,7 @@ Domain describes what the system means, not how a database, socket, or native vi | Directory | Responsibility | | --- | --- | -| Root files | Shared saved-name normalization/defaulting and tag-color values. | +| Root files | Shared saved-name normalization/defaulting, tag-color values, and the portable `DeviceRecord` used by the synchronized installation directory. | | `AI` | Model/provider form values, API-type selection, and model-editing rules. | | `Agent` | External tool-call audit values; the generic agent loop remains in AgentKit. | | `Commerce` | Pro feature and entitlement/access value types. | @@ -146,10 +154,10 @@ Application depends on Domain and AgentKit abstractions. It exposes the capabili | `Agent` | Conversation/composer/library state, Assistant execution assembly, title/command generation, attachments, slash commands, tool approval/presentation policy, and memory-indexed repository wrapping. It coordinates AgentKit rather than replacing its loop. | | `Connections` | Route preparation and snapshots, external connection URLs, connection journeys, trust/MFA prompts, shared retry state, session host-store boundary, and plans for applying remote Vault changes. | | `Events` | Outbox store contract, retry runner, and host-deletion handlers. | -| `FeatureModels` | Observable libraries/editing policy for hosts, credentials, Known Hosts, snippets, forwarding, histories, AI, skills, memory, SSH import, settings, expansion state, and review prompting. | +| `FeatureModels` | Observable libraries/editing policy for hosts, credentials, Known Hosts, snippets, forwarding, histories, the reusable device directory, AI, skills, memory, SSH import, settings, expansion state, and review prompting. | | `Logging` | Application log-management contract and disabled logging support. | | `Metrics` | Sampling clock, snapshots/read interface, and shared server-monitoring model. | -| `Repositories` | History repository contracts, SSH-import batch/reader boundary, and Vault load/save/transaction coordination. | +| `Repositories` | History repository contracts, including event end/delete semantics; the independent device-directory repository port; SSH-import batch/reader boundary; and Vault load/save/transaction coordination. | | `Speech` | Speech-transcription service/events and coordination contract. Native microphone capture remains injected. | | `Terminal` | Shared terminal Find controller; the native terminal view is not here. | | `Transfers` | Remote-filesystem port and basic file operations, remote-edit model, and workspace upload-notice state. Bulk scheduling belongs to Remote Infrastructure. | @@ -163,7 +171,7 @@ This module currently has **no functional subdirectories**. Its root spans sever | Root-level responsibility group | What it owns | | --- | --- | | Schema and migration | Current SwiftData schema, frozen historical representations, migration plan, and the sole shared container factory. | -| Vault and histories | Persistent Host/credential metadata and related Vault records; command, connection, and SFTP history repositories. | +| Vault and histories | Persistent Host/credential metadata and related Vault records; command and SFTP local histories; separate synchronized connection-event and generic device-directory repositories; and Vault journal integration. | | AI, Agent, skills, and audit | Provider/configuration records, agent-run/message replay, tool-audit storage, skill packages, and persistence redaction. | | Preferences and sync journals | SSH/SFTP/terminal settings, metrics/settings/Vault local stores, mutation journals, scoped upload restrictions, recovery checkpoints, and sync-facing persistence adapters. | | Secret metadata/adapters | AI secret references and storage policy bridged to the injected Keychain service; not a plaintext secret database. | @@ -184,7 +192,7 @@ Details: [Persistent Data, Migrations, and Secrets](../concepts/persistence-and- | `Logging` | Launch-scoped file logging and log-file lifecycle. | | `Speech` | Apple speech-recognition implementation plus an audio-input abstraction; each app supplies its native audio capture. | -This layer can use Persistence. It does not depend on either native app, and a CloudKit coordinator is not a screen-level settings model. The shared safety adapter projects scoped persistent restrictions and recovery state into the lifecycle; it does not merge the categories' engines, accounts, subscriptions or journals. V31 adds the safety storage locally, without changing the existing CloudKit record formats. Both received records and save-conflict replies use the same validation path before allowing a blocked record to become uploadable again. +This layer can use Persistence. It does not depend on either native app, and a CloudKit coordinator is not a screen-level settings model. The shared safety adapter projects scoped persistent restrictions and recovery state into the lifecycle; it does not merge the categories' engines, accounts, subscriptions or journals. V31 added safety storage locally; V32 replaces current Host/connection-history shapes, adds the generic device-directory record, and keeps the existing CloudKit record format. Both received records and save-conflict replies use the same validation path before allowing a blocked record to become uploadable again. ## TermindRemoteInfrastructure — protocol and remote-operation machinery diff --git a/openwiki/architecture/supporting-system-directories.md b/openwiki/architecture/supporting-system-directories.md index 6b825c19..0c290e8f 100644 --- a/openwiki/architecture/supporting-system-directories.md +++ b/openwiki/architecture/supporting-system-directories.md @@ -3,9 +3,6 @@ type: architecture directory guide title: Supporting Products, Packages, and Tooling description: Directory responsibilities and integration relationships for AgentKit, the MCP bridge, Live Activities, tests, benchmarks, the Astro site, marketing assets, scripts, and repository automation. tags: [architecture, directories, agentkit, mcp, widgets, testing, website, tooling] -verified: - - by: openwiki/0.5.2 - at: 2026-09-22T03:45:03.442Z sources: - id: openwiki-source-6d4b4e707b8d60b6ccfa3425 resource: repo://.github/workflows/openwiki-update.yml @@ -82,6 +79,9 @@ sources: - id: openwiki-source-0f8d0f9d08d6674698dd054c resource: repo://WorkspaceActivitySupport/WorkspaceActivityAttributes.swift generated: { by: "omp", at: "2026-09-22T03:45:03.442Z" } +verified: + - by: openwiki/0.5.2 + at: 2026-09-22T10:02:02.985Z --- # Supporting Products, Packages, and Tooling diff --git a/openwiki/architecture/system-boundaries.md b/openwiki/architecture/system-boundaries.md index 3f6d8472..8d50d44b 100644 --- a/openwiki/architecture/system-boundaries.md +++ b/openwiki/architecture/system-boundaries.md @@ -6,6 +6,18 @@ tags: [architecture, modularity, dependencies, persistence, agentkit, swift] sources: - id: openwiki-source-8037e2358a2c4f9b2c722a11 resource: repo://AGENTS.md + - id: openwiki-source-9ee5ed32f6974f5906438e59 + resource: repo://Frameworks/TermindApplication/Repositories/ConnectionHistoryRepository.swift + - id: openwiki-source-6f388236eaa573c3c2ba7706 + resource: repo://Frameworks/TermindApplication/Repositories/DeviceDirectoryRepository.swift + - id: openwiki-source-e4f5ea9b30063cdc7d027956 + resource: repo://Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift + - id: openwiki-source-7367aa06bc3b7ce470e78113 + resource: repo://Frameworks/TermindDomain/DeviceRecord.swift + - id: openwiki-source-4217f56e034013cb8704d525 + resource: repo://Frameworks/TermindPersistence/SwiftDataConnectionHistoryRepository.swift + - id: openwiki-source-0b6ab59ec6a684de8101ff8e + resource: repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift - id: openwiki-source-7a9c6d90cba4ebd1d1398673 resource: repo://Frameworks/TermindPersistence/TermindPersistence.swift - id: openwiki-source-1c845e5af67c400566be83bd @@ -24,10 +36,10 @@ sources: resource: repo://TFM/App/TermindApp.swift - id: openwiki-source-d3d065330c730839f5cc6112 resource: repo://TFMTests/ArchitectureBoundaryTests.swift -generated: { by: "omp", at: "2026-09-22T05:22:55.650Z" } +generated: { by: "codex", at: "2026-09-22T10:02:02.985Z" } verified: - by: openwiki/0.5.2 - at: 2026-09-22T05:22:55.650Z + at: 2026-09-22T10:02:02.985Z --- # System Boundaries and Dependency Ownership @@ -87,7 +99,9 @@ The checks also prohibit all shared frameworks from importing `Termind` or `Term ## Persistence is a single-owner boundary -`TermindPersistence` exports the complete current schema (`TermindSchemaV31`) and builds a `ModelContainer` using its migration plan. Its factory rejects an in-memory request that also supplies a file URL and configures the store with CloudKit disabled; platform composition roots choose *where* the store lives and retain the container, but do not define the model set or construct a substitute container. +`TermindPersistence` exports the complete current schema (`TermindSchemaV32`) and builds a `ModelContainer` using its migration plan. Its factory rejects an in-memory request that also supplies a file URL and configures the store with CloudKit disabled; platform composition roots choose *where* the store lives and retain the container, but do not define the model set or construct a substitute container. + +Connection history follows the same boundary: Domain owns the portable event value, Application owns the observable model and repository protocol, and Persistence owns its SwiftData shape and Vault-journal transaction. The reusable ID-to-name mapping is deliberately separate: Domain owns `DeviceRecord`, Application owns `DeviceDirectoryModel` and `DeviceDirectoryRepository`, and Persistence owns `PersistentDeviceRecord` plus the SwiftData adapter. A history event merely references `sourceDeviceID`; it does not own or capture the device name. Platform apps inject connection lifecycles, acquire their native system display name, and choose local versus all-device presentation; they do not declare stored models. This separation matters for migrations: all SwiftData models, frozen historical schemas, migration plans, and container construction stay together in Persistence. The boundary test rejects `@Model` declarations everywhere else and requires Persistence to contain Swift files. A new persisted SwiftData entity therefore requires a Persistence schema/migration decision—not a model embedded in a feature, app, or general infrastructure module. @@ -95,7 +109,7 @@ Both app roots call `TermindPersistence.makeContainer`. Desktop turns an unavail ## Composition, resource, and state ownership -The app targets are composition roots. `TermindApp` creates the desktop `ModelContainer`, keychain store, sync persistence/coordinators, repositories, connection coordinator, logging, and app-wide `AppModel`; it injects the shared model and container into its window scenes. `TermindIOSApp` chooses live or testing dependencies, creates one startup `MobileAppModel`, and injects that model's container into its `WindowGroup`. This is where a concrete implementation becomes a port supplied to feature code. +The app targets are composition roots. `TermindApp` creates the desktop `ModelContainer`, keychain store, sync persistence/coordinators, repositories, connection coordinator, logging, and app-wide `AppModel`; it also adapts DeviceKit's `Device.current.safeDescription` into the shared device-directory port. It injects the shared model and container into its window scenes. `TermindIOSApp` chooses live or testing dependencies, creates one startup `MobileAppModel`, and injects that model's container into its `WindowGroup`; the mobile composition uses the same DeviceKit description for the device-directory port. This is where a concrete implementation or platform value becomes a port supplied to shared feature code. Do not use this as permission to move live state into the app-wide layer. Durable stores and cross-window coordinators are app-wide; desktop navigation and workspaces are window-owned; mobile navigation is scene-owned; live SSH/SFTP transports remain with the workspace/session that opened them. Refer to [Platform Runtime, App Composition, and Lifetimes](platform-runtime.md) for the detailed ownership map. diff --git a/openwiki/concepts/persistence-and-secrets.md b/openwiki/concepts/persistence-and-secrets.md index 70082cd3..3e67dba0 100644 --- a/openwiki/concepts/persistence-and-secrets.md +++ b/openwiki/concepts/persistence-and-secrets.md @@ -18,8 +18,6 @@ sources: resource: repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncPersistenceSafety.swift - id: openwiki-source-8a2e7d4db4b334995f8eaf61 resource: repo://Frameworks/TermindPersistence/AgentPersistence.swift - - id: openwiki-source-1d9bb30e44318b5d16259035 - resource: repo://Frameworks/TermindPersistence/AIPersistence.swift - id: openwiki-source-ec0a64adad50721ec500ebc2 resource: repo://Frameworks/TermindPersistence/AISecretStore.swift - id: openwiki-source-1366e71322413c98e0ac1534 @@ -30,20 +28,24 @@ sources: resource: repo://Frameworks/TermindPersistence/HostPersistence.swift - id: openwiki-source-65a2da12a42f42fb0850078e resource: repo://Frameworks/TermindPersistence/MetricsSyncStore.swift - - id: openwiki-source-8a05ed491f0965e8444981d6 - resource: repo://Frameworks/TermindPersistence/ModelContext%2BCommit.swift - id: openwiki-source-6df73854db20ee83905a634c resource: repo://Frameworks/TermindPersistence/SettingsSyncJournalStore.swift - id: openwiki-source-28d6772acb1a409d29ff1973 resource: repo://Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift - id: openwiki-source-9eb2234b02897ade8e405374 resource: repo://Frameworks/TermindPersistence/SwiftDataAISecretStore.swift + - id: openwiki-source-4217f56e034013cb8704d525 + resource: repo://Frameworks/TermindPersistence/SwiftDataConnectionHistoryRepository.swift + - id: openwiki-source-0b6ab59ec6a684de8101ff8e + resource: repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift - id: openwiki-source-63e25c47c9c7d38b354f4123 resource: repo://Frameworks/TermindPersistence/SwiftDataHostRepository.swift - id: openwiki-source-bc351b6c699ff4bbf9f8c2b1 resource: repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift - id: openwiki-source-7a9c6d90cba4ebd1d1398673 resource: repo://Frameworks/TermindPersistence/TermindPersistence.swift + - id: openwiki-source-8fda0c115d3df709fffe110c + resource: repo://Frameworks/TermindPersistence/TermindSchemaV29HostModels.swift - id: openwiki-source-e918380c206900db181ca1d8 resource: repo://Frameworks/TermindPersistence/TermindSchemaV30KnownHostModels.swift - id: openwiki-source-bf7a64e7c819eb9279e2f9e4 @@ -62,10 +64,10 @@ sources: resource: repo://TFM/App/TermindApp.swift - id: openwiki-source-1d1efb20e239b1f2142f6cb7 resource: repo://TFMTests/SettingsSyncTests.swift -generated: { by: "omp", at: "2026-09-22T05:22:55.650Z" } +generated: { by: "codex", at: "2026-09-22T09:17:11.491Z" } verified: - by: openwiki/0.5.2 - at: 2026-09-22T05:49:38.238Z + at: 2026-09-22T10:02:02.985Z --- # Persistent Data, Migrations, and Secret Boundaries @@ -92,7 +94,7 @@ This diagram distinguishes durable SwiftData and GRDB records from Keychain mate ## Store ownership and startup -`TermindPersistence.makeContainer(inMemory:url:)` is the single SwiftData entry point. It exposes the complete `TermindSchemaV31` schema, attaches `TermindMigrationPlan`, disables SwiftData CloudKit integration for this store, and rejects the invalid combination of an in-memory store and a file URL. Both apps retain one container for the process and construct separate `ModelContext` instances where independent repository contexts are needed; they do not assemble a partial schema per feature or scene. +`TermindPersistence.makeContainer(inMemory:url:)` is the single SwiftData entry point. It exposes the complete `TermindSchemaV32` schema, attaches `TermindMigrationPlan`, disables SwiftData CloudKit integration for this store, and rejects the invalid combination of an in-memory store and a file URL. Both apps retain one container for the process and construct separate `ModelContext` instances where independent repository contexts are needed; they do not assemble a partial schema per feature or scene. The desktop root makes container opening part of initialization and uses a `KeychainSecretStore` for live composition. The mobile root creates its Application Support parent before opening `Termind.store`, or chooses an in-memory container and `InMemorySecretStore` for test/injected configurations. Store-open errors propagate out of these composition paths rather than being silently replaced with a new empty database: data availability is an explicit startup failure condition. @@ -106,7 +108,7 @@ Agent conversations use a separate SwiftData repository with similarly deliberat ## Schema evolution: preserve the past exactly -Historical SwiftData model shapes are deliberately frozen. A `VersionedSchema` hashes the types it names, so retroactively editing a type used by an already shipped schema can make an installed store unopenable rather than migrated. The migration plan therefore retains every schema from V1 through V31 and stages each adjacent migration. When a persisted shape changes, introduce a version-specific model and replace it only in the new schema; do not “clean up” an old model definition. +Historical SwiftData model shapes are deliberately frozen. A `VersionedSchema` hashes the types it names, so retroactively editing a type used by an already shipped schema can make an installed store unopenable rather than migrated. The migration plan therefore retains every schema from V1 through V32 and stages each adjacent migration. When a persisted shape changes, introduce a version-specific model and replace it only in the new schema; do not “clean up” an old model definition. Examples show why this matters: @@ -114,9 +116,18 @@ Examples show why this matters: - V11’s provider model remains frozen while V12 changes address representation; legacy columns in the live provider model are folded on read to preserve the former endpoint behavior. - V30 removes the obsolete persisted Known Host mismatch flag by replacing the V29 known-host model, while retaining hostname, port, algorithm, fingerprint, and date. - V31 additively introduces scoped CloudKit quarantine and recovery models with a lightweight V30→V31 migration. The existing V1–V30 model shapes remain unchanged; the local schema increment does not change the V1 CloudKit envelope formats. +- V32 replaces the live Host and connection-history representations and adds the generic device-directory representation. The Host replacement removes its obsolete `lastUsedAt` column; this does not remove Identity `lastUsedAt`, which remains local credential-use metadata. The custom migration preserves events, assigns the stable installation ID to their `sourceDeviceID`, and inserts a separate `DeviceRecord` for that installation. The event itself receives no hostname field, and the CloudKit envelope format remains V1. `PersistenceCompatibilityTests` opens original V1 and V14 fixture stores and verifies their data survives; it also checks a file store can reopen, that in-memory containers use the full schema but remain independent, and that representative version upgrades retain data while giving new fields their intended defaults. Treat these compatibility fixtures as a release contract when changing entities, defaults, migration stages, or storage enum raw values. +### Synchronized connection events and device directory + +`SwiftDataConnectionHistoryRepository` stores each saved-host SSH, Telnet or SFTP attempt as an independent event with stable ID, Host ID, protocol, start time, `sourceDeviceID` and optional end time. Every insert/end/delete/clear reconciles the complete local event set with `VaultSyncJournal` inside the same `ModelContext` commit, so a successful history mutation and its envelope or tombstone cannot diverge. The repository and remote-merge path sort by start time and UUID, then deterministically retain the newest 300. The 100-row History limit is presentation-only. + +`SwiftDataDeviceDirectoryRepository` independently persists reusable `DeviceRecord` values that map installation IDs to mutable display names. Its upsert and `.device` journal envelope commit together. Device records are not dropped when a Host is deleted, History is cleared, or the 300-event retention rule prunes old events; this lets any feature resolve the current name for a stable installation ID. A system-name change updates one directory row, so all connection events and future consumers display the new name without event rewrites. + +The old Host `lastUsedAt` column is not a replacement source for this timeline and is removed from the live V32 Host model. Identity `lastUsedAt` below is a separate device-local signal about successful saved-secret reads. + ## Cloud sync restrictions are durable local state The current safety store has two responsibilities: diff --git a/openwiki/concepts/remote-services-and-forwarding.md b/openwiki/concepts/remote-services-and-forwarding.md index 92d72145..f8c71976 100644 --- a/openwiki/concepts/remote-services-and-forwarding.md +++ b/openwiki/concepts/remote-services-and-forwarding.md @@ -4,9 +4,6 @@ title: "Remote services and forwarding" description: "Cross-service ownership of SSH terminal, metrics, SFTP, Agent, and forwarding runtimes, with routing to detailed channel scheduling, shell integration, and multi-connection dispatch mechanisms." tags: [ssh, terminal, metrics, sftp, port-forwarding, scheduling] openwiki_generated: true -verified: - - by: openwiki/0.5.2 - at: 2026-09-22T03:23:14.035Z sources: - id: openwiki-source-ca49232858afa421bf33a03b resource: repo://Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -37,6 +34,9 @@ sources: - id: openwiki-source-ec3ec372dd69a7df3113c6b1 resource: repo://TFMTests/SSHMaxSessionsPolicyTests.swift generated: { by: "omp", at: "2026-09-22T02:43:23.140Z" } +verified: + - by: openwiki/0.5.2 + at: 2026-09-22T05:59:31.338Z --- diff --git a/openwiki/concepts/shell-integration.md b/openwiki/concepts/shell-integration.md index eafd3f41..3d585738 100644 --- a/openwiki/concepts/shell-integration.md +++ b/openwiki/concepts/shell-integration.md @@ -3,9 +3,6 @@ type: implementation guide title: "Shell Integration: Bootstrap, OSC Events, and Terminal State" description: "How Termind installs session-local Bash, Zsh, and Fish hooks over the PTY, hides bootstrap exchanges, decodes OSC events, and drives cwd, history, startup commands, and command composition on macOS and iOS." tags: [ssh, terminal, shell-integration, osc, command-history, charset, lifecycle] -verified: - - by: openwiki/0.5.2 - at: 2026-09-22T03:23:14.035Z sources: - id: openwiki-source-209392b5f97bd72d0bd87a15 resource: repo://Frameworks/TermindApplication/Agent/TerminalCommandDraftController.swift @@ -23,7 +20,12 @@ sources: resource: repo://TFMTests/CommandHistoryTests.swift - id: openwiki-source-d94aebde8a56adf5ef02c658 resource: repo://TFMTests/HostShellIntegrationSessionTests.swift + - id: openwiki-source-b1446950c4c60caef12aa43a + resource: repo://TFMTests/ShellIntegrationBootstrapTests.swift generated: { by: "omp", at: "2026-09-22T02:43:23.140Z" } +verified: + - by: openwiki/0.5.2 + at: 2026-09-22T05:59:31.338Z --- # Shell Integration: Bootstrap, OSC Events, and Terminal State diff --git a/openwiki/concepts/ui-design-principles.md b/openwiki/concepts/ui-design-principles.md index 4084d862..3a5bac24 100644 --- a/openwiki/concepts/ui-design-principles.md +++ b/openwiki/concepts/ui-design-principles.md @@ -3,9 +3,6 @@ type: design guide title: UI Design Principles and Shared Presentation Boundaries description: "Source-backed UI principles for TFM and TFI: semantic visual systems, native interaction, shared forms and presentation values, stable rendering identity, accessibility, localization and safe extension boundaries." tags: [ui, design-system, presentation, accessibility, localization, macos, ios] -verified: - - by: openwiki/0.5.2 - at: 2026-09-22T03:23:14.035Z sources: - id: openwiki-source-400e4d9303a3f9d98f90660d resource: repo://Frameworks/TermindPresentation/AssistantTranscriptEntry.swift @@ -48,6 +45,9 @@ sources: - id: openwiki-source-dceb8d7e7c5bb09359e70052 resource: repo://TFMTests/ResponsivePageLayoutTests.swift generated: { by: "omp", at: "2026-09-22T02:51:54.212Z" } +verified: + - by: openwiki/0.5.2 + at: 2026-09-22T09:51:39.958Z --- # UI Design Principles and Shared Presentation Boundaries diff --git a/openwiki/integrations/mcp-server-and-bridge.md b/openwiki/integrations/mcp-server-and-bridge.md index 48cb4059..cd2c2b56 100644 --- a/openwiki/integrations/mcp-server-and-bridge.md +++ b/openwiki/integrations/mcp-server-and-bridge.md @@ -3,9 +3,6 @@ type: integration guide title: MCP Server, Transfer APIs, and Stdio Bridge description: How the macOS-only Termind MCP listener authenticates local clients, executes host tools through external UI authentication, and provides scoped HTTP SFTP transfers. Covers the bundled termind-mcp stdio bridge and MCP session lifecycle. tags: [mcp, macos, loopback, authentication, sftp, bridge] -verified: - - by: openwiki/0.5.2 - at: 2026-09-22T03:23:14.035Z sources: - id: openwiki-source-1fc00d0407016efa5a0378eb resource: repo://Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift @@ -44,6 +41,9 @@ sources: - id: openwiki-source-47dc468698b2904f0004ee04 resource: repo://TFMTests/MCPUploadTests.swift generated: { by: "openwiki/0.5.2", at: "2026-09-22T00:31:10.149Z" } +verified: + - by: openwiki/0.5.2 + at: 2026-09-22T09:51:39.958Z --- # MCP Server, Transfer APIs, and Stdio Bridge diff --git a/openwiki/operations/cloudkit-release-readiness.md b/openwiki/operations/cloudkit-release-readiness.md index 514c49b0..a9bd9a93 100644 --- a/openwiki/operations/cloudkit-release-readiness.md +++ b/openwiki/operations/cloudkit-release-readiness.md @@ -3,16 +3,9 @@ type: release-readiness title: CloudKit Schema Deployment and Signed-Device Release Readiness description: Production CloudKit schema, compatibility, and signed-device checks for Vault, Settings, SFTP preferences, Metrics, and Keychain-backed secrets. tags: [cloudkit, sync, release, settings, vault, metrics, sftp] -verified: - - by: openwiki/0.5.2 - at: 2026-09-22T05:22:55.650Z sources: - id: openwiki-source-9fbc733f79d51a7265ee9e4a resource: repo://Frameworks/TermindApplication/SettingsKey.swift - - id: openwiki-source-9489f42c5779904e0b51c8fc - resource: repo://Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift - - id: openwiki-source-36d16adefef02f0ed0cdc428 - resource: repo://Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift - id: openwiki-source-46ef2fcd9e3aba751015021b resource: repo://Frameworks/TermindDomain/Transfers/SFTPPreferences.swift - id: openwiki-source-a9afccb62e1c69e5a0fbf46c @@ -35,6 +28,8 @@ sources: resource: repo://Frameworks/TermindPersistence/MetricsSyncJournal.swift - id: openwiki-source-2dc6b29f02b74b32c0eb786e resource: repo://Frameworks/TermindPersistence/SettingsSyncJournal.swift + - id: openwiki-source-bc351b6c699ff4bbf9f8c2b1 + resource: repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift - id: openwiki-source-3cfc52217a7a6835b4bffac1 resource: repo://Frameworks/TermindPersistence/VaultSyncPersistence.swift - id: openwiki-source-bf7a64e7c819eb9279e2f9e4 @@ -57,7 +52,12 @@ sources: resource: repo://TFI/TFI.entitlements - id: openwiki-source-f5ab2303a67ae18cbe2f59f2 resource: repo://TFM/TFM.entitlements -generated: { by: "omp", at: "2026-09-22T05:22:55.650Z" } + - id: openwiki-source-960109cd5bc0de7a879c1e16 + resource: repo://TFMTests/VaultSyncTests.swift +generated: { by: "codex", at: "2026-09-22T09:17:11.491Z" } +verified: + - by: openwiki/0.5.2 + at: 2026-09-22T09:51:39.958Z --- # CloudKit Schema Deployment and Signed-Device Release Readiness @@ -78,6 +78,8 @@ Validate the Development schema first, then explicitly deploy it to Production. Vault records use the same private container through their own coordinator and compatibility contract. A Vault envelope stores integer `formatVersion` and `counter`, Boolean `isDeleted`, strings `kind`, `entityID`, `writerID`, and `payloadHash`, date `modifiedAt`, inline bytes `payload`, or `payloadAsset` for payloads above the 700,000-byte inline limit. Preserve zone names, record types, subscription identifiers, envelope fields, and format compatibility: older clients must be able to ignore the distinct `SFTPPreferencesItem` record type before attempting to decode it. +Connection-history events and the reusable device directory use separate `connectionHistory` and `device` payload kinds inside the existing Vault `VaultItem` record type; neither requires a new CloudKit zone, record type, field or Production schema deployment. Release validation still needs mixed-version coverage because older clients cannot decode those payload kinds safely until the quarantine-capable build is deployed. On signed devices verify event start/end updates and tombstones, local-only Recents versus all-device History, and deterministic convergence to the newest 300 events. Independently rename a device and verify that one `device` update changes every history row resolving its stable ID, while event pruning, per-Host deletion and Clear History leave device records intact. + There is no public-database access in this design. Before release, verify the app entitlements, the private-container Development schema, and the deployed Production schema with credentials that actually have CloudKit management access. An unavailable management token or a successful local test is not deployment evidence. ## Signed-device matrix diff --git a/openwiki/operations/marketing-assets-and-app-icons.md b/openwiki/operations/marketing-assets-and-app-icons.md index 19b3f555..877b472b 100644 --- a/openwiki/operations/marketing-assets-and-app-icons.md +++ b/openwiki/operations/marketing-assets-and-app-icons.md @@ -47,7 +47,7 @@ sources: generated: { by: "codex", at: "2026-09-22T01:13:21.371Z" } verified: - by: openwiki/0.5.2 - at: 2026-09-22T03:23:14.035Z + at: 2026-09-22T10:02:02.985Z --- # Marketing Assets, Screenshot Production, Figma Exports, and App Icons diff --git a/openwiki/operations/port-forwarding-benchmarks.md b/openwiki/operations/port-forwarding-benchmarks.md index 846a654a..94e9e390 100644 --- a/openwiki/operations/port-forwarding-benchmarks.md +++ b/openwiki/operations/port-forwarding-benchmarks.md @@ -19,7 +19,7 @@ sources: generated: { by: "codex", at: "2026-09-22T01:13:21.371Z" } verified: - by: openwiki/0.5.2 - at: 2026-09-22T03:23:14.035Z + at: 2026-09-22T10:02:02.985Z --- # Port Forwarding Benchmark Design, Results, and Reproduction diff --git a/openwiki/quickstart.md b/openwiki/quickstart.md index ef954c92..e920f9da 100644 --- a/openwiki/quickstart.md +++ b/openwiki/quickstart.md @@ -25,7 +25,7 @@ sources: generated: { by: "omp", at: "2026-09-22T03:23:14.035Z" } verified: - by: openwiki/0.5.2 - at: 2026-09-22T05:22:55.650Z + at: 2026-09-22T10:02:02.985Z --- # Termind Engineering Quickstart diff --git a/openwiki/testing/behavioral-boundaries-and-fixtures.md b/openwiki/testing/behavioral-boundaries-and-fixtures.md index ef22c1e5..38ed97a8 100644 --- a/openwiki/testing/behavioral-boundaries-and-fixtures.md +++ b/openwiki/testing/behavioral-boundaries-and-fixtures.md @@ -26,20 +26,28 @@ sources: resource: repo://TFI/Infrastructure/MobileWorkspaceStore.swift - id: openwiki-source-b822b041afbf829d09a8555a resource: repo://TFITests/MobileAppTests.swift + - id: openwiki-source-7a29627adf93e3da4a56dd5d + resource: repo://TFITests/MobileCardRenderingTests.swift + - id: openwiki-source-16ec96bd78162e7e62effd0e + resource: repo://TFITests/MobileConnectionPickerTests.swift - id: openwiki-source-9b78a7e39bfa08bea5192722 resource: repo://TFITests/MobileWorkspaceStoreTests.swift - id: openwiki-source-cfbc34def649c94f760c5695 resource: repo://TFMTests/AgentHostAuthenticationTests.swift - id: openwiki-source-d3d065330c730839f5cc6112 resource: repo://TFMTests/ArchitectureBoundaryTests.swift + - id: openwiki-source-c15cd5bef4646aaf64df407e + resource: repo://TFMTests/ConnectionHistoryTests.swift - id: openwiki-source-53e2b2fd2e661123f63e69e1 resource: repo://TFMTests/SFTPTransferContextTests.swift + - id: openwiki-source-960109cd5bc0de7a879c1e16 + resource: repo://TFMTests/VaultSyncTests.swift - id: openwiki-source-a08489338be00125d646c77e resource: repo://TFMTests/VisibleWindowTestTrait.swift -generated: { by: "omp", at: "2026-09-22T05:22:55.650Z" } +generated: { by: "codex", at: "2026-09-22T09:17:11.491Z" } verified: - by: openwiki/0.5.2 - at: 2026-09-22T05:22:55.650Z + at: 2026-09-22T09:51:39.958Z --- # Testing Strategy and Behavioral Boundaries @@ -86,6 +94,8 @@ The safety fixture substitutes only the CloudKit transport. It uses the producti Disk-backed tests separately migrate a V30 store to V31, import legacy quarantine, reopen interrupted state and verify that journal intent and recovery flags survive. Scope tests vary container, environment, account, zone and owner independently. Lifecycle tests hold fetches and inject persistence/lookup failures, exercise overlapping explicit operations and automatic callbacks, and prove that an old or failed engine cannot reopen upload authorization. +Connection-history coverage adds a V31-to-V32 disk migration, Vault codec/merge/tombstone tests, event start/end lifetime checks, stable local-device filtering and deterministic retention of 300 stored events. Migration and sync tests also verify the independent generic device directory: old events receive the installation ID, a separate `DeviceRecord` is created, and changing that record's name updates resolution without rewriting events. Desktop tests distinguish local Welcome Recents from the 100-row all-device sidebar projection. Mobile tests distinguish local per-Host Recents and host-only quick actions from the 100-event Vault History page, including preferred SSH/Telnet activation, disabled protocol actions, device-name search and multilingual light/dark screenshots. Serial remains outside the connection-history enum and mobile workspace routes. + These tests do not prove unknown-field round trips through an actual historical binary, Production schema deployment, or signed-device delivery. Keep those checks in [CloudKit Release Readiness](../operations/cloudkit-release-readiness.md); a fake transport passing is not evidence that iCloud has delivered a record. ### macOS: composition, security, and resource lifetime diff --git a/openwiki/workflows/settings-and-cloud-sync.md b/openwiki/workflows/settings-and-cloud-sync.md index 16a4d0d2..d2eacc00 100644 --- a/openwiki/workflows/settings-and-cloud-sync.md +++ b/openwiki/workflows/settings-and-cloud-sync.md @@ -8,6 +8,8 @@ sources: resource: repo://Frameworks/TermindApplication/SettingsKey.swift - id: openwiki-source-1429ec01ac68a7770ce41977 resource: repo://Frameworks/TermindDomain/Vault/CloudSyncSafety.swift + - id: openwiki-source-a9afccb62e1c69e5a0fbf46c + resource: repo://Frameworks/TermindDomain/Vault/VaultSyncTypes.swift - id: openwiki-source-1d1f503a260f304a969fcee4 resource: repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift - id: openwiki-source-4957f13c836c497e7ebe4656 @@ -32,8 +34,14 @@ sources: resource: repo://Frameworks/TermindPersistence/SettingsSyncJournalStore.swift - id: openwiki-source-9eb2234b02897ade8e405374 resource: repo://Frameworks/TermindPersistence/SwiftDataAISecretStore.swift + - id: openwiki-source-4217f56e034013cb8704d525 + resource: repo://Frameworks/TermindPersistence/SwiftDataConnectionHistoryRepository.swift + - id: openwiki-source-0b6ab59ec6a684de8101ff8e + resource: repo://Frameworks/TermindPersistence/SwiftDataDeviceDirectoryRepository.swift - id: openwiki-source-bc351b6c699ff4bbf9f8c2b1 resource: repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift + - id: openwiki-source-a0d614d618b11f103ec246ae + resource: repo://Frameworks/TermindPersistence/SwiftDataVaultStore.swift - id: openwiki-source-3cfc52217a7a6835b4bffac1 resource: repo://Frameworks/TermindPersistence/VaultSyncPersistence.swift - id: openwiki-source-87b90af0bd504e35083a8414 @@ -52,10 +60,10 @@ sources: resource: repo://TFI/App/MobileAppModel.swift - id: openwiki-source-4dbea0ff2808163cb4ecdd8f resource: repo://TFM/App/AppModel.swift -generated: { by: "omp", at: "2026-09-22T05:22:55.650Z" } +generated: { by: "codex", at: "2026-09-22T09:17:11.491Z" } verified: - by: openwiki/0.5.2 - at: 2026-09-22T05:22:55.650Z + at: 2026-09-22T09:17:11.491Z --- # Settings, Vault, Metrics, and Cloud Sync Workflow @@ -68,7 +76,7 @@ This workflow is CloudKit replication built around a SwiftData journal, not Swif | Category | Coordinator and zone | Content boundary | Default control | | --- | --- | --- | --- | -| Vault | `VaultSyncCoordinator`; `TermindVaultV1`; `VaultItem` | Hosts, identities, SSH-key references, known hosts, snippets, forwarding rules, and workspace templates | `vault.sync.enabled` plus `icloud.sync.enabled` | +| Vault | `VaultSyncCoordinator`; `TermindVaultV1`; `VaultItem` | Hosts, identities, SSH-key references, known hosts, snippets, forwarding rules, workspace templates, connection-history events, and the reusable device directory | `vault.sync.enabled` plus `icloud.sync.enabled` | | Settings | `SettingsSyncCoordinator`; `TermindAIConfigurationV1`; `AIConfigurationItem`, `SSHPreferencesItem`, and `SFTPPreferencesItem` | AI configuration, skills, search, terminal, SSH, and SFTP preferences—not credential material | `settings.sync.enabled` plus `icloud.sync.enabled` | | Metrics | `MetricsSyncCoordinator`; `TermindMetricsV1`; `MetricsItem` | Persisted metrics selections, not samples or active monitoring | `metrics.sync.enabled` plus `icloud.sync.enabled` | @@ -131,6 +139,10 @@ Merge is deterministic: newer versions win; an older remote value causes the loc Vault projections deliberately remove device-local presentation/usage metadata from envelopes: group/package expansion is normalized and identity `lastUsedAt` is omitted, while applying a remote identity preserves the local usage timestamp. Settings likewise excludes explicitly device-local defaults such as debug/logging, review history, and terminal font family/size/follow-theme-size. Settings record-type compatibility is intentional: SSH and SFTP preferences use distinct types in the Settings zone so older clients can ignore newer records before decoding. +Connection history is an exception to “configuration-only Vault” by design: each event is an independent `connectionHistory` Vault key using the same `VaultItem` record type and V1 envelope. Existing local events bootstrap even after the original Vault bootstrap completed. Insert, end-time update, deletion and clear reconcile history rows with the journal in one transaction; logical deletions use ordinary envelope tombstones. Remote events may arrive before their referenced Host and remain stored but hidden until that Host exists. After local or remote merge, deterministic start-time/UUID ordering retains the newest 300 and journals tombstones for the overflow, allowing devices to converge. Platform views then choose a stable-source-device local projection or the complete synchronized event stream. + +The reusable device directory is a separate replication stream in the same Vault category. Each `DeviceRecord` uses a `.device` payload keyed by the stable installation ID; its repository upsert and journal envelope commit together independently of the event repository. Platform activation refreshes the current system display name, and app models invalidate the directory cache when remote `.device` keys arrive. Device reconciliation creates or updates records but does not tombstone mappings merely because they are absent locally, and connection-event retention, per-Host deletion, or Clear History never removes them. + ## Per-record compatibility restrictions A scoped quarantine entry is an upload restriction, not only a diagnostic. It retains the received payload, encoded system fields, reason and identity. Its scope prevents a block for one account or environment from leaking into another. Legacy category rows are imported under their prior binding/environment before being removed in a successful local save; unknown historical ownership stays in a legacy scope.