diff --git a/Packages/PeerCore/README.md b/Packages/PeerCore/README.md index c92c866..b637b70 100644 --- a/Packages/PeerCore/README.md +++ b/Packages/PeerCore/README.md @@ -6,6 +6,12 @@ 调用方创建 `PeerDatabase(applicationVersion:name:labels:)`,通过显式 `ConnectionTarget` 与凭据发起操作,结束时 `await database.shutdown()`。构造本身不联网、不注册 Peer;注册和租约是明确的方法调用。借用数据库的 `CaptureWriter` / `LocalFilesRepository` 不关闭它;第一方 App 将一个实例放入 `PeerHostContext`,在各扩展停止后统一关闭。独立消费者可以创建彼此隔离的实例,不继承 App 的 Bundle 身份、机器名称或进程单例。 +持续运行的 Keychain 消费者传递 `DatabaseCredentials.keychain(store)`。`PeerDatabase` 在没有会话时取得一次密码,后续数据库操作与续租直接复用已授权 client,不查询 Keychain。`.password` 和 `password:` 入口同样只提供冷启动凭据;它们不能让旧业务引用覆盖新认证或把运行角色切回旧配置。显式更新认证使用 `database.authorize(target, password:)`,相同密码与数据库配置继续复用 client;`database.invalidate(id)` 或 `shutdown()` 结束会话。独立 Core 消费者应自行持有并关闭数据库实例。 + +App 的 Host、Capture 和 Local Files 共享同一个 `PeerHostContext` 与数据库实例。连接表单及 Capture 待办的密码修复通过 `context.savePassword` 持久化并更新该会话;相同密码及只修改名称/Web 地址继续复用 client。连接表单留空密码保存时通过 `context.reauthorize` 明确重新取密;同密码继续复用 client,失败则撤销会话。Keychain 锁定、删项或外部修改不撤销已建立的会话,重新授权或下次启动才重新检查。 + +`CredentialStore.read` 始终直接读取,不自行缓存。现有凭据继续保存在原 file-based Keychain,不迁移存储或引入对 SDK 宿主签名/entitlement 的新要求。`CredentialStore(service:keychain:)` 可将访问限定到明确的文件型密钥串;省略 keychain 时沿用系统搜索列表。 + `PeerMacUI` 是同一 package 中的应用 UI product,提供宿主 UI 协作和连接表单编辑。仅链接 `PeerCore` 不会拉入该 target。窗口、表单输入与授权交互策略由 App/PeerMacUI 拥有;`NativeExtension` 只要求业务生命周期,不要求显示主窗口。具体扩展的界面仍属于相应扩展。 本次候选尚未发布,`PeerDatabase.shared` 已删除。扩展写入与数据访问接口的构造函数要求传入数据库;表单型 `ConnectionSettings.saveEdited` 的消费者需 `import PeerMacUI`。数据库协议、图形状与扩展业务身份保持原合同。 diff --git a/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift index d230f56..9918740 100644 --- a/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift +++ b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift @@ -6,11 +6,18 @@ extension PeerDatabase { public func join( _ target: ConnectionTarget, password: String, extension descriptor: NativeExtensionDescriptor + ) async throws { + try await join(target, credentials: .password(password), extension: descriptor) + } + + public func join( + _ target: ConnectionTarget, credentials: DatabaseCredentials, + extension descriptor: NativeExtensionDescriptor ) async throws { let defaults = UserDefaults(suiteName: "dev.inkcre.native-participation")! let key = "\(target.peerID).\(descriptor.name)" let firstJoin = !defaults.bool(forKey: key) - let issue: String? = try await withConnection(for: target, password: password) { + let issue: String? = try await withConnection(for: target, credentials: credentials) { connection in try await connection.withTransaction(logger: Self.logger) { connection in if firstJoin { @@ -60,8 +67,15 @@ extension PeerDatabase { public func useBundledVersion( _ descriptor: NativeExtensionDescriptor, target: ConnectionTarget, password: String ) async throws { - try await register(target, password: password) - try await withConnection(for: target, password: password) { connection in + try await useBundledVersion(descriptor, target: target, credentials: .password(password)) + } + + public func useBundledVersion( + _ descriptor: NativeExtensionDescriptor, target: ConnectionTarget, + credentials: DatabaseCredentials + ) async throws { + try await register(target, credentials: credentials) + try await withConnection(for: target, credentials: credentials) { connection in try await connection.withTransaction(logger: Self.logger) { connection in try await connection.query( "INSERT INTO inkcre.extensions(name) VALUES (\(descriptor.name)) ON CONFLICT (name) DO NOTHING", diff --git a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift index 747ac67..42e5a72 100644 --- a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift @@ -75,11 +75,34 @@ public struct PeerHostContext: Sendable { public let directory: URL public let connections: ConnectionSettings public let credentials: CredentialStore - public init(identity: String, directory: URL, database: PeerDatabase = PeerDatabase()) { + public init( + identity: String, directory: URL, database: PeerDatabase = PeerDatabase(), + credentials: CredentialStore? = nil + ) { self.database = database self.identity = identity self.directory = directory connections = ConnectionSettings(url: directory.appendingPathComponent("connection.json")) - credentials = CredentialStore(service: "\(identity).connections") + self.credentials = credentials ?? CredentialStore(service: "\(identity).connections") + } + + /// Saving through the Host updates the same session used by every bundled extension. + public func savePassword(_ password: String, for target: ConnectionTarget) async throws { + try target.validate() + try credentials.save(password, for: target.id, allowInteraction: true) + try await database.authorize(target, password: password) + } + + /// Explicitly reread Keychain; unchanged authentication keeps its client, failure ends the session. + public func reauthorize(_ target: ConnectionTarget, allowInteraction: Bool = true) async throws + { + try target.validate() + do { + let password = try credentials.read(for: target.id, allowInteraction: allowInteraction) + try await database.authorize(target, password: password) + } catch { + await database.invalidate(target.id) + throw error + } } } diff --git a/Packages/PeerCore/Sources/PeerCore/Credentials.swift b/Packages/PeerCore/Sources/PeerCore/Credentials.swift index 8d5ac51..823c20c 100644 --- a/Packages/PeerCore/Sources/PeerCore/Credentials.swift +++ b/Packages/PeerCore/Sources/PeerCore/Credentials.swift @@ -3,32 +3,75 @@ import Security public struct CredentialStore: Sendable { private let service: String + private let keychain: KeychainReference? private static let accessLock = NSLock() + /// An explicit keychain scopes independent consumers and disposable acceptance fixtures. + public init(service: String, keychain: SecKeychain? = nil) { + self.service = service + self.keychain = keychain.map(KeychainReference.init) + } - public init(service: String) { self.service = service } + private final class KeychainReference: @unchecked Sendable { + // Security.framework keychain references are thread-safe; all accesses also use accessLock. + let value: SecKeychain + init(_ value: SecKeychain) { self.value = value } + } - public func save(_ secret: String, for connection: UUID) throws { - Self.accessLock.lock() - defer { Self.accessLock.unlock() } - guard !secret.isEmpty else { - throw PeerError.invalid("请输入数据库密码。") - } - let query: [String: Any] = [ + private var query: [String: Any] { + var result: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: connection.uuidString, ] - let data = Data(secret.utf8) - var status = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) - if status == errSecItemNotFound { - var item = query - item[kSecValueData as String] = data - status = SecItemAdd(item as CFDictionary, nil) + if let keychain { result[kSecMatchSearchList as String] = [keychain.value] } + return result + } + + public func save(_ secret: String, for connection: UUID, allowInteraction: Bool = false) throws + { + try Self.withAccess(allowInteraction: allowInteraction) { + guard !secret.isEmpty else { + throw PeerError.invalid("请输入数据库密码。") + } + var query = query + query[kSecAttrAccount as String] = connection.uuidString + let data = Data(secret.utf8) + var status = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) + if status == errSecItemNotFound { + var item = query + item.removeValue(forKey: kSecMatchSearchList as String) + if let keychain { item[kSecUseKeychain as String] = keychain.value } + item[kSecValueData as String] = data + status = SecItemAdd(item as CFDictionary, nil) + } + guard status == errSecSuccess else { + throw PeerError.storage("Keychain 保存失败(\(status))。") + } } - guard status == errSecSuccess else { throw PeerError.storage("Keychain 保存失败(\(status))。") } } public func read(for connection: UUID, allowInteraction: Bool = false) throws -> String { + try Self.withAccess(allowInteraction: allowInteraction) { + var query = query + query[kSecAttrAccount as String] = connection.uuidString + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecInteractionNotAllowed || status == errSecAuthFailed { + throw PeerError.needsAction("凭据暂不可用,请在连接设置中解锁密钥串或重新保存密码。") + } + guard status == errSecSuccess, let data = result as? Data, + let secret = String(data: data, encoding: .utf8) + else { + throw PeerError.needsAction("无法读取此信息库的凭据,请更新数据库密码。") + } + return secret + } + } + + static func withAccess(allowInteraction: Bool, _ body: () throws -> Result) throws + -> Result + { Self.accessLock.lock() defer { Self.accessLock.unlock() } // File-based Keychain ignores LAContext; serialize and restore its process-wide UI policy. @@ -37,23 +80,20 @@ public struct CredentialStore: Sendable { SecKeychainSetUserInteractionAllowed(allowInteraction) == errSecSuccess else { throw PeerError.storage("无法设置密钥串访问方式。") } defer { SecKeychainSetUserInteractionAllowed(previous.boolValue) } - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: connection.uuidString, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecInteractionNotAllowed || status == errSecAuthFailed { - throw PeerError.needsAction("凭据暂不可用,请在连接设置中解锁密钥串或重新保存密码。") - } - guard status == errSecSuccess, let data = result as? Data, - let secret = String(data: data, encoding: .utf8) - else { - throw PeerError.needsAction("无法读取此信息库的凭据,请更新数据库密码。") + return try body() + } +} + +/// A bootstrap credential source. PeerDatabase resolves it only when no session exists. +public enum DatabaseCredentials: Sendable { + case password(String) + case keychain(CredentialStore) + + /// One-off access for consumers that do not own a reusable database authentication session. + public func read(for connection: UUID) throws -> String { + switch self { + case .password(let password): return password + case .keychain(let store): return try store.read(for: connection) } - return secret } } diff --git a/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift index 54ba280..3b88a10 100644 --- a/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift +++ b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift @@ -26,15 +26,42 @@ public actor PeerDatabase { } public func client(for target: ConnectionTarget, password: String) throws -> PostgresClient { + try client(for: target, credentials: .password(password)) + } + + /// Bootstrap credentials are ignored once a session exists; only authorize changes its password. + public func client(for target: ConnectionTarget, credentials: DatabaseCredentials) throws + -> PostgresClient + { try target.validate() - if let pool = pools[target.id], pool.target.host == target.host, + // An established session owns authentication. Old repositories may still carry old + // bootstrap credentials; ordinary work must never turn those into an authentication update. + if let pool = pools[target.id] { + guard pool.target.hasSameDestination(as: target) else { + throw PeerError.invalid("已授权连接不能改投另一个信息库,请创建新连接。") + } + return pool.client + } + return try authorize(target, password: credentials.read(for: target.id)) + } + + /// Explicitly replace session authentication. Reusing the same password preserves the client. + @discardableResult + public func authorize(_ target: ConnectionTarget, password: String) throws -> PostgresClient { + try target.validate() + let previous = pools[target.id] + if let pool = previous, pool.target.host == target.host, pool.target.port == target.port, pool.target.database == target.database, pool.target.username == target.username, pool.target.tls == target.tls, pool.password == password { + pools[target.id] = Pool( + target: target, + password: password, client: pool.client, task: pool.task, + registered: pool.registered) return pool.client } - let previous = pools.removeValue(forKey: target.id) + pools.removeValue(forKey: target.id) previous?.task.cancel() var configuration = PostgresClient.Configuration( host: target.host, port: target.port, username: target.username, password: password, @@ -51,12 +78,18 @@ public actor PeerDatabase { let client = PostgresClient(configuration: configuration, backgroundLogger: Self.logger) let task = Task { await client.run() } pools[target.id] = Pool( - target: target, password: password, client: client, task: task, + target: target, + password: password, client: client, task: task, registered: previous?.registered == true && previous?.target.hasSameDestination(as: target) == true) return client } + /// End this owner's authorization. The next operation must obtain credentials again. + public func invalidate(_ connection: UUID) { + pools.removeValue(forKey: connection)?.task.cancel() + } + public func shutdown() async { let tasks = pools.values.map(\.task) pools.removeAll() @@ -65,12 +98,17 @@ public actor PeerDatabase { } public func register(_ target: ConnectionTarget, password: String) async throws { - let client = try client(for: target, password: password) + try await register(target, credentials: .password(password)) + } + + public func register(_ target: ConnectionTarget, credentials: DatabaseCredentials) async throws + { + let client = try client(for: target, credentials: credentials) if pools[target.id]?.registered == true { return } let name = self.name let labels = self.labels let applicationVersion = self.applicationVersion - try await withConnection(for: target, password: password) { connection in + try await withConnection(for: target, credentials: credentials) { connection in try await connection.withTransaction(logger: Self.logger) { connection in try await connection.query( """ @@ -90,7 +128,21 @@ public actor PeerDatabase { for target: ConnectionTarget, password: String, _ operation: @escaping @Sendable (PostgresConnection) async throws -> Result ) async throws -> Result { - let client = try await client(for: target, password: password) + try await withConnection(for: target, credentials: .password(password), operation) + } + + public nonisolated func withConnection( + for target: ConnectionTarget, credentials: DatabaseCredentials, + _ operation: @escaping @Sendable (PostgresConnection) async throws -> Result + ) async throws -> Result { + let client = try await client(for: target, credentials: credentials) + return try await withConnection(client: client, operation) + } + + private nonisolated func withConnection( + client: PostgresClient, + _ operation: @escaping @Sendable (PostgresConnection) async throws -> Result + ) async throws -> Result { return try await withThrowingTaskGroup(of: Result.self) { group in group.addTask { try await client.withConnection { connection in @@ -120,7 +172,7 @@ public actor PeerDatabase { for (id, pool) in pools where pool.registered { group.addTask { do { - try await self.withConnection(for: pool.target, password: pool.password) { + try await self.withConnection(client: pool.client) { connection in let rows = try await connection.query( "SELECT inkcre.renew_peer_lease(\(pool.target.peerID),120)", diff --git a/Packages/PeerCore/Sources/PeerMacUI/ConnectionEditing.swift b/Packages/PeerCore/Sources/PeerMacUI/ConnectionEditing.swift index ef32076..a0b0f23 100644 --- a/Packages/PeerCore/Sources/PeerMacUI/ConnectionEditing.swift +++ b/Packages/PeerCore/Sources/PeerMacUI/ConnectionEditing.swift @@ -1,12 +1,11 @@ import Foundation import PeerCore -extension ConnectionSettings { +extension PeerHostContext { public func saveEdited( host: String, port: String, database: String, username: String, tls: ConnectionTarget.TLS, - web: String, password: String, name: String = "", previous: ConnectionTarget?, - credentials: CredentialStore - ) throws -> ConnectionTarget { + web: String, password: String, name: String = "", previous: ConnectionTarget? + ) async throws -> ConnectionTarget { let host = host.trimmingCharacters(in: .whitespacesAndNewlines) guard let port = Int(port) else { throw PeerError.invalid("数据库端口无效。") } let web = web.trimmingCharacters(in: .whitespacesAndNewlines) @@ -21,9 +20,12 @@ extension ConnectionSettings { name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? host : name.trimmingCharacters(in: .whitespacesAndNewlines)) try target.validate() - if !password.isEmpty { try credentials.save(password, for: target.id) } - _ = try credentials.read(for: target.id, allowInteraction: true) - try save(target) + if !password.isEmpty { + try await savePassword(password, for: target) + } else { + try await reauthorize(target) + } + try await connections.save(target) return target } } diff --git a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift index 703ad9e..793cbe6 100644 --- a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift +++ b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift @@ -2,8 +2,7 @@ import PeerCore import SwiftUI public struct PeerConnectionEditor: View { - private let settings: ConnectionSettings - private let credentials: CredentialStore + private let context: PeerHostContext private let onSaved: @MainActor (ConnectionTarget) -> Void private let onCancel: @MainActor () -> Void private let target: ConnectionTarget? @@ -22,13 +21,12 @@ public struct PeerConnectionEditor: View { @FocusState private var addressFocused: Bool public init( - settings: ConnectionSettings, credentials: CredentialStore, target: ConnectionTarget?, + context: PeerHostContext, target: ConnectionTarget?, saveTitle: String = "保存", onCancel: @escaping @MainActor () -> Void = {}, onSaved: @escaping @MainActor (ConnectionTarget) -> Void ) { - self.settings = settings - self.credentials = credentials + self.context = context self.target = target self.saveTitle = saveTitle self.onCancel = onCancel @@ -73,11 +71,10 @@ public struct PeerConnectionEditor: View { Task { defer { saving = false } do { - let saved = try await settings.saveEdited( + let saved = try await context.saveEdited( host: server, port: port, database: database, username: username, tls: tls, - web: web, password: secret, name: name, previous: target, - credentials: credentials) + web: web, password: secret, name: name, previous: target) onSaved(saved) secret = "" dismiss() diff --git a/README.md b/README.md index c177957..362618c 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ open dist/InKCre.app 切换只改变当前操作的信息库。冻结 Capture 待办继续向原目标恢复,已经授权的目录继续监视并收入其原信息库;目录绑定只保存连接引用、Peer 身份和访问书签,后台与待办从完整连接集合读取最新认证配置;不会搬迁、清空材料或将旧目录改投新库。前台目录列表在切换时清空并重新加载,切换完成前禁用其操作。 -凭据使用 macOS file-based Keychain。后台读取禁止系统授权弹窗;密钥串锁定或应用访问被拒绝时,显示需要处理的连接错误,只有主动保存连接时允许交互。访问策略属于进程,因此共享凭据入口串行执行读写并恢复原策略。开发验收应由签名应用保存测试密码,不能用 `security add-generic-password -T` 预置后假定签名分区已正确授权。 +密码持久化于 macOS file-based Keychain。Host、Capture 与 Local Files 共用一个 `PeerHostContext` 和数据库会话;没有会话时取一次密码,日常数据库操作、重试与续租都复用已授权 client,不再查询密码、项目属性或锁定状态。这避免部分 macOS 版本成功取密时持续累积的堆外内存,也不新增存储迁移或 SDK 宿主签名要求。 + +应用内保存新密码立即更新同一连接的会话;相同密码、名称和 Web 地址修改继续复用 client。Keychain 锁定、删项或外部修改不会撤销已经建立的会话,显式重新授权或下次启动才重新检查。连接表单留空密码保存会重新读取 Keychain,适用于外部更新凭据后的重新授权;读取失败会撤销该会话。PeerCore 也提供明确的认证更新与会话撤销入口。后台取密禁止系统授权弹窗,连接表单必要的取密与主动保存允许交互;所有 Keychain 读写串行执行并恢复进程级访问策略。开发验收应由签名应用保存测试密码,不能用 `security add-generic-password -T` 预置后假定签名分区已正确授权。 `⌘,` 打开唯一的原生设置窗口,工具栏提供“连接”“扩展”“采集”三页;采集页集中管理快捷键和系统权限。主窗口 Peer 页提供同一连接选择入口,当前连接编辑打开设置窗口中的原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后通知正在运行的 Capture 更新连接,并在后台检查,不重启扩展或丢弃当前草稿。Host 维护连接的在线状态,扩展在执行时另行检查共享参与;配置已保存或在线不等于扩展可以写入。权限页分别显示辅助功能与屏幕录制的实际许可状态。未配置连接时点击“收入”会打开连接表单;保存或取消都返回原草稿,保存后由用户再次确认收入。 @@ -56,7 +58,7 @@ open dist/InKCre.app 材料累计最多 32 MiB,支持 PNG/JPEG/GIF/WebP,原生数据库通过 bytea 参数保存原图。失败材料可在“待收入”中导出 JSON,导出不含连接凭据。暂不提供修改已经发送的材料或自动舍弃失败项。 -失败记录的“更新密码…”仅更新原连接的 Keychain 项,并按原 UUID、材料与数据库目标重试,不切换当前连接。已收入记录缺少打开地址时,“配置 Web 地址…”仅修改该条记录的展示地址,保存后打开共享内容;记录自身地址优先,同一 Peer 当前连接的 Web 地址仅作补充。 +失败记录的“更新密码…”更新原连接的 Keychain 项与共享会话,并按原 UUID、材料与数据库目标重试,不切换当前连接。首次发送前先准备认证;凭据不可用时仍显示尚未发送,不增加尝试次数。已收入记录缺少打开地址时,“配置 Web 地址…”仅修改该条记录的展示地址,保存后打开共享内容;记录自身地址优先,同一 Peer 当前连接的 Web 地址仅作补充。 待收入记录可“取消收入”,停止后续自动发送并保留原材料、UUID 和目标;“继续收入”先核对原 Capture 根。已发出的请求可能已完成,取消不能撤回共享内容。确认删除待办会停止后续发送并删除本机记录和材料;已经发出的请求仍可能完成,迟到响应不会重新创建本机记录。 diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift index b7ccb0d..0dbf44e 100644 --- a/Sources/InKCreApp/AppMain.swift +++ b/Sources/InKCreApp/AppMain.swift @@ -107,7 +107,7 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI try await reloadConnections() if let target = connection { try await context.database.register( - target, password: context.credentials.read(for: target.id)) + target, credentials: .keychain(context.credentials)) } connectionIssue = nil } catch { connectionIssue = PeerDatabase.failure(error).localizedDescription } @@ -253,7 +253,7 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI do { try await context.database.useBundledVersion( descriptor, target: connection, - password: context.credentials.read(for: connection.id)) + credentials: .keychain(context.credentials)) await localFiles.model.refresh() } catch { throw PeerDatabase.failure(error) } } @@ -297,7 +297,7 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI } let content = NSHostingView( rootView: PeerConnectionEditor( - settings: context.connections, credentials: context.credentials, + context: context, target: target, saveTitle: onFinished == nil ? "保存" : "保存并返回采集", onCancel: close, onSaved: { [weak self] target in self?.changingConnection = true diff --git a/Tests/CaptureMacTests/CaptureInteractionTests.swift b/Tests/CaptureMacTests/CaptureInteractionTests.swift index 8c3588d..81bb365 100644 --- a/Tests/CaptureMacTests/CaptureInteractionTests.swift +++ b/Tests/CaptureMacTests/CaptureInteractionTests.swift @@ -14,7 +14,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.test.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } var mainOpened = false @@ -32,7 +34,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.test.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } let old = CaptureRecord(payload: CapturePayload(materials: [Material(text: "旧的已收入记录")])) @@ -61,7 +65,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.test.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } model.thought = "主窗口中未提交的草稿" @@ -96,7 +102,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.test.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } await model.receive( @@ -144,7 +152,8 @@ struct CaptureInteractionTests { try? FileManager.default.removeItem(at: directory) } let writer = JourneyWriter() - let model = try CaptureModel(directory: directory, identity: identity, writer: writer) + let context = PeerHostContext(identity: identity, directory: directory) + let model = try CaptureModel(directory: directory, context: context, writer: writer) defer { model.cancelWork() } let originalTarget = ConnectionTarget( host: "original.test", database: "original", username: "test") @@ -162,7 +171,15 @@ struct CaptureInteractionTests { // Merely opening and cancelling the editor must leave cancelled work dormant. await model.sendPending(force: true) #expect(await writer.calls.isEmpty) + let originalClient = try await context.database.client( + for: edited, password: "old-fixture-password") try await model.updatePassword("new-fixture-password", for: cancelled) + let updatedClient = try await context.database.client( + for: edited, credentials: .keychain(context.credentials)) + #expect(updatedClient !== originalClient) + #expect( + try await context.database.client(for: edited, password: "old-fixture-password") + === updatedClient) for _ in 0..<100 where model.records.first?.state != .confirmed { try await Task.sleep(for: .milliseconds(10)) } @@ -194,7 +211,9 @@ struct CaptureInteractionTests { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: identity, writer: JourneyWriter()) + directory: directory, + context: PeerHostContext(identity: identity, directory: directory), + writer: JourneyWriter()) defer { model.cancelWork() } let target = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "test") model.connection = target @@ -225,7 +244,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.web.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.web.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } let original = ConnectionTarget( @@ -262,7 +283,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.result.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.result.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } let target = ConnectionTarget( @@ -291,7 +314,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.commands.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.commands.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } #expect(!model.canPerform(.submit, isEditor: true, isQuickPanel: false)) @@ -315,7 +340,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.test.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } let material = Material(text: "误采的材料") @@ -340,7 +367,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.test.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } model.thought = "尚未等到自动保存的中文 👩🏽‍💻" @@ -369,7 +398,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.test.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } var closed = false @@ -394,7 +425,9 @@ struct CaptureInteractionTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.test.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } let other = CaptureRecord(payload: CapturePayload(materials: [Material(text: "另一份草稿")])) @@ -420,11 +453,20 @@ private actor JourneyWriter: CaptureWriting { let secret: String } private(set) var calls: [Call] = [] - func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? { + func prepare(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws { + _ = try credentials.read(for: record.target!.id) + } + func recover(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + -> CaptureResult? + { + let secret = try credentials.read(for: record.target!.id) calls.append(Call(kind: "recover", record: record, secret: secret)) return nil } - func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult { + func submit(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + -> CaptureResult + { + let secret = try credentials.read(for: record.target!.id) calls.append(Call(kind: "submit", record: record, secret: secret)) return CaptureResult( submissionID: record.id, rootBlockID: 42, committedAt: Date()) diff --git a/Tests/CaptureMacTests/CaptureLayoutTests.swift b/Tests/CaptureMacTests/CaptureLayoutTests.swift index fe508e0..5aaeb20 100644 --- a/Tests/CaptureMacTests/CaptureLayoutTests.swift +++ b/Tests/CaptureMacTests/CaptureLayoutTests.swift @@ -15,7 +15,9 @@ struct CaptureLayoutTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.layout.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } for width in [520, 550, 790] { @@ -62,13 +64,11 @@ struct CaptureLayoutTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)", - writer: CaptureWriter(database: PeerDatabase())) - defer { model.cancelWork() } + let context = PeerHostContext( + identity: "dev.inkcre.layout.\(UUID().uuidString)", directory: directory) let view = NSHostingView( rootView: PeerConnectionEditor( - settings: model.connections, credentials: model.credentials, target: nil, + context: context, target: nil, onSaved: { _ in } ).environment(\.colorScheme, .light).background(Color.white)) let window = NSWindow( @@ -96,7 +96,9 @@ struct CaptureLayoutTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.surfaces.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.surfaces.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } let window = NSWindow( @@ -142,7 +144,9 @@ struct CaptureLayoutTests { UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel( - directory: directory, identity: "dev.inkcre.journey-layout.\(UUID().uuidString)", + directory: directory, + context: PeerHostContext( + identity: "dev.inkcre.journey-layout.\(UUID().uuidString)", directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { model.cancelWork() } let window = NSWindow( diff --git a/Tests/CaptureMacTests/ConnectionEditingTests.swift b/Tests/CaptureMacTests/ConnectionEditingTests.swift index d39b672..4253798 100644 --- a/Tests/CaptureMacTests/ConnectionEditingTests.swift +++ b/Tests/CaptureMacTests/ConnectionEditingTests.swift @@ -20,16 +20,43 @@ struct ConnectionEditingTests { ] as CFDictionary) try? FileManager.default.removeItem(at: directory) } - let settings = ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + let context = PeerHostContext( + identity: service, directory: directory, credentials: credentials) + let settings = context.connections let first = ConnectionTarget(host: "first.example", database: "first", username: "old_role") let second = ConnectionTarget(host: "second.example", database: "second", username: "peer") try await settings.save(first) try await settings.save(second) - let edited = try await settings.saveEdited( + let edited = try await context.saveEdited( host: first.host, port: "5432", database: first.database, username: "new_role", tls: .require, web: "https://first.example", password: "synthetic", - name: "新名称", previous: first, credentials: credentials) + name: "新名称", previous: first) + let original = try await context.database.client( + for: edited, credentials: .keychain(credentials)) + _ = try await context.saveEdited( + host: edited.host, port: "5432", database: edited.database, + username: edited.username, tls: .require, web: "https://renamed.example", + password: "", name: "展示变化", previous: edited) + #expect( + try await context.database.client(for: edited, credentials: .keychain(credentials)) + === original) + try credentials.save("external-update", for: edited.id) + _ = try await context.saveEdited( + host: edited.host, port: "5432", database: edited.database, + username: edited.username, tls: .require, web: "https://first.example", + password: "", name: "新名称", previous: edited) + let updated = try await context.database.client( + for: edited, credentials: .keychain(credentials)) + #expect(updated !== original) + #expect(try await context.database.client(for: edited, password: "synthetic") === updated) + _ = try await context.saveEdited( + host: edited.host, port: "5432", database: edited.database, + username: edited.username, tls: .require, web: "https://first.example", + password: "updated", name: "新名称", previous: edited) + let savedPasswordClient = try await context.database.client( + for: edited, password: "synthetic") + #expect(savedPasswordClient !== updated) #expect(edited.id == first.id && edited.peerID == first.peerID) #expect(try await settings.loadAll().connections == [edited, second]) _ = try await settings.select(second.id) @@ -40,6 +67,7 @@ struct ConnectionEditingTests { await #expect(throws: PeerError.self) { try await settings.save(redirected) } await #expect(throws: PeerError.self) { try await settings.resolve(redirected) } #expect(try await settings.resolve(first) == edited) + await context.database.shutdown() } } diff --git a/Tests/CaptureTests/CaptureLifecycleTests.swift b/Tests/CaptureTests/CaptureLifecycleTests.swift index e02181f..1e57210 100644 --- a/Tests/CaptureTests/CaptureLifecycleTests.swift +++ b/Tests/CaptureTests/CaptureLifecycleTests.swift @@ -1,36 +1,51 @@ import Capture import Foundation -import PeerCore import Security import Testing +@testable import PeerCore + private actor PausedRecovery: CaptureWriting { private let pausedTarget: UUID? - init(pausedTarget: UUID? = nil) { self.pausedTarget = pausedTarget } + private let pausePreparation: Bool + init(pausedTarget: UUID? = nil, pausePreparation: Bool = false) { + self.pausedTarget = pausedTarget + self.pausePreparation = pausePreparation + } private var entered = false private var arrival: CheckedContinuation? private var release: CheckedContinuation? private(set) var dispatches = 0 - func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? { + func prepare(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws { + if pausePreparation { await pause() } + } + func recover(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + -> CaptureResult? + { if let pausedTarget, record.target?.id != pausedTarget { return CaptureResult(submissionID: record.id, rootBlockID: 123, committedAt: Date()) } + await pause() + return nil + } + func submit(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + -> CaptureResult + { + dispatches += 1 + throw PeerError.invalid("must not dispatch after stop") + } + private func pause() async { await withCheckedContinuation { continuation in release = continuation entered = true arrival?.resume() arrival = nil } - return nil - } - func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult { - dispatches += 1 - throw PeerError.invalid("must not dispatch after stop") } - func waitForRecovery() async { + func waitForOperation() async { if !entered { await withCheckedContinuation { arrival = $0 } } } - func finishRecovery() { + func finishOperation() { release?.resume() release = nil } @@ -61,10 +76,10 @@ private actor PausedRecovery: CaptureWriting { let queue = CaptureQueue( store: store, connections: connections, credentials: credentials, writer: writer) let processing = Task { try await queue.process(force: true) } - await writer.waitForRecovery() + await writer.waitForOperation() let stopped = Task { await queue.stop() } while !(await queue.isStopped) { await Task.yield() } - await writer.finishRecovery() + await writer.finishOperation() _ = try await processing.value await stopped.value try await queue.process(force: true) @@ -100,7 +115,7 @@ private actor PausedRecovery: CaptureWriting { let queue = CaptureQueue( store: store, connections: connections, credentials: credentials, writer: writer) let processing = Task { try await queue.process(force: true) } - await writer.waitForRecovery() + await writer.waitForOperation() let deadline = ContinuousClock.now.advanced(by: .seconds(2)) while try await store.record(ids[1])?.result == nil, ContinuousClock.now < deadline { await Task.yield() @@ -109,8 +124,107 @@ private actor PausedRecovery: CaptureWriting { #expect(try await store.record(ids[0])?.payload != nil) let stopped = Task { await queue.stop() } while !(await queue.isStopped) { await Task.yield() } - await writer.finishRecovery() + await writer.finishOperation() _ = try await processing.value await stopped.value #expect(try await store.record(ids[0])?.state == .unknown) } + +private actor PreparedCaptureWriter: CaptureWriting { + let writer: CaptureWriter + private(set) var dispatches = 0 + private(set) var recoveries = 0 + init(database: PeerDatabase) { writer = CaptureWriter(database: database) } + func prepare(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws { + try await writer.prepare(record, credentials: credentials) + } + func recover(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + -> CaptureResult? + { + recoveries += 1 + return nil + } + func submit(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + -> CaptureResult + { + dispatches += 1 + return CaptureResult(submissionID: record.id, rootBlockID: 42, committedAt: Date()) + } +} + +@Test func unavailableFirstAuthenticationPreservesAnUnsentCaptureAndRetryIdentity() async throws { + let fixture = try CredentialStoreTests.Fixture() + let credentials = CredentialStore( + service: "dev.inkcre.first-attempt.\(UUID())", keychain: fixture.keychain) + let target = ConnectionTarget(host: "localhost", database: "unused", username: "peer") + try credentials.save("fixture", for: target.id) + #expect( + try CredentialStore.withAccess(allowInteraction: false) { + SecKeychainLock(fixture.keychain) + } == errSecSuccess) + let connections = ConnectionSettings( + url: fixture.directory.appendingPathComponent("connection.json")) + try await connections.save(target) + let store = try CaptureStore(url: fixture.directory.appendingPathComponent("capture.sqlite")) + let frozen = try await store.freeze( + CaptureRecord(payload: CapturePayload(materials: [Material(text: "首次不可用仍未发送")])), + target: target) + let database = PeerDatabase() + let writer = PreparedCaptureWriter(database: database) + let queue = CaptureQueue( + store: store, connections: connections, credentials: credentials, writer: writer) + try await queue.process(force: true) + let failed = try #require(try await store.record(frozen.id)) + #expect(failed.state == .needsAction && failed.attempts == 0) + #expect(failed.id == frozen.id && failed.target == frozen.target) + #expect(try CaptureJSON.encode(failed.payload) == CaptureJSON.encode(frozen.payload)) + #expect(await writer.dispatches == 0) + #expect(await writer.recoveries == 0) + let cancelled = try await store.cancel(frozen.id) + #expect(cancelled.attempts == 0 && cancelled.message?.contains("尚未发送") == true) + try await store.retry(frozen.id) + #expect(try await store.record(frozen.id)?.state == .pending) + try fixture.unlock() + try await queue.process(force: true) + let confirmed = try #require(try await store.record(frozen.id)) + #expect(confirmed.state == .confirmed && confirmed.attempts == 1) + #expect(confirmed.result?.submissionID == frozen.id && confirmed.target == frozen.target) + #expect(await writer.dispatches == 1) + #expect(await writer.recoveries == 0) + await database.shutdown() +} + +@Test(arguments: [false, true]) +func stoppingOrCancellingDuringFirstAuthenticationLeavesCaptureUnsent(cancel: Bool) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let target = ConnectionTarget(host: "localhost", database: "unused", username: "peer") + let connections = ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + try await connections.save(target) + let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + let frozen = try await store.freeze( + CaptureRecord(payload: CapturePayload(materials: [Material(text: "停止首次发送")])), + target: target) + let writer = PausedRecovery(pausePreparation: true) + let queue = CaptureQueue( + store: store, connections: connections, + credentials: CredentialStore(service: "unused-first-attempt"), writer: writer) + let processing = Task { try await queue.process(force: true) } + await writer.waitForOperation() + let stopped: Task? + if cancel { + processing.cancel() + stopped = nil + } else { + stopped = Task { await queue.stop() } + while !(await queue.isStopped) { await Task.yield() } + } + await writer.finishOperation() + _ = try await processing.value + await stopped?.value + #expect(await writer.dispatches == 0) + let unsent = try #require(try await store.record(frozen.id)) + #expect(unsent.state == .pending && unsent.attempts == 0) + #expect(unsent.id == frozen.id && unsent.target == frozen.target) + #expect(try CaptureJSON.encode(unsent.payload) == CaptureJSON.encode(frozen.payload)) +} diff --git a/Tests/CaptureTests/ConnectionSettingsTests.swift b/Tests/CaptureTests/ConnectionSettingsTests.swift index d0b76b9..4311e25 100644 --- a/Tests/CaptureTests/ConnectionSettingsTests.swift +++ b/Tests/CaptureTests/ConnectionSettingsTests.swift @@ -52,9 +52,15 @@ struct ConnectionSettingsTests { edited.webURL = URL(string: "https://example.test") #expect(try await database.client(for: edited, password: "unused") === original) edited.username = "new_role" - let replaced = try await database.client(for: edited, password: "unused") + #expect(try await database.client(for: edited, password: "unused") === original) + let replaced = try await database.authorize(edited, password: "unused") #expect(replaced !== original) - #expect(try await database.client(for: edited, password: "changed") !== replaced) + // Bootstrap credentials carried by an old reference cannot replace an authorized session. + #expect(try await database.client(for: edited, password: "changed") === replaced) + let updated = try await database.authorize(edited, password: "changed") + #expect(updated !== replaced) + #expect(try await database.client(for: edited, password: "unused") === updated) + #expect(try await database.client(for: target, password: "unused") === updated) await database.shutdown() } diff --git a/Tests/CaptureTests/CredentialStoreTests.swift b/Tests/CaptureTests/CredentialStoreTests.swift index 8762820..17b8414 100644 --- a/Tests/CaptureTests/CredentialStoreTests.swift +++ b/Tests/CaptureTests/CredentialStoreTests.swift @@ -1,30 +1,81 @@ import Foundation -import PeerCore import Security import Testing +@testable import PeerCore + @Suite(.serialized) struct CredentialStoreTests { + final class Fixture { + let directory: URL + let keychain: SecKeychain + static let password = "disposable-keychain-password" + init() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + let path = directory.appendingPathComponent("fixture.keychain").path + let (status, created) = try CredentialStore.withAccess(allowInteraction: false) { + var created: SecKeychain? + let status = Self.password.withCString { bytes in + SecKeychainCreate( + path, UInt32(Self.password.utf8.count), bytes, false, nil, &created) + } + return (status, created) + } + keychain = try #require(created) + #expect(status == errSecSuccess) + } + deinit { + let removed = try? CredentialStore.withAccess(allowInteraction: false) { + let unlocked = Self.password.withCString { + SecKeychainUnlock(keychain, UInt32(Self.password.utf8.count), $0, true) + } + #expect(unlocked == errSecSuccess) + let status = SecKeychainDelete(keychain) + #expect(status == errSecSuccess) + return status == errSecSuccess + } + if removed == true { try? FileManager.default.removeItem(at: directory) } + } + func unlock() throws { + let status = try CredentialStore.withAccess(allowInteraction: false) { + Self.password.withCString { + SecKeychainUnlock(keychain, UInt32(Self.password.utf8.count), $0, true) + } + } + #expect(status == errSecSuccess) + } + } + @Test func backgroundReadsRejectUntrustedItemsWithoutInteraction() throws { + let fixture = try Fixture() let service = "dev.inkcre.credentials-test.\(UUID())" let connection = UUID() let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: connection.uuidString, + kSecMatchSearchList as String: [fixture.keychain], ] let command = Process() command.executableURL = URL(fileURLWithPath: "/usr/bin/security") command.arguments = [ "add-generic-password", "-s", service, "-a", connection.uuidString, "-w", "disposable-fixture", "-T", "/usr/bin/security", + fixture.directory.appendingPathComponent("fixture.keychain").path, ] try command.run() command.waitUntilExit() #expect(command.terminationStatus == 0) - defer { SecItemDelete(query as CFDictionary) } + defer { + _ = try? CredentialStore.withAccess(allowInteraction: false) { + SecItemDelete(query as CFDictionary) + } + } - let store = CredentialStore(service: service) + let store = CredentialStore(service: service, keychain: fixture.keychain) for _ in 0..<3 { do { _ = try store.read(for: connection) @@ -36,19 +87,99 @@ struct CredentialStoreTests { } @Test func appOwnedPasswordCanBeReadAndUpdatedWithoutCaching() throws { + let fixture = try Fixture() let service = "dev.inkcre.credentials-test.\(UUID())" let connection = UUID() defer { - SecItemDelete( - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - ] as CFDictionary) + _ = try? CredentialStore.withAccess(allowInteraction: false) { + SecItemDelete( + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchSearchList as String: [fixture.keychain], + ] as CFDictionary) + } } - let store = CredentialStore(service: service) + let store = CredentialStore(service: service, keychain: fixture.keychain) try store.save("first-fixture", for: connection) #expect(try store.read(for: connection) == "first-fixture") try store.save("updated-fixture", for: connection) #expect(try store.read(for: connection) == "updated-fixture") } + + @Test func sessionSurvivesKeychainDeletionAndExplicitlySavedPasswordsReplaceIt() + async throws + { + let fixture = try Fixture() + let service = "dev.inkcre.credentials-test.\(UUID())" + let store = CredentialStore(service: service, keychain: fixture.keychain) + let target = ConnectionTarget(host: "localhost", database: "unused", username: "peer") + let database = PeerDatabase() + let context = PeerHostContext( + identity: service, directory: fixture.directory, database: database, credentials: store) + try store.save("first-fixture", for: target.id) + let original = try await database.client(for: target, credentials: .keychain(store)) + // Saving persistent data alone does not silently change this owner's established session. + try store.save("external-fixture", for: target.id) + #expect(try await database.client(for: target, credentials: .keychain(store)) === original) + var renamed = target + renamed.name = "新名称" + renamed.webURL = URL(string: "https://example.test") + try await context.savePassword("first-fixture", for: renamed) + #expect(try await database.client(for: renamed, credentials: .keychain(store)) === original) + try await context.reauthorize(renamed, allowInteraction: false) + #expect(try await database.client(for: renamed, credentials: .keychain(store)) === original) + try store.save("reauthorized-fixture", for: target.id) + try await context.reauthorize(target, allowInteraction: false) + let reauthorized = try await database.client(for: target, credentials: .keychain(store)) + #expect(reauthorized !== original) + try await context.savePassword("updated-fixture", for: target) + let updated = try await database.client(for: target, credentials: .keychain(store)) + #expect(updated !== reauthorized) + #expect(try await database.client(for: target, password: "first-fixture") === updated) + #expect( + try CredentialStore.withAccess(allowInteraction: false) { + SecItemDelete( + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchSearchList as String: [fixture.keychain], + ] as CFDictionary) + } == errSecSuccess) + for _ in 0..<500 { + #expect( + try await database.client(for: target, credentials: .keychain(store)) === updated) + } + await #expect(throws: PeerError.self) { + try await context.reauthorize(target, allowInteraction: false) + } + // Failed explicit reauthorization does not resurrect the discarded session. + await #expect(throws: PeerError.self) { + try await database.client(for: target, credentials: .keychain(store)) + } + await database.shutdown() + } + + @Test func sessionSurvivesKeychainLockUntilItsOwnerRevokesAuthorization() async throws { + let fixture = try Fixture() + let store = CredentialStore( + service: "dev.inkcre.credentials-test.\(UUID())", keychain: fixture.keychain) + let target = ConnectionTarget(host: "localhost", database: "unused", username: "peer") + let database = PeerDatabase() + try store.save("fixture", for: target.id) + let original = try await database.client(for: target, credentials: .keychain(store)) + #expect( + try CredentialStore.withAccess(allowInteraction: false) { + SecKeychainLock(fixture.keychain) + } == errSecSuccess) + #expect(try await database.client(for: target, credentials: .keychain(store)) === original) + await database.invalidate(target.id) + await #expect(throws: PeerError.self) { + try await database.client(for: target, credentials: .keychain(store)) + } + try fixture.unlock() + let restored = try await database.client(for: target, credentials: .keychain(store)) + #expect(restored !== original) + await database.shutdown() + } } diff --git a/Tests/HostTests/HostInterfaceTests.swift b/Tests/HostTests/HostInterfaceTests.swift index 9b91dc0..a30a1ab 100644 --- a/Tests/HostTests/HostInterfaceTests.swift +++ b/Tests/HostTests/HostInterfaceTests.swift @@ -125,7 +125,8 @@ struct HostInterfaceTests { } } let captureModel = try CaptureModel( - directory: directory.appendingPathComponent("capture"), identity: domain, + directory: directory.appendingPathComponent("capture"), + context: PeerHostContext(identity: domain, directory: directory), writer: CaptureWriter(database: PeerDatabase())) defer { captureModel.cancelWork() } window.replaceCaptureSettings( diff --git a/extensions/capture/Sources/Capture/CaptureQueue.swift b/extensions/capture/Sources/Capture/CaptureQueue.swift index 589fdc1..a90a6ad 100644 --- a/extensions/capture/Sources/Capture/CaptureQueue.swift +++ b/extensions/capture/Sources/Capture/CaptureQueue.swift @@ -57,22 +57,26 @@ public actor CaptureQueue { guard let target = item.target else { continue } do { let connection = try await connections.resolve(target) - let secret = try credentials.read(for: connection.id) var outgoing = item outgoing.target = connection if item.attempts > 0, - let result = try await writer.recover(outgoing, secret: secret) + let result = try await writer.recover( + outgoing, credentials: .keychain(credentials)) { try await store.confirm(item.id, result: result) changed = true continue } + if item.attempts == 0 { + try await writer.prepare(outgoing, credentials: .keychain(credentials)) + } guard !isStopped, !Task.isCancelled else { return changed } var dispatched = try await store.markDispatch(item.id) dispatched.target = connection changed = true guard !isStopped, !Task.isCancelled else { return changed } - let result = try await writer.submit(dispatched, secret: secret) + let result = try await writer.submit( + dispatched, credentials: .keychain(credentials)) try await store.confirm(item.id, result: result) } catch let error as PeerError { let state: CaptureRecord.State @@ -85,6 +89,7 @@ public actor CaptureQueue { try await store.report(item.id, state: state, message: error.localizedDescription) changed = true } catch { + if PeerDatabase.isCancellation(error) { return changed } try await store.report( item.id, state: .unknown, message: "无法确认响应,原提交已保留。请检查服务版本后重试。" diff --git a/extensions/capture/Sources/Capture/CaptureWriter.swift b/extensions/capture/Sources/Capture/CaptureWriter.swift index da6640e..60c50ae 100644 --- a/extensions/capture/Sources/Capture/CaptureWriter.swift +++ b/extensions/capture/Sources/Capture/CaptureWriter.swift @@ -3,8 +3,12 @@ import PeerCore import PostgresNIO public protocol CaptureWriting: Sendable { - func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? - func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult + /// Prepare authentication before the queue records a possible dispatch. + func prepare(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + func recover(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + -> CaptureResult? + func submit(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + -> CaptureResult } public struct CaptureWriter: CaptureWriting { @@ -33,12 +37,24 @@ public struct CaptureWriter: CaptureWriting { private let database: PeerDatabase public init(database: PeerDatabase) { self.database = database } + public func prepare(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws { + guard let target = record.target else { throw PeerError.invalid("待办缺少目标。") } + _ = try await database.client(for: target, credentials: credentials) + } + public func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? { + try await recover(record, credentials: .password(secret)) + } + + public func recover(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + -> CaptureResult? + { guard let target = record.target, let payload = record.payload else { throw PeerError.invalid("待办缺少材料或目标。") } do { - return try await database.withConnection(for: target, password: secret) { connection in + return try await database.withConnection(for: target, credentials: credentials) { + connection in try await Self.result( from: connection.query(Self.lookup(record.id), logger: PeerDatabase.logger), id: record.id, digest: payload.canonicalDigest()) @@ -66,14 +82,21 @@ public struct CaptureWriter: CaptureWriting { } public func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult { + try await submit(record, credentials: .password(secret)) + } + + public func submit(_ record: CaptureRecord, credentials: DatabaseCredentials) async throws + -> CaptureResult + { guard let target = record.target, let payload = record.payload else { throw PeerError.invalid("待办缺少冻结材料或目标。") } let command = try CaptureCommit(payload) do { - try await database.register(target, password: secret) - try await database.join(target, password: secret, extension: Self.descriptor) - return try await database.withConnection(for: target, password: secret) { connection in + try await database.register(target, credentials: credentials) + try await database.join(target, credentials: credentials, extension: Self.descriptor) + return try await database.withConnection(for: target, credentials: credentials) { + connection in try await connection.withTransaction(logger: PeerDatabase.logger) { connection in try await PeerDatabase.requireSelection( Self.descriptor, peerID: target.peerID, on: connection) diff --git a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift index aee57f9..3ed4b64 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift @@ -96,7 +96,7 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate do { let model = try CaptureModel( directory: context.directory.appendingPathComponent("extensions/inkcre-capture"), - identity: context.identity, connections: context.connections, + context: context, writer: CaptureWriter(database: context.database)) self.model = model let panel = CapturePanel( diff --git a/extensions/capture/Sources/CaptureMac/CaptureModel.swift b/extensions/capture/Sources/CaptureMac/CaptureModel.swift index e026853..38f20f1 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureModel.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureModel.swift @@ -61,8 +61,9 @@ final class CaptureModel: ObservableObject { var showQuick: (() -> Void)? var editConnection: ((ConnectionTarget?, (@MainActor () -> Void)?) -> Void)? let store: CaptureStore - let credentials: CredentialStore - let connections: ConnectionSettings + private let context: PeerHostContext + var credentials: CredentialStore { context.credentials } + var connections: ConnectionSettings { context.connections } private let queue: CaptureQueue private var saveTask: Task? private var heartbeat: Task? @@ -72,18 +73,13 @@ final class CaptureModel: ObservableObject { private var actions: [UUID: Task] = [:] private var stopping = false - init( - directory: URL, identity: String, connections: ConnectionSettings? = nil, - writer: any CaptureWriting - ) throws { - self.connections = - connections - ?? ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + init(directory: URL, context: PeerHostContext, writer: any CaptureWriting) throws { + self.context = context shortcuts = try GlobalShortcuts() store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - credentials = CredentialStore(service: "\(identity).connections") queue = CaptureQueue( - store: store, connections: self.connections, credentials: credentials, writer: writer) + store: store, connections: context.connections, credentials: context.credentials, + writer: writer) } func canPerform(_ action: ShortcutAction, isEditor: Bool, isQuickPanel: Bool) -> Bool { @@ -447,7 +443,8 @@ final class CaptureModel: ObservableObject { let current = try await store.record(record.id), let target = current.target, current.state != .draft, current.state != .confirmed else { throw PeerError.invalid("这条记录已变更,请关闭后重新查看。") } - try credentials.save(password, for: target.id) + let connection = try await connections.resolve(target) + try await context.savePassword(password, for: connection) try await store.retry(current.id) try await refresh() perform { [self] in await sendPending(force: true) } diff --git a/extensions/local-files/README.md b/extensions/local-files/README.md index 03b8b0a..0c67748 100644 --- a/extensions/local-files/README.md +++ b/extensions/local-files/README.md @@ -2,7 +2,7 @@ `LocalFiles` 拥有位置 URI、提取、Source 配置、对账和图更新;`LocalFilesMac` 拥有书签、FSEvents、监视生命周期及原生界面。Host 静态组合此扩展与 Capture。共享合同位于 InKCre/docs 的 `20-product-tdd/local-files-contract.md`,无需 Python 服务或新数据库迁移。 -Source 配置在 info-base,书签及原连接绑定在 Host 的 `extensions/inkcre-local-files/directories.json`,权限 0600。密码仅存系统 Keychain。先监视再扫描;通知合并为待核对状态,不保存事件日志。每 30 秒核对共享配置、待执行 Job 和失败扫描,正常监视不会每 30 秒重新读取所有文件。每轮从原连接的 Keychain 取凭据。停止等待当前文件事务和提取结束,再释放监视与目录访问。 +Source 配置在 info-base,书签及原连接绑定在 Host 的 `extensions/inkcre-local-files/directories.json`,权限 0600。密码持久化于系统 Keychain。先监视再扫描;通知合并为待核对状态,不保存事件日志。每 30 秒核对共享配置、待执行 Job 和失败扫描,正常监视不会每 30 秒重新读取所有文件。每轮使用 Host 的同一数据库会话,仅在尚无会话时从原连接的 Keychain 取密。停止等待当前文件事务和提取结束,再释放监视与目录访问。 列表与扫描只读取文件元数据和正文 ID;选中文件才读取该文件正文,修订变化后更新当前正文。未变化文件不进入逐文件写事务,完整扫描仍复核来源状态并处理失效文件。 diff --git a/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift b/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift index 792e3d0..f97262a 100644 --- a/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift +++ b/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift @@ -4,7 +4,7 @@ import PostgresNIO extension LocalFilesRepository { public func submitScan(_ source: DirectorySource) async throws -> Int { - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in return try await client.withTransaction(logger: PeerDatabase.logger) { connection in _ = try await locked(source.id, on: connection, expected: source.config) return try await SourceJobs.submit(source: source.id, on: connection) @@ -13,7 +13,7 @@ extension LocalFilesRepository { } public func pendingScans(_ source: DirectorySource) async throws -> [Int] { - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in try await client.withTransaction(logger: PeerDatabase.logger) { connection in _ = try await locked(source.id, on: connection, expected: source.config) try await connection.query( @@ -28,7 +28,7 @@ extension LocalFilesRepository { } public func reportIssue(_ message: String, source: DirectorySource) async throws { - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in try await client.withTransaction(logger: PeerDatabase.logger) { connection in let current = try await locked(source.id, on: connection, expected: source.config) var state = current.state @@ -42,7 +42,7 @@ extension LocalFilesRepository { } public func claim(_ id: Int, source: DirectorySource) async throws -> SourceJob? { - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in return try await client.withTransaction(logger: PeerDatabase.logger) { connection in _ = try await locked(source.id, on: connection, expected: source.config) return try await SourceJobs.claim(id, source: source.id, on: connection) @@ -52,13 +52,13 @@ extension LocalFilesRepository { public func check(_ job: SourceJob?) async throws { try Task.checkCancellation() - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in if let job { try await SourceJobs.check(job, on: client) } } } public func close(_ job: SourceJob, status: String, state: DirectoryState) async throws { - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in try await SourceJobs.close( job, status: status, state: FileJSON.encode(state), on: client) } @@ -68,7 +68,7 @@ extension LocalFilesRepository { _ observation: FileObservation, replacing record: FileRecord?, source: DirectorySource ) async throws { if let record, !observation.changes(record.metadata) { return } - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in try await client.withTransaction(logger: PeerDatabase.logger) { connection in let source = try await locked(source.id, on: connection, expected: source.config) guard let anchor = source.block else { throw PeerError.invalid("目录来源缺少资料锚点。") } @@ -119,7 +119,7 @@ extension LocalFilesRepository { public func finishScan(source: DirectorySource, missing: [FileRecord], state: DirectoryState) async throws { - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in try await client.withTransaction(logger: PeerDatabase.logger) { connection in let source = try await locked(source.id, on: connection, expected: source.config) guard let anchor = source.block else { throw PeerError.invalid("目录来源缺少资料锚点。") } diff --git a/extensions/local-files/Sources/LocalFiles/LocalFilesRepository.swift b/extensions/local-files/Sources/LocalFiles/LocalFilesRepository.swift index b6f098d..5da9311 100644 --- a/extensions/local-files/Sources/LocalFiles/LocalFilesRepository.swift +++ b/extensions/local-files/Sources/LocalFiles/LocalFilesRepository.swift @@ -4,19 +4,24 @@ import PostgresNIO public struct LocalFilesRepository: Sendable { public let target: ConnectionTarget - let password: String + let credentials: DatabaseCredentials let database: PeerDatabase public init(target: ConnectionTarget, password: String, database: PeerDatabase) { + self.init(target: target, credentials: .password(password), database: database) + } + + public init(target: ConnectionTarget, credentials: DatabaseCredentials, database: PeerDatabase) + { self.target = target - self.password = password + self.credentials = credentials self.database = database } public func prepare() async throws { - try await database.register(target, password: password) + try await database.register(target, credentials: credentials) try await database.join( - target, password: password, extension: LocalFilesContract.descriptor) - return try await database.withConnection(for: target, password: password) { client in + target, credentials: credentials, extension: LocalFilesContract.descriptor) + return try await database.withConnection(for: target, credentials: credentials) { client in let config = #"{"type":"object","additionalProperties":false,"properties":{"peer":{"type":"string","format":"uuid"},"path":{"type":"string","pattern":"^/"},"enabled":{"type":"boolean"}},"required":["peer","path","enabled"]}"# try await client.query( @@ -37,7 +42,7 @@ public struct LocalFilesRepository: Sendable { } public func checkParticipation() async throws { - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in try await client.withTransaction(logger: PeerDatabase.logger) { connection in try await PeerDatabase.requireSelection( LocalFilesContract.descriptor, peerID: target.peerID, on: connection) @@ -46,7 +51,7 @@ public struct LocalFilesRepository: Sendable { } public func sources() async throws -> [DirectorySource] { - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in let rows = try await client.query( "SELECT id,config::text,block,state::text FROM inkcre.sources WHERE type=\(LocalFilesContract.sourceType) AND config->>'peer'=\(target.peerID.uuidString.lowercased()) ORDER BY id", logger: PeerDatabase.logger) @@ -64,7 +69,7 @@ public struct LocalFilesRepository: Sendable { let config = DirectoryConfig(peer: target.peerID, path: path) _ = try PeerFileLocation(peer: config.peer, path: path) let serialized = try FileJSON.encode(config) - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in return try await client.withTransaction(logger: PeerDatabase.logger) { connection in try await PeerDatabase.requireSelection( LocalFilesContract.descriptor, peerID: target.peerID, on: connection) @@ -102,7 +107,7 @@ public struct LocalFilesRepository: Sendable { } public func setEnabled(_ enabled: Bool, source: DirectorySource) async throws { - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in try await client.withTransaction(logger: PeerDatabase.logger) { connection in var current = try await locked( source.id, on: connection, expected: source.config, requireEnabled: false) @@ -116,7 +121,7 @@ public struct LocalFilesRepository: Sendable { } public func remove(_ source: DirectorySource) async throws { - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in try await client.withTransaction(logger: PeerDatabase.logger) { connection in _ = try await locked( source.id, on: connection, expected: source.config, requireEnabled: false) @@ -129,7 +134,7 @@ public struct LocalFilesRepository: Sendable { public func records(source: DirectorySource) async throws -> [FileRecord] { guard source.config.peer == target.peerID else { throw PeerError.invalid("目录不属于当前 Peer。") } guard let block = source.block else { return [] } - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in let rows = try await client.query( """ SELECT b.id,b.resolver,b.content,t.id,t.resolver,t.storage FROM inkcre.relations managed JOIN inkcre.blocks b ON b.id=managed.to_ @@ -154,7 +159,7 @@ public struct LocalFilesRepository: Sendable { public func content(for record: FileRecord) async throws -> String? { guard let body = record.bodyID else { return nil } - return try await database.withConnection(for: target, password: password) { client in + return try await database.withConnection(for: target, credentials: credentials) { client in let rows = try await client.query( "SELECT content FROM inkcre.blocks WHERE id=\(body) AND resolver=\(record.bodyResolver) AND storage IS NULL", logger: PeerDatabase.logger) diff --git a/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift index 68d0dad..4b418ec 100644 --- a/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift +++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift @@ -385,7 +385,7 @@ public final class LocalFilesModel: ObservableObject { private func repository(_ target: ConnectionTarget) async throws -> LocalFilesRepository { let connection = try await context.connections.resolve(target) return LocalFilesRepository( - target: connection, password: try context.credentials.read(for: connection.id), + target: connection, credentials: .keychain(context.credentials), database: context.database) } diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 2c2cb24..24d69bc 100644 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -48,7 +48,7 @@ cat > "$app/Contents/Info.plist" <CFBundleIconFileAppIcon.icns CFBundlePackageTypeAPPL CFBundleShortVersionString$version -CFBundleVersion19 +CFBundleVersion21 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication