diff --git a/AGENTS.md b/AGENTS.md index 3e1398c..e416a47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,6 @@ Capture 和此次 Mac 分发尚未发布,直接修订候选 schema,不增加旧 HTTP、旧回执或旧本机格式兼容。保留原开发数据文件,不自动导入或清空。第三方引用必须保留许可证、固定来源、修改说明和署名;打包必须包含完整依赖许可。 -同次提交不得换 UUID、变更材料或改投另一个信息库。只有经过指纹核对的共享回执可以清理本机材料。选区失败不能读旧剪贴板充数;截图只持久化明确选择的区域。 +同次提交不得换 UUID、变更材料或改投另一个信息库。只有事务明确成功或核对共享 Capture 根的材料摘要后才可清理本机材料。扩展不拥有独立共享表;草稿、待办和确认结果由本机 SQLite 保存。选区失败不能读旧剪贴板充数;截图只持久化明确选择的区域。 运行 `bash scripts/check.sh` 和 `bash scripts/build-app.sh`。前者与 CI 共用 Xcode 26.2 的 Swift 格式/lint 检查和测试入口,覆盖 Host、PeerCore 与 first-party 扩展;固定来源的 Vendor 代码按上游格式维护。编译不证明系统权限、真实选区、焦点或多屏行为;真实应用验证使用合成内容,按实际结果记录。开发包使用 `dev.inkcre.capture`;发布身份、签名、公证只能使用显式提供的输入。 diff --git a/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift b/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift index 8ff0318..69f2ab8 100644 --- a/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift +++ b/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift @@ -5,7 +5,6 @@ public final class BundledExtensionRuntime { public let module: any NativeExtension public private(set) var running = false public private(set) var applying = false - public private(set) var syncIssue: String? private var idleWaiters: [CheckedContinuation] = [] private let defaults: UserDefaults private var key: String { "extensions.\(module.descriptor.name).enabled" } @@ -29,17 +28,10 @@ public final class BundledExtensionRuntime { for waiter in idleWaiters { waiter.resume() } idleWaiters.removeAll() } - syncIssue = nil if !enabled { defaults.set(false, forKey: key) try await stop() } - do { try await module.setEnabled(enabled) } catch { - if enabled { - guard case PeerError.connection = error else { throw error } - } - syncIssue = "共享状态未同步:\(error.localizedDescription)" - } if enabled { defaults.set(true, forKey: key) try startIfEnabled() diff --git a/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift index 7e623ee..d230f56 100644 --- a/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift +++ b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift @@ -10,23 +10,26 @@ extension PeerDatabase { let defaults = UserDefaults(suiteName: "dev.inkcre.native-participation")! let key = "\(target.peerID).\(descriptor.name)" let firstJoin = !defaults.bool(forKey: key) - let client = try client(for: target, password: password) - let issue: String? = try await client.withTransaction(logger: Self.logger) { connection in - if firstJoin { - try await connection.query( - "INSERT INTO inkcre.extensions(name) VALUES (\(descriptor.name)) ON CONFLICT (name) DO NOTHING", - logger: Self.logger) - try await connection.query( - """ - INSERT INTO inkcre.extension_peer_participations(name,peer_id,distribution,version,enabled) - VALUES (\(descriptor.name),\(target.peerID),\(descriptor.distribution),\(descriptor.version),true) - ON CONFLICT (name,peer_id) DO NOTHING - """, logger: Self.logger) + let issue: String? = try await withConnection(for: target, password: password) { + connection in + try await connection.withTransaction(logger: Self.logger) { connection in + if firstJoin { + try await connection.query( + "INSERT INTO inkcre.extensions(name) VALUES (\(descriptor.name)) ON CONFLICT (name) DO NOTHING", + logger: Self.logger) + try await connection.query( + """ + INSERT INTO inkcre.extension_peer_participations(name,peer_id,distribution,version,enabled) + VALUES (\(descriptor.name),\(target.peerID),\(descriptor.distribution),\(descriptor.version),true) + ON CONFLICT (name,peer_id) DO NOTHING + """, logger: Self.logger) + } + do { + try await Self.requireSelection( + descriptor, peerID: target.peerID, on: connection) + return nil + } catch PeerError.needsAction(let message) { return message } } - do { - try await Self.requireSelection(descriptor, peerID: target.peerID, on: connection) - return nil - } catch PeerError.needsAction(let message) { return message } } defaults.set(true, forKey: key) if let issue { throw PeerError.needsAction(issue) } @@ -43,22 +46,23 @@ extension PeerDatabase { for try await (distribution, version, enabled) in rows.decode((String?, String, Bool).self) { guard distribution == descriptor.distribution, version == descriptor.version else { - throw PeerError.needsAction("此 Peer 选择的扩展版本不在当前应用中,请核对扩展选择。") + throw PeerError.needsAction("此 Peer 选择的扩展版本不在当前应用中,请在扩展设置中对该信息库启用本机版本。") + } + guard enabled else { + throw PeerError.needsAction("信息库中的扩展参与已停用;本机材料保留,请在扩展设置中对该信息库启用本机版本后继续。") } - guard enabled else { throw PeerError.needsAction("信息库中的扩展参与已停用;本机材料保留,请在线明确启用后继续。") } return } throw PeerError.needsAction("此 Peer 的扩展参与已移除;不会在重连时自动重建。") } - public func setEnabled( - _ enabled: Bool, target: ConnectionTarget, password: String, - extension descriptor: NativeExtensionDescriptor + /// Explicitly select the version shipped in this app for one info-base. + public func useBundledVersion( + _ descriptor: NativeExtensionDescriptor, target: ConnectionTarget, password: String ) async throws { - _ = try await register(target, password: password) - let client = try client(for: target, password: password) - try await client.withTransaction(logger: Self.logger) { connection in - if enabled { + try await register(target, password: password) + try await withConnection(for: target, password: password) { 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", logger: Self.logger) @@ -66,18 +70,10 @@ extension PeerDatabase { """ INSERT INTO inkcre.extension_peer_participations(name,peer_id,distribution,version,enabled) VALUES (\(descriptor.name),\(target.peerID),\(descriptor.distribution),\(descriptor.version),true) - ON CONFLICT (name,peer_id) DO NOTHING + ON CONFLICT (name,peer_id) DO UPDATE SET + distribution=EXCLUDED.distribution,version=EXCLUDED.version,enabled=true """, logger: Self.logger) } - let rows = try await connection.query( - """ - UPDATE inkcre.extension_peer_participations SET enabled=\(enabled) - WHERE name=\(descriptor.name) AND peer_id=\(target.peerID) - AND distribution=\(descriptor.distribution) AND version=\(descriptor.version) - RETURNING name - """, logger: Self.logger) - for try await _ in rows.decode(String.self) { return } - if enabled { throw PeerError.needsAction("信息库中没有与本机匹配的扩展选择。") } } let defaults = UserDefaults(suiteName: "dev.inkcre.native-participation")! defaults.set(true, forKey: "\(target.peerID).\(descriptor.name)") diff --git a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift index 7264e00..d06784d 100644 --- a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift @@ -15,11 +15,6 @@ public actor ConnectionSettings { } let data = try Data(contentsOf: url) let decoder = JSONDecoder() - // Preserve the existing single connection, including its Keychain and Peer identities. - if let target = try? decoder.decode(ConnectionTarget.self, from: data) { - try target.validate() - return SavedConnections(selected: target.id, connections: [target]) - } let saved = try decoder.decode(SavedConnections.self, from: data) guard Set(saved.connections.map(\.id)).count == saved.connections.count, saved.selected == nil || saved.current != nil @@ -32,6 +27,13 @@ public actor ConnectionSettings { public func load() throws -> ConnectionTarget? { try loadAll().current } + public func resolve(_ frozen: ConnectionTarget) throws -> ConnectionTarget { + guard let current = try loadAll().connections.first(where: { $0.id == frozen.id }), + current.hasSameDestination(as: frozen) + else { throw PeerError.needsAction("原目标连接不可用,请恢复该信息库的连接。") } + return current + } + public func select(_ id: UUID) throws -> ConnectionTarget { var saved = try loadAll() guard let target = saved.connections.first(where: { $0.id == id }) else { @@ -46,6 +48,9 @@ public actor ConnectionSettings { try target.validate() var saved = try loadAll() if let index = saved.connections.firstIndex(where: { $0.id == target.id }) { + guard saved.connections[index].hasSameDestination(as: target) else { + throw PeerError.invalid("修改信息库地址需要创建新连接,已有材料的目标不能改变。") + } saved.connections[index] = target } else { saved.connections.append(target) @@ -73,9 +78,8 @@ public actor ConnectionSettings { if !web.isEmpty && webURL == nil { throw PeerError.invalid("Web 地址无效。") } let same = previous?.host == host && previous?.port == port && previous?.database == database - let sameCredentials = same && previous?.username == username let target = ConnectionTarget( - id: sameCredentials ? previous!.id : UUID(), peerID: same ? previous!.peerID : UUID(), + id: same ? previous!.id : UUID(), peerID: same ? previous!.peerID : UUID(), host: host, port: port, database: database, username: username, tls: tls, webURL: webURL, name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty diff --git a/Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift b/Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift index a7b7346..861e4e5 100644 --- a/Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift @@ -28,6 +28,11 @@ public struct ConnectionTarget: Codable, Sendable, Equatable, Identifiable { self.name = name } + public func hasSameDestination(as other: Self) -> Bool { + peerID == other.peerID && host == other.host && port == other.port + && database == other.database + } + public func validate() throws { guard !host.isEmpty, !host.contains(where: { $0.isWhitespace }), !host.contains("/"), (1...65535).contains(port), !database.isEmpty, !username.isEmpty, diff --git a/Packages/PeerCore/Sources/PeerCore/NativeExtension.swift b/Packages/PeerCore/Sources/PeerCore/NativeExtension.swift index 309f618..76c8f14 100644 --- a/Packages/PeerCore/Sources/PeerCore/NativeExtension.swift +++ b/Packages/PeerCore/Sources/PeerCore/NativeExtension.swift @@ -15,7 +15,6 @@ public struct NativeExtensionDescriptor: Codable, Sendable, Equatable { public protocol NativeExtension: AnyObject { var descriptor: NativeExtensionDescriptor { get } func start() throws - func setEnabled(_ enabled: Bool) async throws func stop() async throws func showMain() } diff --git a/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift index 32c90d2..0b9249b 100644 --- a/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift +++ b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift @@ -10,6 +10,7 @@ public actor PeerDatabase { let password: String let client: PostgresClient let task: Task + var registered = false } private var pools: [UUID: Pool] = [:] @@ -17,10 +18,15 @@ public actor PeerDatabase { public func client(for target: ConnectionTarget, password: String) throws -> PostgresClient { try target.validate() - if let pool = pools[target.id], pool.target == target, pool.password == password { + if let pool = pools[target.id], 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 + { return pool.client } - pools.removeValue(forKey: target.id)?.task.cancel() + let previous = pools.removeValue(forKey: target.id) + previous?.task.cancel() var configuration = PostgresClient.Configuration( host: target.host, port: target.port, username: target.username, password: password, database: target.database, @@ -29,9 +35,16 @@ public actor PeerDatabase { configuration.options.minimumConnections = 0 configuration.options.maximumConnections = 2 configuration.options.connectTimeout = .seconds(10) + // Bound server work too when a disconnected client cannot deliver cancellation. + configuration.options.additionalStartupParameters = [ + ("options", "-c statement_timeout=30000") + ] 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) + pools[target.id] = Pool( + target: target, password: password, client: client, task: task, + registered: previous?.registered == true + && previous?.target.hasSameDestination(as: target) == true) return client } @@ -42,22 +55,74 @@ public actor PeerDatabase { for task in tasks { await task.value } } - public func register(_ target: ConnectionTarget, password: String) async throws -> Date { + public func register(_ target: ConnectionTarget, password: String) async throws { let client = try client(for: target, password: password) + if pools[target.id]?.registered == true { return } let name = Host.current().localizedName ?? "InKCre Mac" - return try await client.withTransaction(logger: Self.logger) { connection in - try await connection.query( - """ - INSERT INTO inkcre.peers (id,name,application_version,capabilities,config_schema,config,labels) - VALUES (\(target.peerID),\(name),'0.1.0','[]','{}','{}',ARRAY['macOS']) - ON CONFLICT (id) DO UPDATE SET application_version=EXCLUDED.application_version - """, logger: Self.logger) - let rows = try await connection.query( - "SELECT inkcre.renew_peer_lease(\(target.peerID),120)", logger: Self.logger) - for try await expiry in rows.decode(Date.self) { - return expiry.addingTimeInterval(-120) + try await withConnection(for: target, password: password) { connection in + try await connection.withTransaction(logger: Self.logger) { connection in + try await connection.query( + """ + INSERT INTO inkcre.peers (id,name,application_version,capabilities,config_schema,config,labels) + VALUES (\(target.peerID),\(name),'0.1.0','[]','{}','{}',ARRAY['macOS']) + ON CONFLICT (id) DO UPDATE SET application_version=EXCLUDED.application_version + """, logger: Self.logger) + try await connection.query( + "SELECT inkcre.renew_peer_lease(\(target.peerID),120)", logger: Self.logger) } - throw PeerError.connection("信息库未返回 Peer 联络结果。") + } + if pools[target.id]?.client === client { pools[target.id]?.registered = true } + } + + /// Keep row consumption and transactions inside this scope so cancellation closes their lease. + public nonisolated func withConnection( + for target: ConnectionTarget, password: String, + _ operation: @escaping @Sendable (PostgresConnection) async throws -> Result + ) async throws -> Result { + let client = try await client(for: target, password: password) + return try await withThrowingTaskGroup(of: Result.self) { group in + group.addTask { + try await client.withConnection { connection in + try await withTaskCancellationHandler { + try Task.checkCancellation() + do { return try await operation(connection) } catch { + if Task.isCancelled { throw CancellationError() } + throw error + } + } onCancel: { + // Closing the socket also interrupts queries whose driver future ignores cancellation. + connection.close().whenFailure { _ in } + } + } + } + group.addTask { + try await Task.sleep(for: .seconds(30)) + throw URLError(.timedOut) + } + defer { group.cancelAll() } + return try await group.next()! + } + } + + public func renewLeases() async -> [UUID: String] { + await withTaskGroup(of: (UUID, String?).self) { group in + for (id, pool) in pools where pool.registered { + group.addTask { + do { + try await self.withConnection(for: pool.target, password: pool.password) { + connection in + let rows = try await connection.query( + "SELECT inkcre.renew_peer_lease(\(pool.target.peerID),120)", + logger: Self.logger) + for try await _ in rows {} + } + return (id, nil) + } catch { return (id, Self.failure(error).localizedDescription) } + } + } + var failures: [UUID: String] = [:] + for await (id, message) in group { failures[id] = message } + return failures } } } diff --git a/Packages/PeerCore/Sources/PeerCore/SourceJobs.swift b/Packages/PeerCore/Sources/PeerCore/SourceJobs.swift index 7308472..d666073 100644 --- a/Packages/PeerCore/Sources/PeerCore/SourceJobs.swift +++ b/Packages/PeerCore/Sources/PeerCore/SourceJobs.swift @@ -21,16 +21,16 @@ public enum SourceJobs { throw PeerError.needsAction("信息库尚未登记来源扫描命令。") } - public static func pending(source: Int, on client: PostgresClient) async throws -> [Int] { + public static func pending(source: Int, on client: PostgresConnection) async throws -> [Int] { try await client.query( - "UPDATE inkcre.jobs SET status='timed_out' WHERE type=\(type) AND status='running' AND parameters->'source'=to_jsonb(\(source)::bigint) AND started_at + timeout_seconds * interval '1 second' < now()" - ) + "UPDATE inkcre.jobs SET status='timed_out' WHERE type=\(type) AND status='running' AND parameters->'source'=to_jsonb(\(source)::bigint) AND started_at + timeout_seconds * interval '1 second' < now()", + logger: PeerDatabase.logger) try await client.query( - "UPDATE inkcre.jobs SET status='aborted' WHERE type=\(type) AND status='pending' AND abort_requested AND parameters->'source'=to_jsonb(\(source)::bigint)" - ) + "UPDATE inkcre.jobs SET status='aborted' WHERE type=\(type) AND status='pending' AND abort_requested AND parameters->'source'=to_jsonb(\(source)::bigint)", + logger: PeerDatabase.logger) let rows = try await client.query( - "SELECT id FROM inkcre.jobs WHERE type=\(type) AND status='pending' AND parameters->'source'=to_jsonb(\(source)::bigint) ORDER BY id" - ) + "SELECT id FROM inkcre.jobs WHERE type=\(type) AND status='pending' AND parameters->'source'=to_jsonb(\(source)::bigint) ORDER BY id", + logger: PeerDatabase.logger) var ids: [Int] = [] for try await id in rows.decode(Int.self) { ids.append(id) } return ids @@ -53,11 +53,12 @@ public enum SourceJobs { return nil } - public static func check(_ job: SourceJob, on client: PostgresClient) async throws { + public static func check(_ job: SourceJob, on client: PostgresConnection) async throws { try Task.checkCancellation() if Date() >= job.deadline { throw PeerError.needsAction("扫描已超时。") } let rows = try await client.query( - "SELECT status::text,abort_requested FROM inkcre.jobs WHERE id=\(job.id)") + "SELECT status::text,abort_requested FROM inkcre.jobs WHERE id=\(job.id)", + logger: PeerDatabase.logger) for try await (status, aborted) in rows.decode((String, Bool).self) { guard status == "running", !aborted else { throw CancellationError() } return @@ -66,13 +67,13 @@ public enum SourceJobs { } public static func close( - _ job: SourceJob, status: String, state: String, on client: PostgresClient + _ job: SourceJob, status: String, state: String, on client: PostgresConnection ) async throws { guard ["finished", "failed", "timed_out", "aborted"].contains(status) else { throw PeerError.invalid("无效的任务终态。") } try await client.query( - "UPDATE inkcre.jobs SET status=\(status)::inkcre.jobstatus,state=\(state)::jsonb WHERE id=\(job.id) AND status='running'" - ) + "UPDATE inkcre.jobs SET status=\(status)::inkcre.jobstatus,state=\(state)::jsonb WHERE id=\(job.id) AND status='running'", + logger: PeerDatabase.logger) } } diff --git a/README.md b/README.md index c82c5c9..b98383e 100644 --- a/README.md +++ b/README.md @@ -42,23 +42,23 @@ open dist/InKCre.app 连接设置使用 PostgreSQL 主机、端口、库名、独立运行角色及密码,可选 Web 地址。远端使用 TLS 并校验证书;只有回环开发地址允许关闭 TLS。密码存于 Keychain,角色拥有已准入数据库协议权限,不使用迁移管理员或 JWT 签名密钥。 -在“连接”页可以添加命名连接、编辑当前连接,并用“当前连接”切换;保存会同时使用该连接,重新打开应用会恢复上次选择。已有单连接保留原 Peer 身份和 Keychain 引用。切回连接不生成新身份;编辑数据库或运行角色时沿用既有的新连接规则,原条目仍可选择。连接名称只用于本机辨认,不修改共享 Peer 名称。 +在“连接”页可以添加命名连接、编辑当前连接,并用“当前连接”切换;保存会同时使用该连接,重新打开应用会恢复上次选择。切回连接或编辑运行角色、密码、名称时保留连接与 Peer 身份;更换数据库地址才创建新连接,原条目仍可选择。连接名称只用于本机辨认,不修改共享 Peer 名称,也不重建数据库连接池。只读取当前多连接配置格式;不支持的开发配置会报错并保留原文件,不自动迁移或清空。 -切换只改变当前操作的信息库。冻结 Capture 待办继续向原目标恢复,已经授权的目录继续监视并收入其原信息库;不会搬迁、清空材料或将旧目录改投新库。前台目录列表在切换时清空并重新加载,切换完成前禁用其操作。 +切换只改变当前操作的信息库。冻结 Capture 待办继续向原目标恢复,已经授权的目录继续监视并收入其原信息库;目录绑定只保存连接引用、Peer 身份和访问书签,后台与待办从完整连接集合读取最新认证配置;不会搬迁、清空材料或将旧目录改投新库。前台目录列表在切换时清空并重新加载,切换完成前禁用其操作。 凭据使用 macOS file-based Keychain。后台读取禁止系统授权弹窗;密钥串锁定或应用访问被拒绝时,显示需要处理的连接错误,只有主动保存连接时允许交互。访问策略属于进程,因此共享凭据入口串行执行读写并恢复原策略。开发验收应由签名应用保存测试密码,不能用 `security add-generic-password -T` 预置后假定签名分区已正确授权。 -`⌘,` 打开唯一的原生设置窗口,工具栏提供“连接”“扩展”“采集”三页;采集页集中管理快捷键和系统权限。主窗口 Peer 页提供同一连接选择入口,当前连接编辑打开设置窗口中的原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后通知正在运行的 Capture 更新连接,并在后台检查,不重启扩展或丢弃当前草稿。最近检查须同时通过 Peer 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。未配置连接时点击“收入”会打开连接表单;保存或取消都返回原草稿,保存后由用户再次确认收入。 +`⌘,` 打开唯一的原生设置窗口,工具栏提供“连接”“扩展”“采集”三页;采集页集中管理快捷键和系统权限。主窗口 Peer 页提供同一连接选择入口,当前连接编辑打开设置窗口中的原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后通知正在运行的 Capture 更新连接,并在后台检查,不重启扩展或丢弃当前草稿。Host 维护连接的在线状态,扩展在执行时另行检查共享参与;配置已保存或在线不等于扩展可以写入。权限页分别显示辅助功能与屏幕录制的实际许可状态。未配置连接时点击“收入”会打开连接表单;保存或取消都返回原草稿,保存后由用户再次确认收入。 -共享数据库需要本分支的 Capture 回执表与原生分发参与合同。Capture Swift 分发在本 Peer 内构图,用同一 PostgreSQL 事务写图、原图和回执;Python Peer 与 PostgREST 均无需在线。图、材料指纹和收入语义属于 first-party `inkcre/capture`,共享合同见 [Capture Extension 合同](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。 +共享数据库的分发标识需允许原生宿主使用自己的格式。Capture Swift 分发在本 Peer 内构图,用同一 PostgreSQL 事务写入标准 Block/Relation/Storage,无 Capture 专用表;Python Peer 与 PostgREST 均无需在线。图、材料指纹和收入语义属于 first-party `inkcre/capture`,共享合同见 [Capture Extension 合同](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。 -“草稿已保留在本机”表示 SQLite 已成功落盘;“本机已保留,待收入”表示冻结材料和目标已经保存;“结果待确认”表示请求可能已经成功;只有共享回执通过材料指纹核对后才显示“已收入”。网络中断、退出或切换连接不会给旧提交换身份或改目标。后台恢复先查回执,必要时复用原材料、图与原 UUID 向同一信息库重试。共享根被删除后重试也不会重建。 +“草稿已保留在本机”表示 SQLite 已成功落盘;“本机已保留,待收入”表示冻结材料和目标已经保存;“结果待确认”表示请求可能已经成功;事务明确成功或恢复核对 Capture 根的材料摘要后才显示“已收入”。网络中断、退出或切换连接不会给旧提交换身份或改目标。首次发送直接进入提交事务;已发送待办先查原 Capture 根,必要时复用原材料、图与原 UUID 向同一信息库重试。SQLite 已确认的记录不再发送;未确认的提交若在恢复前被其他 Peer 删除,重试可能重新收入。 材料累计最多 32 MiB,支持 PNG/JPEG/GIF/WebP,原生数据库通过 bytea 参数保存原图。失败材料可在“待收入”中导出 JSON,导出不含连接凭据。暂不提供修改已经发送的材料或自动舍弃失败项。 失败记录的“更新密码…”仅更新原连接的 Keychain 项,并按原 UUID、材料与数据库目标重试,不切换当前连接。已收入记录缺少打开地址时,“配置 Web 地址…”仅修改该条记录的展示地址,保存后打开共享内容;记录自身地址优先,同一 Peer 当前连接的 Web 地址仅作补充。 -待收入记录可“取消收入”,停止后续自动发送并保留原材料、UUID 和目标;“继续收入”先查原回执。已发出的请求可能已完成,取消不能撤回共享内容。确认删除待办会停止后续发送并删除本机记录和材料;已经发出的请求仍可能完成,迟到响应不会重新创建本机记录。 +待收入记录可“取消收入”,停止后续自动发送并保留原材料、UUID 和目标;“继续收入”先核对原 Capture 根。已发出的请求可能已完成,取消不能撤回共享内容。确认删除待办会停止后续发送并删除本机记录和材料;已经发出的请求仍可能完成,迟到响应不会重新创建本机记录。 ## 本地文件 @@ -74,9 +74,9 @@ PDF 读取文字层;DOCX 读取主文档与表格文字,遗漏脚注、批 开发包的 Host 配置位于 `~/Library/Application Support/dev.inkcre.capture/connection.json`;Capture 数据位于同目录 `extensions/inkcre-capture/capture.sqlite`,使用 WAL/FULL。确认后清理本机材料,保留共享引用。WAL 和空闲页不提供安全擦除保证。此前未发布开发版的数据文件原样保留,本版没有旧格式导入或 HTTP 回退层。 -`Sources/InKCreApp` 拥有主窗口、导航、菜单与设置。`Packages/PeerCore` 提供数据库、标准图/存储、Source Job、凭据和原生模块生命周期;`extensions/capture` 与 `extensions/local-files` 是独立 Swift 包,各自拥有业务与 Mac 界面。宿主菜单提供独立的“Peer 连接…”,Capture 停用后仍能编辑连接,宿主继续维护 Peer 在线状态。Capture 在实际提交时办理首次准入并校验共享选择,不另设连接状态页或连接轮询。设置中的“扩展”页展示实际携带的分发版本和本机运行状态,可独立启停。本地停用先保存本机停用状态、撤下入口并等待当前工作收束,再尝试更新共享意图;连接或凭据错误只报告共享状态未同步,不阻止停止,材料保留;退出只停止运行,不撤回启用意图。未配置数据库可离线收集;离线开关不覆盖共享参与意图,重连先读取已有选择,不重建已移除记录。明确在线启用可以重新建立本 Peer 的参与。 +`Sources/InKCreApp` 拥有主窗口、导航、菜单与设置。`Packages/PeerCore` 提供数据库、标准图/存储、Source Job、凭据和原生模块生命周期;`extensions/capture` 与 `extensions/local-files` 是独立 Swift 包,各自拥有业务与 Mac 界面。宿主菜单提供独立的“Peer 连接…”,Capture 停用后仍能编辑连接,宿主继续维护 Peer 在线状态。Capture 在实际提交时办理首次准入并校验共享选择,不另设连接状态页或连接轮询。设置中的“扩展”页展示实际携带的分发版本和本机运行状态,可独立启停。“在此 Mac 启用”只控制本机入口和任务,不读写远端启用意图,离线也可操作;停用等待当前工作收束并保留材料,退出只停止运行。共享停用、移除或版本不匹配仍阻止写入。扩展设置中的“在「信息库」启用本机版本”明确对指定信息库选择当前 bundled 版本并启用参与,可用于升级后恢复或重新加入;普通重连不会覆盖共享选择。 -Capture 的精确 manifest 随签名应用交付,格式为 `swift_bundled`。当前不实现动态插件下载/热加载,其他版本需更新应用。扩展版本与应用版本分开,不声称已有 Registry 原生 Swift 下载通道。 +Capture 的精确 manifest 随签名应用交付,格式为 `swift_bundled`。当前不实现动态插件下载/热加载,其他版本需更新应用。扩展版本与应用版本分开,不声称已有 Registry 原生 Swift 下载通道。目录扩展每次启动或首次使用连接时初始化类型定义,30 秒刷新只检查参与与来源;Host 对已使用的连接统一续租。手动目录扫描逐文件检查本地取消与截止时间,远端取消以 5 秒为间隔在文件处理边界检查,并在结束前再次确认。 菜单栏 template 图标和明暗面板标识通过 InKCre UI 的 SwiftPM product `InKCreLogoAssets` 取得,当前依赖正式版本 `2.6.0`,使用 exact 约束并由 `Package.resolved` 锁定提交。SwiftPM 解析资源,打包脚本将资源 bundle 嵌入最终 `.app`,普通构建无需解压 ZIP、安装 Web 依赖或运行渲染器。应用 ICNS 继续由本仓库维护,在原标志外使用 Mac 专用圆角底板和透明边距,避免通用白底画布满版导致 Dock 尺寸过大。依赖与资源路径、原始出处、小尺寸/高 DPI 几何与图标再生步骤见 [Mac 标识资源](extensions/capture/Sources/CaptureMac/Resources/Brand/README.md)。 @@ -91,3 +91,5 @@ Swift 测试覆盖冻结包重开、目标绑定、原子确认、原生构图 原创 Swift 代码使用 MIT,Cida 选区读取与采集协调改编保留 Apache-2.0,BetterShot 选区与冻结图组件保留 BSD-3-Clause。见 [开源许可与致谢](THIRD_PARTY_NOTICES.md)。构建脚本从本次解析的依赖收集完整许可,随开发包交付;菜单“开源许可与致谢…”可打开通知。 + +数据库操作将租用、结果读取和事务限制在同一个可取消作用域,单次最多等待 30 秒;取消或超时关闭当前连接。在途 Capture 仍通过原提交恢复确认。各信息库独立续租、刷新目录和处理采集待办,同一信息库内的采集待办按原顺序处理。停用本地扫描后不再另起网络任务写回 Job 终态,未确认终态由共享 Job 的既有截止时间收束。 diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift index 3762ebb..8c30419 100644 --- a/Sources/InKCreApp/AppMain.swift +++ b/Sources/InKCreApp/AppMain.swift @@ -99,11 +99,13 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI do { try await reloadConnections() if let target = connection { - _ = try await PeerDatabase.shared.register( + try await PeerDatabase.shared.register( target, password: context.credentials.read(for: target.id)) } connectionIssue = nil } catch { connectionIssue = PeerDatabase.failure(error).localizedDescription } + let failures = await PeerDatabase.shared.renewLeases() + if let id = connection?.id, let failure = failures[id] { connectionIssue = failure } updateStatus() do { try await Task.sleep(for: .seconds(30)) } catch { break } } @@ -237,6 +239,18 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI } } + func useBundledVersion(_ descriptor: NativeExtensionDescriptor, target: ConnectionTarget) + async throws + { + let connection = try await context.connections.resolve(target) + do { + try await PeerDatabase.shared.useBundledVersion( + descriptor, target: connection, + password: context.credentials.read(for: connection.id)) + await localFiles.model.refresh() + } catch { throw PeerDatabase.failure(error) } + } + private func updateStatus() { objectWillChange.send() } func reloadConnections() async throws { @@ -316,14 +330,19 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI return false } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + private func stop(_ runtime: BundledExtensionRuntime) async throws { + await runtime.waitUntilIdle() + try await runtime.stop() + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { presence?.cancel() Task { do { - await runtime.waitUntilIdle() - try await runtime.stop() - await filesRuntime.waitUntilIdle() - try await filesRuntime.stop() + async let captureStopped: Void = stop(runtime) + async let filesStopped: Void = stop(filesRuntime) + try await captureStopped + try await filesStopped await presence?.value await PeerDatabase.shared.shutdown() sender.reply(toApplicationShouldTerminate: true) diff --git a/Sources/InKCreApp/PeerSettingsWindow.swift b/Sources/InKCreApp/PeerSettingsWindow.swift index 09267c0..55e62c1 100644 --- a/Sources/InKCreApp/PeerSettingsWindow.swift +++ b/Sources/InKCreApp/PeerSettingsWindow.swift @@ -112,6 +112,9 @@ struct ConnectionSettingsView: View { private struct ExtensionSettingsView: View { @ObservedObject var host: PeerHost + @State private var selecting = false + @State private var issue: String? + @State private var result: String? var body: some View { Form { Section("随手收集") { @@ -123,15 +126,13 @@ private struct ExtensionSettingsView: View { Text( host.runtime.applying ? "正在应用…" - : host.runtime.syncIssue - ?? (host.runtime.running - ? "本机运行中" - : host.runtime.enabled ? "尚未启动;请停用后重新启用。" : "已停用;本机材料与共享内容保留") + : (host.runtime.running + ? "本机运行中" + : host.runtime.enabled ? "尚未启动" : "已停用;本机材料与共享内容保留") ) .foregroundStyle(.secondary) LabeledContent("版本", value: host.capture.descriptor.version) - Text("随应用交付,提供选区、粘贴、截图与想法采集。") - .font(.caption).foregroundStyle(.secondary) + participation(host.capture.descriptor) } Section("本地文件") { Toggle( @@ -144,12 +145,35 @@ private struct ExtensionSettingsView: View { Text( host.filesRuntime.applying ? "正在应用…" - : host.filesRuntime.syncIssue - ?? (host.filesRuntime.running ? "本机运行中" : "已停用") + : (host.filesRuntime.running + ? "本机运行中" : host.filesRuntime.enabled ? "尚未启动" : "已停用") ) .foregroundStyle(.secondary) LabeledContent("版本", value: host.localFiles.descriptor.version) + participation(host.localFiles.descriptor) } + if let issue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } + if let result { Text(result).textSelection(.enabled) } }.formStyle(.grouped) } + + @ViewBuilder private func participation(_ descriptor: NativeExtensionDescriptor) -> some View { + if let target = host.connection { + Button("在「\(target.name)」启用本机版本") { + selecting = true + issue = nil + result = nil + Task { + defer { selecting = false } + do { + try await host.useBundledVersion(descriptor, target: target) + result = "已在「\(target.name)」启用 \(descriptor.name) \(descriptor.version)" + } catch { + issue = error.localizedDescription + } + } + } + .disabled(selecting || host.changingConnection) + } + } } diff --git a/Tests/CaptureMacTests/CaptureInteractionTests.swift b/Tests/CaptureMacTests/CaptureInteractionTests.swift index 5921dcf..aade01b 100644 --- a/Tests/CaptureMacTests/CaptureInteractionTests.swift +++ b/Tests/CaptureMacTests/CaptureInteractionTests.swift @@ -8,6 +8,7 @@ import Testing @Suite(.serialized) @MainActor struct CaptureInteractionTests { + init() { _ = NSApplication.shared } @Test func emptyCaptureShortcutDoesNotOpenConnectionOrStartSubmission() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString) @@ -41,7 +42,7 @@ struct CaptureInteractionTests { try await model.store.confirm( old.id, result: CaptureResult( - submissionID: old.id, rootBlockID: 42, committedAt: Date(), availability: .available + submissionID: old.id, rootBlockID: 42, committedAt: Date() )) model.newThought() for _ in 0..<100 where model.submitting { try await Task.sleep(for: .milliseconds(10)) } @@ -145,10 +146,15 @@ struct CaptureInteractionTests { host: "original.test", database: "original", username: "test") let active = ConnectionTarget(host: "active.test", database: "active", username: "test") model.connection = active + try await model.connections.save(originalTarget) try await model.connections.save(active) let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "原提交材料")])) _ = try await model.store.freeze(draft, target: originalTarget) let cancelled = try await model.store.cancel(draft.id) + var edited = originalTarget + edited.username = "updated_role" + try await model.connections.save(edited) + _ = try await model.connections.select(active.id) // Merely opening and cancelling the editor must leave cancelled work dormant. await model.sendPending(force: true) #expect(await transport.calls.isEmpty) @@ -159,15 +165,16 @@ struct CaptureInteractionTests { #expect(model.connection == active) #expect(try await model.connections.load() == active) let calls = await transport.calls - #expect(calls.map(\.kind) == ["recover", "submit"]) + #expect(calls.map(\.kind) == ["submit"]) for call in calls { #expect( - call.record.id == draft.id && call.record.target == originalTarget + call.record.id == draft.id && call.record.target == edited && call.secret == "new-fixture-password") #expect( try CaptureJSON.encode(call.record.payload) == CaptureJSON.encode(draft.payload)) } #expect(model.records.first?.state == .confirmed) + #expect(try await model.store.record(draft.id)?.target == originalTarget) } @Test func submittedFeedbackRemainsFindableWhileWritingTheNextDraft() async throws { @@ -187,6 +194,7 @@ struct CaptureInteractionTests { defer { model.cancelWork() } let target = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "test") model.connection = target + try await model.connections.save(target) try model.credentials.save("fixture", for: target.id) model.thought = "第一条想法" let submittedID = model.draft.id @@ -219,11 +227,12 @@ struct CaptureInteractionTests { host: "original.test", database: "original", username: "test") let active = ConnectionTarget(host: "active.test", database: "active", username: "test") model.connection = active + try await model.connections.save(original) try await model.connections.save(active) let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "历史记录")])) _ = try await model.store.freeze(draft, target: original) let receipt = CaptureResult( - submissionID: draft.id, rootBlockID: 42, committedAt: Date(), availability: .available) + submissionID: draft.id, rootBlockID: 42, committedAt: Date()) try await model.store.confirm(draft.id, result: receipt) let confirmed = try #require(try await model.store.record(draft.id)) let updated = try await model.updateWebAddress("https://original-web.test", for: confirmed) @@ -256,7 +265,7 @@ struct CaptureInteractionTests { record.target = target record.state = .confirmed record.result = CaptureResult( - submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) + submissionID: record.id, rootBlockID: 42, committedAt: Date()) #expect(model.resultURL(for: record) == nil) var configured = target configured.webURL = URL(string: "https://fixture.test") @@ -269,10 +278,6 @@ struct CaptureInteractionTests { host: "other.test", database: "other", username: "inkcre_core", webURL: URL(string: "https://other.test")) #expect(model.resultURL(for: record) == nil) - model.connection = configured - record.result = CaptureResult( - submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .deleted) - #expect(model.resultURL(for: record) == nil) } @Test func commandAvailabilityFollowsEditorContentAndPresentation() throws { @@ -411,6 +416,6 @@ private actor JourneyTransport: CaptureTransport { func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult { calls.append(Call(kind: "submit", record: record, secret: secret)) return CaptureResult( - submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) + submissionID: record.id, rootBlockID: 42, committedAt: Date()) } } diff --git a/Tests/CaptureTests/CaptureContractTests.swift b/Tests/CaptureTests/CaptureContractTests.swift index 549f09d..b1ced26 100644 --- a/Tests/CaptureTests/CaptureContractTests.swift +++ b/Tests/CaptureTests/CaptureContractTests.swift @@ -30,30 +30,25 @@ import Testing Bundle.module.url( forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1] - let data = try CaptureJSON.encode(CaptureCommit(fixture.payload)) - #expect(data == (try CaptureJSON.encode(CaptureCommit(fixture.payload)))) - let document = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - #expect(document["payload_digest"] as? String == fixture.digest) - let graph = try #require(document["graph"] as? [String: Any]) - let blocks = try #require(graph["blocks"] as? [[String: Any]]) - let relations = try #require(graph["relations"] as? [[String: Any]]) - let blobs = try #require(graph["blobs"] as? [[String: Any]]) - #expect(blocks.map { $0["id"] as? Int } == [-1, -2, -3, -4, -5]) + let command = try CaptureCommit(fixture.payload) + let graph = command.graph + #expect(command.payload_digest == fixture.digest) + #expect(graph.blocks.map(\.id) == [-1, -2, -3, -4, -5]) #expect( - blocks.map { $0["resolver"] as? String } == [ + graph.blocks.map(\.resolver) == [ "extensions.capture.capture.v1", "core.text.v1", "extensions.capture.attachment.v1", "core.image.v1", "core.text.v1", ]) - #expect(blocks[1]["content"] as? String == fixture.payload.materials[0].text) - #expect(blocks[4]["content"] as? String == fixture.payload.annotation) + let root = try #require( + JSONSerialization.jsonObject(with: Data(graph.blocks[0].content.utf8)) as? [String: Any]) #expect( - relations.map { $0["content"] as? String } == [ - "material:0", "content", "material:1", "annotation", - ]) - #expect(blobs.count == 1 && blobs[0]["block_id"] as? Int == -4) + root["submission_id"] as? String == fixture.payload.submissionID.uuidString.lowercased()) + #expect(root["payload_digest"] as? String == fixture.digest) + #expect(graph.blocks[1].content == fixture.payload.materials[0].text) + #expect(graph.blocks[4].content == fixture.payload.annotation) #expect( - Data(base64Encoded: try #require(blobs[0]["data_base64"] as? String)) - == fixture.payload.materials[1].data) + graph.relations.map(\.content) == ["material:0", "content", "material:1", "annotation"]) + #expect(graph.blobs.count == 1 && graph.blobs[-4] == fixture.payload.materials[1].data) } @Test func nativeValidationRejectsMismatchedImagesAndCountsUTF8Bytes() throws { diff --git a/Tests/CaptureTests/CaptureLifecycleTests.swift b/Tests/CaptureTests/CaptureLifecycleTests.swift index c6f515f..ab565b9 100644 --- a/Tests/CaptureTests/CaptureLifecycleTests.swift +++ b/Tests/CaptureTests/CaptureLifecycleTests.swift @@ -5,11 +5,16 @@ import Security import Testing private actor PausedRecovery: CaptureTransport { + private let pausedTarget: UUID? + init(pausedTarget: UUID? = nil) { self.pausedTarget = pausedTarget } 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? { + if let pausedTarget, record.target?.id != pausedTarget { + return CaptureResult(submissionID: record.id, rootBlockID: 123, committedAt: Date()) + } await withCheckedContinuation { continuation in release = continuation entered = true @@ -42,15 +47,19 @@ private actor PausedRecovery: CaptureTransport { } let target = ConnectionTarget(host: "localhost", database: "inkcre", username: "test") let credentials = CredentialStore(service: service) + let connections = ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + try await connections.save(target) try credentials.save("test-password", for: target.id) let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) for text in ["第一份", "第二份"] { - _ = try await store.freeze( + let frozen = try await store.freeze( CaptureRecord(payload: CapturePayload(materials: [Material(text: text)])), target: target) + _ = try await store.markDispatch(frozen.id) } let transport = PausedRecovery() - let queue = CaptureQueue(store: store, credentials: credentials, client: transport) + let queue = CaptureQueue( + store: store, connections: connections, credentials: credentials, client: transport) let processing = Task { try await queue.process(force: true) } await transport.waitForRecovery() let stopped = Task { await queue.stop() } @@ -60,5 +69,48 @@ private actor PausedRecovery: CaptureTransport { await stopped.value try await queue.process(force: true) #expect(await transport.dispatches == 0) - #expect(try await store.records().allSatisfy { $0.state == .pending && $0.attempts == 0 }) + #expect(try await store.records().allSatisfy { $0.state == .unknown && $0.attempts == 1 }) +} + +@Test func aStalledInfoBaseDoesNotBlockAnotherCaptureDestination() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let service = "dev.inkcre.independent.\(UUID().uuidString)" + defer { + SecItemDelete( + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service] + as CFDictionary) + try? FileManager.default.removeItem(at: directory) + } + let first = ConnectionTarget(host: "localhost", database: "first", username: "test") + let second = ConnectionTarget(host: "localhost", database: "second", username: "test") + let credentials = CredentialStore(service: service) + let connections = ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + var ids: [UUID] = [] + for target in [first, second] { + try await connections.save(target) + try credentials.save("test-password", for: target.id) + let frozen = try await store.freeze( + CaptureRecord(payload: CapturePayload(materials: [Material(text: target.database)])), + target: target) + _ = try await store.markDispatch(frozen.id) + ids.append(frozen.id) + } + let transport = PausedRecovery(pausedTarget: first.id) + let queue = CaptureQueue( + store: store, connections: connections, credentials: credentials, client: transport) + let processing = Task { try await queue.process(force: true) } + await transport.waitForRecovery() + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while try await store.record(ids[1])?.result == nil, ContinuousClock.now < deadline { + await Task.yield() + } + #expect(try await store.record(ids[1])?.result != nil) + #expect(try await store.record(ids[0])?.payload != nil) + let stopped = Task { await queue.stop() } + while !(await queue.isStopped) { await Task.yield() } + await transport.finishRecovery() + _ = try await processing.value + await stopped.value + #expect(try await store.record(ids[0])?.state == .unknown) } diff --git a/Tests/CaptureTests/CaptureStoreTests.swift b/Tests/CaptureTests/CaptureStoreTests.swift index 6ccad47..a3ad808 100644 --- a/Tests/CaptureTests/CaptureStoreTests.swift +++ b/Tests/CaptureTests/CaptureStoreTests.swift @@ -41,9 +41,13 @@ struct CaptureStoreTests { try await store.report(draft.id, state: .pending, message: "迟到的错误") // No credential exists: an accidental dispatch would change the state to needsAction. let credentials = CredentialStore(service: "dev.inkcre.test.\(UUID().uuidString)") + let connections = ConnectionSettings( + url: directory.appendingPathComponent("connection.json")) + try await connections.save(target) #expect(try await store.pendingRecords().isEmpty) #expect( - try await CaptureQueue(store: store, credentials: credentials).process(force: true) + try await CaptureQueue(store: store, connections: connections, credentials: credentials) + .process(force: true) == false) let retained = try #require(try await store.record(draft.id)) #expect(retained.state == .cancelled) @@ -77,7 +81,7 @@ struct CaptureStoreTests { try await store.deleteRecord(draft.id) let result = CaptureResult( submissionID: draft.id, rootBlockID: 42, - committedAt: Date(timeIntervalSince1970: 1_780_000_000), availability: .available) + committedAt: Date(timeIntervalSince1970: 1_780_000_000)) try await store.confirm(draft.id, result: result) try await store.report(draft.id, state: .unknown, message: "删除后的迟到失败") #expect(try await store.record(draft.id) == nil) @@ -184,8 +188,7 @@ struct CaptureStoreTests { _ = try await store.markDispatch(draft.id) let result = CaptureResult( submissionID: draft.id, rootBlockID: 123, - committedAt: Date(timeIntervalSince1970: 1_780_000_000), - availability: .deleted + committedAt: Date(timeIntervalSince1970: 1_780_000_000) ) try await store.confirm(draft.id, result: result) let reopened = try CaptureStore(url: url) diff --git a/Tests/CaptureTests/ConnectionSettingsTests.swift b/Tests/CaptureTests/ConnectionSettingsTests.swift index f2a7cae..29f0e50 100644 --- a/Tests/CaptureTests/ConnectionSettingsTests.swift +++ b/Tests/CaptureTests/ConnectionSettingsTests.swift @@ -1,5 +1,6 @@ import Foundation import PeerCore +import Security import Testing struct ConnectionSettingsTests { @@ -29,17 +30,69 @@ struct ConnectionSettingsTests { #expect(try await reopened.load() == second) } - @Test func preservesExistingSingleConnectionAndRejectsCorruptLists() async throws { + @Test func rejectsUnsupportedOrCorruptFormatsWithoutModifyingData() async throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: url) } let original = ConnectionTarget(host: "one.example", database: "first", username: "peer") try JSONEncoder().encode(original).write(to: url) let settings = ConnectionSettings(url: url) - #expect(try await settings.loadAll().connections == [original]) - #expect(try await settings.select(original.id) == original) - #expect(try await ConnectionSettings(url: url).load() == original) + let previous = try Data(contentsOf: url) + await #expect(throws: (any Error).self) { try await settings.loadAll() } + #expect(try Data(contentsOf: url) == previous) try Data("{\"connections\":42}".utf8).write(to: url) await #expect(throws: (any Error).self) { try await settings.loadAll() } #expect(try String(contentsOf: url, encoding: .utf8) == "{\"connections\":42}") } + @Test func editingCredentialsKeepsIdentityAndResolvesWhileAnotherConnectionIsSelected() + async throws + { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + let service = "dev.inkcre.connection-edit.\(UUID())" + let credentials = CredentialStore(service: service) + defer { + SecItemDelete( + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] as CFDictionary) + try? FileManager.default.removeItem(at: directory) + } + let settings = ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + 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( + host: first.host, port: "5432", database: first.database, + username: "new_role", tls: .require, web: "https://first.example", + password: "synthetic", + name: "新名称", previous: first, credentials: credentials) + #expect(edited.id == first.id && edited.peerID == first.peerID) + #expect(try await settings.loadAll().connections == [edited, second]) + _ = try await settings.select(second.id) + #expect(try await settings.resolve(first) == edited) + #expect(try await settings.load() == second) + var redirected = edited + redirected.database = "another_database" + 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) + } + + @Test func displayEditsReusePoolAndCredentialEditsReplaceIt() async throws { + let database = PeerDatabase() + let target = ConnectionTarget(host: "localhost", database: "unused", username: "peer") + let original = try await database.client(for: target, password: "unused") + var edited = target + edited.name = "新名称" + 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(replaced !== original) + #expect(try await database.client(for: edited, password: "changed") !== replaced) + await database.shutdown() + } + } diff --git a/Tests/CaptureTests/LivePeerTests.swift b/Tests/CaptureTests/LivePeerTests.swift index 6239c8e..8f625f0 100644 --- a/Tests/CaptureTests/LivePeerTests.swift +++ b/Tests/CaptureTests/LivePeerTests.swift @@ -1,6 +1,7 @@ import Capture import Foundation import PeerCore +import PostgresNIO import Security import Testing @@ -52,6 +53,8 @@ func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async th let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let service = "dev.inkcre.native.acceptance.\(UUID().uuidString)" let credentials = CredentialStore(service: service) + let connections = ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + try await connections.save(target) try credentials.save(password, for: target.id) defer { SecItemDelete( @@ -64,11 +67,15 @@ func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async th _ = try await store.freeze(CaptureRecord(payload: payload), target: target) _ = try await store.markDispatch(payload.submissionID) let reopened = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - try await CaptureQueue(store: reopened, credentials: credentials, client: client).process( + try await CaptureQueue( + store: reopened, connections: connections, credentials: credentials, client: client + ).process( force: true) let recovered = try #require(try await reopened.record(payload.submissionID)) #expect(recovered.state == .confirmed && recovered.payload == nil) #expect(recovered.result == result) + try await database.useBundledVersion( + CaptureClient.descriptor, target: target, password: password) var changed = payload changed.annotation = "另一份材料" @@ -78,7 +85,9 @@ func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async th _ = try await client.submit(conflict, secret: password) } - let before = try await pg.query("SELECT count(*) FROM inkcre.blocks").decode(Int.self).first { + let before = try await pg.query( + "SELECT count(*) FROM inkcre.blocks WHERE content='must rollback'" + ).decode(Int.self).first { _ in true } await #expect(throws: (any Error).self) { @@ -89,15 +98,20 @@ func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async th on: connection) } } - let after = try await pg.query("SELECT count(*) FROM inkcre.blocks").decode(Int.self).first { + let after = try await pg.query( + "SELECT count(*) FROM inkcre.blocks WHERE content='must rollback'" + ).decode(Int.self).first { _ in true } #expect(before == after) try await pg.query("DELETE FROM inkcre.blocks WHERE id=\(result.rootBlockID)") - #expect(try await client.submit(record, secret: password).availability == .deleted) - try await database.setEnabled( - false, target: target, password: password, extension: CaptureClient.descriptor) + #expect(try await client.recover(record, secret: password) == nil) + // A locally confirmed capture is never queued again, even if its shared root is deleted. + #expect(try await reopened.pendingRecords().isEmpty) + try await pg.query( + "UPDATE inkcre.extension_peer_participations SET enabled=false WHERE peer_id=\(target.peerID)" + ) do { _ = try await client.submit(record, secret: password) Issue.record("停用后不得继续共享写入") @@ -115,14 +129,14 @@ func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async th ).decode(Int.self).first { _ in true } #expect(removed == 0) // An explicit enable is allowed to create a first participation before Capture starts. - try await database.setEnabled( - true, target: target, password: password, extension: CaptureClient.descriptor) + try await database.useBundledVersion( + CaptureClient.descriptor, target: target, password: password) _ = try await client.submit(record, secret: password) let first = ConnectionTarget( host: target.host, port: target.port, database: target.database, username: target.username, tls: target.tls) - try await database.setEnabled( - true, target: first, password: password, extension: CaptureClient.descriptor) + try await database.useBundledVersion( + CaptureClient.descriptor, target: first, password: password) var firstRecord = CaptureRecord(payload: CapturePayload(materials: [Material(text: "首次准入")])) firstRecord.target = first try await pg.query( @@ -134,8 +148,93 @@ func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async th "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)" ).decode(Int.self).first { _ in true } #expect(removedBeforeFirstJoin == 0) - try await database.setEnabled( - true, target: first, password: password, extension: CaptureClient.descriptor) + try await database.useBundledVersion( + CaptureClient.descriptor, target: first, password: password) _ = try await client.submit(firstRecord, secret: password) + try await pg.query( + "UPDATE inkcre.extension_peer_participations SET version='0.0.9',enabled=false WHERE peer_id=\(first.peerID)" + ) + await #expect(throws: PeerError.self) { + try await database.join(first, password: password, extension: CaptureClient.descriptor) + } + let old = try await pg.query( + "SELECT version,enabled FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)" + ) + .decode((String, Bool).self).first { _ in true } + #expect(old?.0 == "0.0.9" && old?.1 == false) + try await database.useBundledVersion( + CaptureClient.descriptor, target: first, password: password) + try await database.join(first, password: password, extension: CaptureClient.descriptor) + let registeredVersion = try await pg.query( + "SELECT xmin::text FROM inkcre.peers WHERE id=\(first.peerID)" + ) + .decode(String.self).first { _ in true } + try await database.register(first, password: password) + let unchangedVersion = try await pg.query( + "SELECT xmin::text FROM inkcre.peers WHERE id=\(first.peerID)" + ) + .decode(String.self).first { _ in true } + #expect(registeredVersion == unchangedVersion) + try await pg.query( + "UPDATE inkcre.peers SET lease_expires_at=now()-interval '1 second' WHERE id IN (\(first.peerID),\(target.peerID))" + ) + #expect(await database.renewLeases().isEmpty) + let online = try await pg.query( + "SELECT count(*) FROM inkcre.peers WHERE id IN (\(first.peerID),\(target.peerID)) AND lease_expires_at > now()" + ) + .decode(Int.self).first { _ in true } + #expect(online == 2) + await database.shutdown() +} + +@Test(.enabled(if: ProcessInfo.processInfo.environment["INKCRE_CAPTURE_TEST_DATABASE"] != nil)) +func cancellingAStalledQueryReleasesItsConnectionAndRollsBack() async throws { + let env = ProcessInfo.processInfo.environment + let target = ConnectionTarget( + host: "127.0.0.1", port: Int(env["INKCRE_CAPTURE_TEST_PORT"] ?? "5432")!, + database: try #require(env["INKCRE_CAPTURE_TEST_DATABASE"]), username: "inkcre_core", + tls: .disable) + let password = try #require(env["INKCRE_CAPTURE_TEST_PASSWORD"]) + let database = PeerDatabase() + let marker = "cancel-\(UUID().uuidString)" + let sleepingQuery = "SELECT pg_sleep(600) /* \(marker) */" + let blocked = Task { + try await database.withConnection(for: target, password: password) { connection in + try await connection.withTransaction(logger: PeerDatabase.logger) { connection in + try await connection.query( + "INSERT INTO inkcre.blocks(resolver,content) VALUES ('core.text.v1',\(marker))", + logger: PeerDatabase.logger) + let rows = try await connection.query( + PostgresQuery(unsafeSQL: sleepingQuery), logger: PeerDatabase.logger) + for try await _ in rows {} + } + } + } + let pg = try await database.client(for: target, password: password) + var active = false + for _ in 0..<100 { + let rows = try await pg.query( + "SELECT count(*)::integer FROM pg_stat_activity WHERE datname=current_database() AND query=\(sleepingQuery) AND state='active'" + ) + for try await count in rows.decode(Int.self) { active = count > 0 } + if active { break } + try await Task.sleep(for: .milliseconds(20)) + } + #expect(active) + let start = ContinuousClock.now + blocked.cancel() + do { + try await blocked.value + Issue.record("Cancelled transaction unexpectedly completed") + } catch {} + #expect(start.duration(to: .now) < .seconds(2)) + let count = try await database.withConnection(for: target, password: password) { connection in + let rows = try await connection.query( + "SELECT count(*)::integer FROM inkcre.blocks WHERE content=\(marker)", + logger: PeerDatabase.logger) + for try await count in rows.decode(Int.self) { return count } + return -1 + } + #expect(count == 0) await database.shutdown() } diff --git a/Tests/CaptureTests/NativeExtensionTests.swift b/Tests/CaptureTests/NativeExtensionTests.swift index d4239fc..f772684 100644 --- a/Tests/CaptureTests/NativeExtensionTests.swift +++ b/Tests/CaptureTests/NativeExtensionTests.swift @@ -7,20 +7,12 @@ import Testing name: "inkcre/test", distribution: "swift_bundled", version: "0.1.0") var starts = 0 var stops = 0 - var intents: [Bool] = [] - var failure: PeerError? - var stoppedBeforeSync = false func start() throws { starts += 1 } func stop() async throws { stops += 1 } - func setEnabled(_ enabled: Bool) async throws { - intents.append(enabled) - stoppedBeforeSync = stops > 0 - if let failure { throw failure } - } func showMain() {} } -@Test @MainActor func nativeExtensionLifecycleKeepsRuntimeSeparateFromEnabledIntent() async throws { +@Test @MainActor func nativeExtensionLifecycleWorksWithoutAnyConnection() async throws { let domain = "dev.inkcre.extension-tests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: domain)! defer { defaults.removePersistentDomain(forName: domain) } @@ -29,37 +21,14 @@ import Testing try runtime.startIfEnabled() #expect(runtime.running && module.starts == 1) try await runtime.stop() - #expect(!runtime.running && runtime.enabled && module.intents.isEmpty) + #expect(!runtime.running && runtime.enabled) try runtime.startIfEnabled() try await runtime.setEnabled(false) - #expect(!runtime.running && !runtime.enabled && module.intents == [false]) + #expect(!runtime.running && !runtime.enabled && module.stops == 2) let restarted = BundledExtensionRuntime(module: module, defaults: defaults) try restarted.startIfEnabled() #expect(!restarted.running && module.starts == 2) try await restarted.setEnabled(true) #expect(restarted.running && module.starts == 3) - #expect(module.intents == [false, true]) try await restarted.stop() } - -@Test(arguments: [PeerError.needsAction("凭据不可用"), .connection("数据库离线")]) -@MainActor func disablingLocallySurvivesSharedStateFailure(_ failure: PeerError) async throws { - let domain = "dev.inkcre.extension-tests.\(UUID())" - let defaults = try #require(UserDefaults(suiteName: domain)) - defer { defaults.removePersistentDomain(forName: domain) } - let module = Module() - module.failure = failure - let runtime = BundledExtensionRuntime(module: module, defaults: defaults) - try runtime.startIfEnabled() - try await runtime.setEnabled(false) - #expect(!runtime.running && !runtime.enabled && module.stops == 1) - #expect(module.stoppedBeforeSync) - #expect(runtime.syncIssue?.contains(failure.localizedDescription) == true) - let reopened = BundledExtensionRuntime(module: module, defaults: defaults) - try reopened.startIfEnabled() - #expect(!reopened.running) - if case .needsAction = failure { - await #expect(throws: PeerError.self) { try await reopened.setEnabled(true) } - #expect(!reopened.running && !reopened.enabled) - } -} diff --git a/Tests/HostTests/HostInterfaceTests.swift b/Tests/HostTests/HostInterfaceTests.swift index 0f1d919..f554475 100644 --- a/Tests/HostTests/HostInterfaceTests.swift +++ b/Tests/HostTests/HostInterfaceTests.swift @@ -49,6 +49,18 @@ struct HostInterfaceTests { to: URL(fileURLWithPath: path).appendingPathComponent("multiple-connections.png")) } + if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { + let window = PeerSettingsWindow(host: host) + defer { window.orderOut(nil) } + window.select(.extensions) + try await Task.sleep(for: .milliseconds(150)) + let view = try #require(window.contentView?.superview) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: URL(fileURLWithPath: path).appendingPathComponent("extensions-connected.png")) + } #expect( try await ConnectionSettings(url: directory.appendingPathComponent("connection.json")) .load() == second) diff --git a/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift b/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift index 843dcaa..2bd9bc8 100644 --- a/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift +++ b/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift @@ -60,7 +60,19 @@ struct LocalFilesInterfaceTests { model.selectedFile = file.id #expect(model.selectedText == nil && model.loadingText) try await until { model.selectedText == "# 标题\n\n第一段。\n\n- 列表\n" && !model.loadingText } - await model.refresh() + let database = PeerDatabase.shared + let pool = try await database.client( + for: target, password: try #require(env["INKCRE_FILES_TEST_PASSWORD"])) + let catalogVersion = try await pool.query( + "SELECT xmin::text FROM inkcre.sources_types WHERE id=\(LocalFilesContract.sourceType)" + ) + .decode(String.self).first { @Sendable _ in true } + for _ in 0..<3 { await model.refresh() } + let afterRefresh = try await pool.query( + "SELECT xmin::text FROM inkcre.sources_types WHERE id=\(LocalFilesContract.sourceType)" + ) + .decode(String.self).first { @Sendable _ in true } + #expect(catalogVersion == afterRefresh) #expect(model.selectedText != nil && !model.loadingText) try await snapshot(window, name: "files-body") let url = folder.appendingPathComponent("note.md") diff --git a/Tests/LocalFilesTests/LocalFilesTests.swift b/Tests/LocalFilesTests/LocalFilesTests.swift index 0d9522f..1e2f6e9 100644 --- a/Tests/LocalFilesTests/LocalFilesTests.swift +++ b/Tests/LocalFilesTests/LocalFilesTests.swift @@ -169,6 +169,20 @@ func nativeSourceUpdatesCurrentGraphAndPreservesFailedAndDeletedBodies() async t #expect(try await client.claim(jobID, source: source) == nil) let result = try await DirectoryScanner.scan(source: source, client: client, job: job) try await client.close(job, status: "failed", state: result) + let cancelledID = try await client.submitScan(source) + let cancelled = try #require(try await client.claim(cancelledID, source: source)) + await #expect(throws: CancellationError.self) { + try await DirectoryScanner.scan(source: source, client: client, job: cancelled) { + progress in + if progress.completed == 1 { + do { + try await pool.query( + "UPDATE inkcre.jobs SET abort_requested=true WHERE id=\(cancelledID)") + } catch { Issue.record("无法设置合成取消请求:\(error)") } + } + } + } + try await client.close(cancelled, status: "aborted", state: DirectoryState()) try await client.setEnabled(false, source: source) await #expect(throws: (any Error).self) { try await DirectoryScanner.scan(source: source, client: client) diff --git a/extensions/capture/Sources/Capture/CaptureClient.swift b/extensions/capture/Sources/Capture/CaptureClient.swift index 90d2cae..ebb97f2 100644 --- a/extensions/capture/Sources/Capture/CaptureClient.swift +++ b/extensions/capture/Sources/Capture/CaptureClient.swift @@ -1,4 +1,3 @@ -import CryptoKit import Foundation import PeerCore import PostgresNIO @@ -39,25 +38,29 @@ public struct CaptureClient: CaptureTransport { throw PeerError.invalid("待办缺少材料或目标。") } do { - let client = try await database.client(for: target, password: secret) - return try await Self.receipt( - record.id, digest: payload.canonicalDigest(), client: client) + return try await database.withConnection(for: target, password: secret) { connection in + try await Self.result( + from: connection.query(Self.lookup(record.id), logger: PeerDatabase.logger), + id: record.id, digest: payload.canonicalDigest()) + } } catch { throw PeerDatabase.failure(error, dispatched: false) } } - private static func receipt(_ id: UUID, digest: String, client: PostgresClient) async throws + private static func lookup(_ id: UUID) -> PostgresQuery { + """ + SELECT content::jsonb->>'payload_digest',id,created_at FROM inkcre.blocks + WHERE CASE WHEN resolver='extensions.capture.capture.v1' + THEN content::jsonb->>'submission_id' END=\(id.uuidString.lowercased()) + """ + } + + private static func result(from rows: PostgresRowSequence, id: UUID, digest: String) + async throws -> CaptureResult? { - let rows = try await client.query( - """ - SELECT r.payload_digest,r.root_block_id,r.committed_at,EXISTS(SELECT 1 FROM inkcre.blocks b WHERE b.id=r.root_block_id) - FROM inkcre.capture_receipts r WHERE r.submission_id=\(id) - """) - for try await (saved, root, date, exists) in rows.decode((String, Int, Date, Bool).self) { + for try await (saved, root, date) in rows.decode((String, Int, Date).self) { guard saved == digest else { throw PeerError.needsAction("提交身份与已保存材料冲突,原材料已保留。") } - return CaptureResult( - submissionID: id, rootBlockID: root, committedAt: date, - availability: exists ? .available : .deleted) + return CaptureResult(submissionID: id, rootBlockID: root, committedAt: date) } return nil } @@ -67,64 +70,40 @@ public struct CaptureClient: CaptureTransport { throw PeerError.invalid("待办缺少冻结材料或目标。") } let command = try CaptureCommit(payload) - let graphDigest = SHA256.hash(data: try CaptureJSON.encode(command.graph)).map { - String(format: "%02x", $0) - }.joined() do { - _ = try await database.register(target, password: secret) + try await database.register(target, password: secret) try await database.join(target, password: secret, extension: Self.descriptor) - let client = try await database.client(for: target, password: secret) - let result = try await client.withTransaction(logger: PeerDatabase.logger) { - connection in - try await PeerDatabase.requireSelection( - Self.descriptor, peerID: target.peerID, on: connection) - // The UUID lock covers receipt lookup, graph writes and the receipt itself. - try await connection.query( - "SELECT pg_advisory_xact_lock(hashtextextended(\(record.id.uuidString.lowercased()),0))", - logger: PeerDatabase.logger) - let rows = try await connection.query( - """ - SELECT r.payload_digest,r.graph_digest,r.root_block_id,r.committed_at,EXISTS(SELECT 1 FROM inkcre.blocks b WHERE b.id=r.root_block_id) - FROM inkcre.capture_receipts r WHERE r.submission_id=\(record.id) - """, logger: PeerDatabase.logger) - for try await (saved, graph, root, date, exists) in rows.decode( - (String, String, Int, Date, Bool).self) - { - guard saved == command.payload_digest, graph == graphDigest else { - throw PeerError.needsAction("提交身份与已保存材料或图冲突。") + return try await database.withConnection(for: target, password: secret) { connection in + try await connection.withTransaction(logger: PeerDatabase.logger) { connection in + try await PeerDatabase.requireSelection( + Self.descriptor, peerID: target.peerID, on: connection) + // Serialize retries of this submission before checking or writing its graph. + try await connection.query( + "SELECT pg_advisory_xact_lock(hashtextextended(\(record.id.uuidString.lowercased()),0))", + logger: PeerDatabase.logger) + if let existing = try await Self.result( + from: connection.query(Self.lookup(record.id), logger: PeerDatabase.logger), + id: record.id, digest: command.payload_digest) + { + return existing } - return CaptureResult( - submissionID: record.id, rootBlockID: root, committedAt: date, - availability: exists ? .available : .deleted) - } - let ids = try await InfoBase.persist( - blocks: command.graph.blocks, relations: command.graph.relations, - blobs: Dictionary( - uniqueKeysWithValues: command.graph.blobs.map { - ($0.block_id, $0.data_base64) - }), - on: connection - ) - guard let root = ids[command.graph.root_local_id] else { - throw PeerError.invalid("采集图缺少根。") - } - let inserted = try await connection.query( - """ - INSERT INTO inkcre.capture_receipts(submission_id,payload_digest,graph_digest,root_block_id) - VALUES (\(record.id),\(command.payload_digest),\(graphDigest),\(root)) RETURNING committed_at - """, logger: PeerDatabase.logger) - for try await date in inserted.decode(Date.self) { - return CaptureResult( - submissionID: record.id, rootBlockID: root, committedAt: date, - availability: .available) + let ids = try await InfoBase.persist( + blocks: command.graph.blocks, relations: command.graph.relations, + blobs: command.graph.blobs, + on: connection) + guard let root = ids[command.graph.root_local_id] else { + throw PeerError.invalid("采集图缺少根。") + } + let rows = try await connection.query( + "SELECT created_at FROM inkcre.blocks WHERE id=\(root)", + logger: PeerDatabase.logger) + for try await date in rows.decode(Date.self) { + return CaptureResult( + submissionID: record.id, rootBlockID: root, committedAt: date) + } + throw PeerError.outcomeUnknown } - throw PeerError.outcomeUnknown } - guard let persisted = try await recover(record, secret: secret), - persisted.rootBlockID == result.rootBlockID - else { throw PeerError.outcomeUnknown } - return persisted } catch { throw PeerDatabase.failure(error, dispatched: true) } } - } diff --git a/extensions/capture/Sources/Capture/CaptureGraph.swift b/extensions/capture/Sources/Capture/CaptureGraph.swift index 89bb406..8173ead 100644 --- a/extensions/capture/Sources/Capture/CaptureGraph.swift +++ b/extensions/capture/Sources/Capture/CaptureGraph.swift @@ -4,26 +4,23 @@ import PeerCore /// Capture owns this graph; the Host supplies transaction and storage mechanics. // Capture 的序列化字段遵循跨端图协议。 // swift-format-ignore: AlwaysUseLowerCamelCase -struct CaptureCommit: Encodable, Sendable { - struct Blob: Encodable, Sendable { - let block_id: Int - let data_base64: Data - } - - struct Graph: Encodable, Sendable { +struct CaptureCommit: Sendable { + struct Graph: Sendable { let root_local_id = -1 var blocks: [GraphBlock] var relations: [GraphRelation] = [] - var blobs: [Blob] = [] + var blobs: [Int: Data] = [:] } - let submission_id: UUID let payload_digest: String let graph: Graph init(_ payload: CapturePayload) throws { try payload.validate() + let digest = try payload.canonicalDigest() struct Metadata: Encodable { + let submission_id: String + let payload_digest: String let captured_at: Date let entry: CaptureEntry let origin: CaptureOrigin? @@ -40,6 +37,8 @@ struct CaptureCommit: Encodable, Sendable { id: -1, resolver: "extensions.capture.capture.v1", content: try json( Metadata( + submission_id: payload.submissionID.uuidString.lowercased(), + payload_digest: digest, captured_at: payload.capturedAt, entry: payload.entry, origin: payload.origin)) ) @@ -60,7 +59,7 @@ struct CaptureCommit: Encodable, Sendable { content: try json(Attachment(mime_type: mime, filename: material.filename)) )) graph.blocks.append(GraphBlock(id: id - 1, resolver: "core.image.v1", content: "")) - graph.blobs.append(Blob(block_id: id - 1, data_base64: bytes)) + graph.blobs[id - 1] = bytes graph.relations.append(GraphRelation(from_: id, to_: id - 1, content: "content")) } graph.relations.append(GraphRelation(from_: -1, to_: id, content: "material:\(index)")) @@ -70,8 +69,7 @@ struct CaptureCommit: Encodable, Sendable { graph.blocks.append(GraphBlock(id: id, resolver: "core.text.v1", content: annotation)) graph.relations.append(GraphRelation(from_: -1, to_: id, content: "annotation")) } - submission_id = payload.submissionID - payload_digest = try payload.canonicalDigest() + payload_digest = digest self.graph = graph } } diff --git a/extensions/capture/Sources/Capture/CaptureQueue.swift b/extensions/capture/Sources/Capture/CaptureQueue.swift index fcb7c2d..1c8a45a 100644 --- a/extensions/capture/Sources/Capture/CaptureQueue.swift +++ b/extensions/capture/Sources/Capture/CaptureQueue.swift @@ -3,6 +3,7 @@ import PeerCore public actor CaptureQueue { private let store: CaptureStore + private let connections: ConnectionSettings private let credentials: CredentialStore private let client: any CaptureTransport private var running = false @@ -10,9 +11,10 @@ public actor CaptureQueue { private var idleWaiters: [CheckedContinuation] = [] public init( - store: CaptureStore, credentials: CredentialStore, + store: CaptureStore, connections: ConnectionSettings, credentials: CredentialStore, client: any CaptureTransport = CaptureClient() ) { + self.connections = connections self.store = store self.credentials = credentials self.client = client @@ -28,13 +30,25 @@ public actor CaptureQueue { public func process(force: Bool = false) async throws -> Bool { guard !isStopped, !Task.isCancelled, !running else { return false } running = true - var changed = false defer { running = false for waiter in idleWaiters { waiter.resume() } idleWaiters.removeAll() } - for item in try await store.pendingRecords() { + let groups = Dictionary(grouping: try await store.pendingRecords()) { $0.target?.id } + return try await withThrowingTaskGroup(of: Bool.self) { group in + for records in groups.values { + group.addTask { try await self.process(records, force: force) } + } + var changed = false + for try await result in group { changed = changed || result } + return changed + } + } + + private func process(_ records: [CaptureRecord], force: Bool) async throws -> Bool { + var changed = false + for item in records { guard !isStopped, !Task.isCancelled else { return changed } let delay = min(300.0, pow(2.0, Double(min(item.attempts, 8))) * 2) if !force && item.attempts > 0 && Date().timeIntervalSince(item.updatedAt) < delay { @@ -42,14 +56,20 @@ public actor CaptureQueue { } guard let target = item.target else { continue } do { - let secret = try credentials.read(for: target.id) - if let result = try await client.recover(item, secret: secret) { + 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 client.recover(outgoing, secret: secret) + { try await store.confirm(item.id, result: result) changed = true continue } guard !isStopped, !Task.isCancelled else { return changed } - let dispatched = try await store.markDispatch(item.id) + var dispatched = try await store.markDispatch(item.id) + dispatched.target = connection changed = true guard !isStopped, !Task.isCancelled else { return changed } let result = try await client.submit(dispatched, secret: secret) diff --git a/extensions/capture/Sources/Capture/CaptureStore.swift b/extensions/capture/Sources/Capture/CaptureStore.swift index f29586f..7bbf7dc 100644 --- a/extensions/capture/Sources/Capture/CaptureStore.swift +++ b/extensions/capture/Sources/Capture/CaptureStore.swift @@ -196,7 +196,7 @@ public actor CaptureStore { value.message = value.attempts == 0 ? "尚未发送。材料保留在本机,可继续收入。" - : "已停止后续发送;此前请求可能已收入。继续收入时会先确认原回执。" + : "已停止后续发送;此前请求可能已收入。继续收入时会先核对原 Capture 根。" value.updatedAt = Date() try save(value) return value @@ -212,7 +212,7 @@ public actor CaptureStore { guard var value = try record(id), value.state != .draft, result.submissionID == id else { - throw PeerError.invalid("提交回执与本机记录不匹配。") + throw PeerError.invalid("提交结果与本机记录不匹配。") } value.result = result value.state = .confirmed diff --git a/extensions/capture/Sources/Capture/Models.swift b/extensions/capture/Sources/Capture/Models.swift index 26ca56d..dfd6699 100644 --- a/extensions/capture/Sources/Capture/Models.swift +++ b/extensions/capture/Sources/Capture/Models.swift @@ -138,27 +138,22 @@ public struct CapturePayload: Codable, Sendable { } public struct CaptureResult: Codable, Sendable, Equatable { - public enum Availability: String, Codable, Sendable { case available, deleted } public let submissionID: UUID public let rootBlockID: Int public let committedAt: Date - public let availability: Availability - public init(submissionID: UUID, rootBlockID: Int, committedAt: Date, availability: Availability) - { + public init(submissionID: UUID, rootBlockID: Int, committedAt: Date) { self.submissionID = submissionID self.rootBlockID = rootBlockID let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] self.committedAt = formatter.date(from: formatter.string(from: committedAt))! - self.availability = availability } enum CodingKeys: String, CodingKey { case submissionID = "submission_id" case rootBlockID = "root_block_id" case committedAt = "committed_at" - case availability } } @@ -190,7 +185,7 @@ public struct CaptureRecord: Codable, Sendable, Identifiable { case .unknown: "结果待确认" case .needsAction: "需要处理" case .cancelled: "已取消后续发送" - case .confirmed: result?.availability == .deleted ? "曾收入,内容已删除" : "已收入" + case .confirmed: "已收入" } } } diff --git a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift index 630c264..5d8235e 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift @@ -413,20 +413,6 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate } } - public func setEnabled(_ enabled: Bool) async throws { - guard let target = try await context.connections.load() else { return } - let password = try context.credentials.read(for: target.id) - do { - try await PeerDatabase.shared.setEnabled( - enabled, target: target, password: password, extension: descriptor) - } catch { - if case PeerError.connection = PeerDatabase.failure(error) { - throw PeerError.connection("离线:本机开关生效,共享意图未更新。重连以信息库选择为准。") - } - throw PeerDatabase.failure(error) - } - } - public func stop() async throws { guard let model else { return } NSApp.keyWindow?.makeFirstResponder(nil) diff --git a/extensions/capture/Sources/CaptureMac/CaptureModel.swift b/extensions/capture/Sources/CaptureMac/CaptureModel.swift index 58ee467..92500ba 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureModel.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureModel.swift @@ -82,7 +82,8 @@ final class CaptureModel: ObservableObject { shortcuts = try GlobalShortcuts() store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) credentials = CredentialStore(service: "\(identity).connections") - queue = CaptureQueue(store: store, credentials: credentials, client: client) + queue = CaptureQueue( + store: store, connections: self.connections, credentials: credentials, client: client) } func canPerform(_ action: ShortcutAction, isEditor: Bool, isQuickPanel: Bool) -> Bool { @@ -487,7 +488,7 @@ final class CaptureModel: ObservableObject { } func resultURL(for record: CaptureRecord) -> URL? { - guard let result = record.result, result.availability == .available, + guard let result = record.result, let target = record.target else { return nil } let base = target.webURL ?? (connection?.peerID == target.peerID ? connection?.webURL : nil) diff --git a/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift index fa32e45..59c3f65 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift @@ -63,10 +63,7 @@ struct CaptureRecordsView: View { if record.state == .draft { Button("继续编辑") { model.resume(record) } } else if record.state == .confirmed { - if record.result?.availability == .deleted { - Text("共享内容已删除").font(.caption).foregroundStyle( - .secondary) - } else if model.resultURL(for: record) == nil { + if model.resultURL(for: record) == nil { Button("配置 Web 地址…") { webRecord = record } } else { Button("打开") { model.openResult(record) } diff --git a/extensions/local-files/Sources/LocalFiles/DirectoryScanner.swift b/extensions/local-files/Sources/LocalFiles/DirectoryScanner.swift index 6370f5f..13d2bcd 100644 --- a/extensions/local-files/Sources/LocalFiles/DirectoryScanner.swift +++ b/extensions/local-files/Sources/LocalFiles/DirectoryScanner.swift @@ -29,8 +29,18 @@ public enum DirectoryScanner { } var used: Set = [] var errors = 0 + var nextRemoteCheck = ContinuousClock.now + func check(force: Bool = false) async throws { + try Task.checkCancellation() + guard let job else { return } + if Date() >= job.deadline { throw PeerError.needsAction("扫描已超时。") } + if force || ContinuousClock.now >= nextRemoteCheck { + try await client.check(job) + nextRemoteCheck = .now.advanced(by: .seconds(5)) + } + } for (index, url) in files.enumerated() { - try await client.check(job) + try await check() var record = byPath[url.path]?.first let previous = record?.metadata.revision let observation = try await background { @@ -44,14 +54,14 @@ public enum DirectoryScanner { if candidates.count == 1 { record = candidates[0] } } if let record { used.insert(record.id) } - try await client.check(job) + try await check() try await client.save(observation, replacing: record, source: source) if observation.metadata.issue != nil { errors += 1 } await progress( ScanProgress( completed: index + 1, total: files.count, filename: url.lastPathComponent)) } - try await client.check(job) + try await check(force: true) guard try rootIdentity(root) == identity else { throw PeerError.needsAction("目录在扫描中变更,等待重新核对。") } diff --git a/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift b/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift index 82f6dcf..ea75838 100644 --- a/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift +++ b/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift @@ -4,98 +4,108 @@ import PostgresNIO extension LocalFileClient { public func submitScan(_ source: DirectorySource) async throws -> Int { - let client = try await pool() - 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) + return try await database.withConnection(for: target, password: password) { 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) + } } } public func pendingScans(_ source: DirectorySource) async throws -> [Int] { - let client = try await pool() - try await client.withTransaction(logger: PeerDatabase.logger) { connection in - _ = try await locked(source.id, on: connection, expected: source.config) - try await connection.query( - """ - UPDATE inkcre.jobs SET status='failed',state='{"issue":"扫描参数不符合本地文件合同。"}'::jsonb - WHERE type=\(SourceJobs.type) AND status='pending' AND parameters->'source'=to_jsonb(\(source.id)::bigint) - AND (COALESCE(parameters->'config','{}'::jsonb)<>'{}'::jsonb OR parameters-'source'-'config'<>'{}'::jsonb) - """, logger: PeerDatabase.logger) + return try await database.withConnection(for: target, password: password) { 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( + """ + UPDATE inkcre.jobs SET status='failed',state='{"issue":"扫描参数不符合本地文件合同。"}'::jsonb + WHERE type=\(SourceJobs.type) AND status='pending' AND parameters->'source'=to_jsonb(\(source.id)::bigint) + AND (COALESCE(parameters->'config','{}'::jsonb)<>'{}'::jsonb OR parameters-'source'-'config'<>'{}'::jsonb) + """, logger: PeerDatabase.logger) + } + return try await SourceJobs.pending(source: source.id, on: client) } - return try await SourceJobs.pending(source: source.id, on: client) } public func reportIssue(_ message: String, source: DirectorySource) async throws { - let client = try await pool() - 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 - state.issue = message - let json = try FileJSON.encode(state) - try await connection.query( - "UPDATE inkcre.sources SET state=\(json)::jsonb WHERE id=\(source.id)", - logger: PeerDatabase.logger) + return try await database.withConnection(for: target, password: password) { 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 + state.issue = message + let json = try FileJSON.encode(state) + try await connection.query( + "UPDATE inkcre.sources SET state=\(json)::jsonb WHERE id=\(source.id)", + logger: PeerDatabase.logger) + } } } public func claim(_ id: Int, source: DirectorySource) async throws -> SourceJob? { - let client = try await pool() - 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) + return try await database.withConnection(for: target, password: password) { 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) + } } } public func check(_ job: SourceJob?) async throws { try Task.checkCancellation() - if let job { try await SourceJobs.check(job, on: pool()) } + return try await database.withConnection(for: target, password: password) { client in + if let job { try await SourceJobs.check(job, on: client) } + } } public func close(_ job: SourceJob, status: String, state: DirectoryState) async throws { - try await SourceJobs.close(job, status: status, state: FileJSON.encode(state), on: pool()) + return try await database.withConnection(for: target, password: password) { client in + try await SourceJobs.close( + job, status: status, state: FileJSON.encode(state), on: client) + } } public func save( _ observation: FileObservation, replacing record: FileRecord?, source: DirectorySource ) async throws { if let record, !observation.changes(record.metadata) { return } - let client = try await pool() - 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("目录来源缺少资料锚点。") } - var metadata = observation.metadata - if let record { - let current = try await currentRecord(record.id, anchor: anchor, on: connection) - if observation.text == nil { metadata.revision = current.metadata.revision } - if let text = observation.text { - if let body = current.bodyID { - try await InfoBase.updateBlock(body, content: text, on: connection) - } else { + return try await database.withConnection(for: target, password: password) { 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("目录来源缺少资料锚点。") } + var metadata = observation.metadata + if let record { + let current = try await currentRecord(record.id, anchor: anchor, on: connection) + if observation.text == nil { metadata.revision = current.metadata.revision } + if let text = observation.text { + if let body = current.bodyID { + try await InfoBase.updateBlock(body, content: text, on: connection) + } else { + let body = try await InfoBase.createBlock( + resolver: "core.text.v1", content: text, on: connection) + try await InfoBase.relate( + record.id, to: body, content: "body", on: connection) + } + } + try await InfoBase.updateBlock( + record.id, content: FileJSON.encode(metadata), on: connection) + } else { + // The Source lock also protects a retry after a successful commit with a lost reply. + let existing = try await connection.query( + "SELECT b.id FROM inkcre.relations r JOIN inkcre.blocks b ON b.id=r.to_ WHERE r.from_=\(anchor) AND r.content='manages' AND b.resolver=\(LocalFilesContract.resolver) AND b.content::jsonb->>'location'=\(metadata.location)", + logger: PeerDatabase.logger) + for try await _ in existing.decode(Int.self) { + throw PeerError.needsAction("资料已存在,正在重新核对目录。") + } + let root = try await InfoBase.createBlock( + resolver: LocalFilesContract.resolver, content: FileJSON.encode(metadata), + on: connection) + if let text = observation.text { let body = try await InfoBase.createBlock( resolver: "core.text.v1", content: text, on: connection) - try await InfoBase.relate( - record.id, to: body, content: "body", on: connection) + try await InfoBase.relate(root, to: body, content: "body", on: connection) } + try await InfoBase.relate(anchor, to: root, content: "manages", on: connection) } - try await InfoBase.updateBlock( - record.id, content: FileJSON.encode(metadata), on: connection) - } else { - // The Source lock also protects a retry after a successful commit with a lost reply. - let existing = try await connection.query( - "SELECT b.id FROM inkcre.relations r JOIN inkcre.blocks b ON b.id=r.to_ WHERE r.from_=\(anchor) AND r.content='manages' AND b.resolver=\(LocalFilesContract.resolver) AND b.content::jsonb->>'location'=\(metadata.location)", - logger: PeerDatabase.logger) - for try await _ in existing.decode(Int.self) { - throw PeerError.needsAction("资料已存在,正在重新核对目录。") - } - let root = try await InfoBase.createBlock( - resolver: LocalFilesContract.resolver, content: FileJSON.encode(metadata), - on: connection) - if let text = observation.text { - let body = try await InfoBase.createBlock( - resolver: "core.text.v1", content: text, on: connection) - try await InfoBase.relate(root, to: body, content: "body", on: connection) - } - try await InfoBase.relate(anchor, to: root, content: "manages", on: connection) } } } @@ -103,25 +113,26 @@ extension LocalFileClient { public func finishScan(source: DirectorySource, missing: [FileRecord], state: DirectoryState) async throws { - let client = try await pool() - 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("目录来源缺少资料锚点。") } - for record in missing { - var current = try await currentRecord(record.id, anchor: anchor, on: connection) - guard current.metadata.available, - current.metadata.location == record.metadata.location - else { continue } - current.metadata.available = false - current.metadata.observed_at = Date() - current.metadata.issue = nil - try await InfoBase.updateBlock( - record.id, content: FileJSON.encode(current.metadata), on: connection) + return try await database.withConnection(for: target, password: password) { 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("目录来源缺少资料锚点。") } + for record in missing { + var current = try await currentRecord(record.id, anchor: anchor, on: connection) + guard current.metadata.available, + current.metadata.location == record.metadata.location + else { continue } + current.metadata.available = false + current.metadata.observed_at = Date() + current.metadata.issue = nil + try await InfoBase.updateBlock( + record.id, content: FileJSON.encode(current.metadata), on: connection) + } + let json = try FileJSON.encode(state) + try await connection.query( + "UPDATE inkcre.sources SET state=\(json)::jsonb WHERE id=\(source.id)", + logger: PeerDatabase.logger) } - let json = try FileJSON.encode(state) - try await connection.query( - "UPDATE inkcre.sources SET state=\(json)::jsonb WHERE id=\(source.id)", - logger: PeerDatabase.logger) } } diff --git a/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift b/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift index d15d434..ad651d6 100644 --- a/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift +++ b/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift @@ -4,144 +4,161 @@ import PostgresNIO public struct LocalFileClient: Sendable { public let target: ConnectionTarget - private let password: String - private let database: PeerDatabase + let password: String + let database: PeerDatabase public init(target: ConnectionTarget, password: String, database: PeerDatabase = .shared) { self.target = target self.password = password self.database = database } - func pool() async throws -> PostgresClient { - try await database.client(for: target, password: password) - } public func prepare() async throws { - _ = try await database.register(target, password: password) + try await database.register(target, password: password) try await database.join( target, password: password, extension: LocalFilesContract.descriptor) - let client = try await pool() - 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( - """ - INSERT INTO inkcre.sources_types(id,description,config_schema,collect_config_schema) - VALUES (\(LocalFilesContract.sourceType),'本地文件目录',\(config)::jsonb,'{"type":"object","additionalProperties":false}') - ON CONFLICT(id) DO UPDATE SET config_schema=EXCLUDED.config_schema,collect_config_schema=EXCLUDED.collect_config_schema - """) - let parameters = - #"{"type":"object","additionalProperties":false,"properties":{"source":{"type":"integer"},"config":{"type":"object","default":{}}},"required":["source"]}"# - try await client.query( - """ - INSERT INTO inkcre.job_types(id,description,parameters_schema,default_timeout_seconds) - VALUES (\(SourceJobs.type),'Run one ordinary collection command for a configured Source.',\(parameters)::jsonb,300) - ON CONFLICT(id) DO NOTHING - """) + return try await database.withConnection(for: target, password: password) { 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( + """ + INSERT INTO inkcre.sources_types(id,description,config_schema,collect_config_schema) + VALUES (\(LocalFilesContract.sourceType),'本地文件目录',\(config)::jsonb,'{"type":"object","additionalProperties":false}') + ON CONFLICT(id) DO UPDATE SET config_schema=EXCLUDED.config_schema,collect_config_schema=EXCLUDED.collect_config_schema + """, logger: PeerDatabase.logger) + let parameters = + #"{"type":"object","additionalProperties":false,"properties":{"source":{"type":"integer"},"config":{"type":"object","default":{}}},"required":["source"]}"# + try await client.query( + """ + INSERT INTO inkcre.job_types(id,description,parameters_schema,default_timeout_seconds) + VALUES (\(SourceJobs.type),'Run one ordinary collection command for a configured Source.',\(parameters)::jsonb,300) + ON CONFLICT(id) DO NOTHING + """, logger: PeerDatabase.logger) + } + } + + public func checkParticipation() async throws { + return try await database.withConnection(for: target, password: password) { client in + try await client.withTransaction(logger: PeerDatabase.logger) { connection in + try await PeerDatabase.requireSelection( + LocalFilesContract.descriptor, peerID: target.peerID, on: connection) + } + } } public func sources() async throws -> [DirectorySource] { - let client = try await pool() - 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" - ) - var sources: [DirectorySource] = [] - for try await (id, config, block, state) in rows.decode((Int, String, Int?, String).self) { - sources.append(try decodeSource(id: id, config: config, block: block, state: state)) + return try await database.withConnection(for: target, password: password) { 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) + var sources: [DirectorySource] = [] + for try await (id, config, block, state) in rows.decode( + (Int, String, Int?, String).self) + { + sources.append(try decodeSource(id: id, config: config, block: block, state: state)) + } + return sources } - return sources } public func createDirectory(path: String) async throws -> DirectorySource { let config = DirectoryConfig(peer: target.peerID, path: path) _ = try PeerFileLocation(peer: config.peer, path: path) let serialized = try FileJSON.encode(config) - let client = try await pool() - return try await client.withTransaction(logger: PeerDatabase.logger) { connection in - try await PeerDatabase.requireSelection( - LocalFilesContract.descriptor, peerID: target.peerID, on: connection) - try await connection.query( - "SELECT pg_advisory_xact_lock(hashtextextended(\(target.peerID.uuidString + path),0))", - logger: PeerDatabase.logger) - let existing = try await connection.query( - "SELECT id FROM inkcre.sources WHERE type=\(LocalFilesContract.sourceType) AND config->>'peer'=\(target.peerID.uuidString.lowercased()) AND config->>'path'=\(path)", - logger: PeerDatabase.logger) - for try await _ in existing.decode(Int.self) { - throw PeerError.needsAction("这个目录已经添加。") - } - let rows = try await connection.query( - "INSERT INTO inkcre.sources(type,nickname,config) VALUES (\(LocalFilesContract.sourceType),\(URL(fileURLWithPath: path).lastPathComponent),\(serialized)::jsonb) RETURNING id", - logger: PeerDatabase.logger) - for try await id in rows.decode(Int.self) { - struct Anchor: Encodable { - let id: Int - let type: String - let nickname: String - } - let content = try FileJSON.encode( - Anchor(id: id, type: LocalFilesContract.sourceType, nickname: path)) - let block = try await InfoBase.createBlock( - resolver: "core.source.v1", content: content, on: connection) + return try await database.withConnection(for: target, password: password) { client in + return try await client.withTransaction(logger: PeerDatabase.logger) { connection in + try await PeerDatabase.requireSelection( + LocalFilesContract.descriptor, peerID: target.peerID, on: connection) try await connection.query( - "UPDATE inkcre.sources SET block=\(block) WHERE id=\(id)", + "SELECT pg_advisory_xact_lock(hashtextextended(\(target.peerID.uuidString + path),0))", + logger: PeerDatabase.logger) + let existing = try await connection.query( + "SELECT id FROM inkcre.sources WHERE type=\(LocalFilesContract.sourceType) AND config->>'peer'=\(target.peerID.uuidString.lowercased()) AND config->>'path'=\(path)", + logger: PeerDatabase.logger) + for try await _ in existing.decode(Int.self) { + throw PeerError.needsAction("这个目录已经添加。") + } + let rows = try await connection.query( + "INSERT INTO inkcre.sources(type,nickname,config) VALUES (\(LocalFilesContract.sourceType),\(URL(fileURLWithPath: path).lastPathComponent),\(serialized)::jsonb) RETURNING id", logger: PeerDatabase.logger) - return DirectorySource( - id: id, config: config, block: block, state: DirectoryState()) + for try await id in rows.decode(Int.self) { + struct Anchor: Encodable { + let id: Int + let type: String + let nickname: String + } + let content = try FileJSON.encode( + Anchor(id: id, type: LocalFilesContract.sourceType, nickname: path)) + let block = try await InfoBase.createBlock( + resolver: "core.source.v1", content: content, on: connection) + try await connection.query( + "UPDATE inkcre.sources SET block=\(block) WHERE id=\(id)", + logger: PeerDatabase.logger) + return DirectorySource( + id: id, config: config, block: block, state: DirectoryState()) + } + throw PeerError.connection("目录未能保存。") } - throw PeerError.connection("目录未能保存。") } } public func setEnabled(_ enabled: Bool, source: DirectorySource) async throws { - let client = try await pool() - try await client.withTransaction(logger: PeerDatabase.logger) { connection in - var current = try await locked( - source.id, on: connection, expected: source.config, requireEnabled: false) - current.config.enabled = enabled - let config = try FileJSON.encode(current.config) - try await connection.query( - "UPDATE inkcre.sources SET config=\(config)::jsonb WHERE id=\(source.id)", - logger: PeerDatabase.logger) + return try await database.withConnection(for: target, password: password) { 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) + current.config.enabled = enabled + let config = try FileJSON.encode(current.config) + try await connection.query( + "UPDATE inkcre.sources SET config=\(config)::jsonb WHERE id=\(source.id)", + logger: PeerDatabase.logger) + } } } public func remove(_ source: DirectorySource) async throws { - let client = try await pool() - try await client.withTransaction(logger: PeerDatabase.logger) { connection in - _ = try await locked( - source.id, on: connection, expected: source.config, requireEnabled: false) - try await connection.query( - "DELETE FROM inkcre.sources WHERE id=\(source.id)", logger: PeerDatabase.logger) + return try await database.withConnection(for: target, password: password) { client in + try await client.withTransaction(logger: PeerDatabase.logger) { connection in + _ = try await locked( + source.id, on: connection, expected: source.config, requireEnabled: false) + try await connection.query( + "DELETE FROM inkcre.sources WHERE id=\(source.id)", logger: PeerDatabase.logger) + } } } 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 [] } - let client = try await pool() - let rows = try await client.query( - """ - SELECT b.id,b.content,t.id FROM inkcre.relations managed JOIN inkcre.blocks b ON b.id=managed.to_ - LEFT JOIN inkcre.relations body ON body.from_=b.id AND body.content='body' - LEFT JOIN inkcre.blocks t ON t.id=body.to_ AND t.resolver='core.text.v1' - WHERE managed.from_=\(block) AND managed.content='manages' AND b.resolver=\(LocalFilesContract.resolver) ORDER BY b.id - """) - var records: [FileRecord] = [] - var seen: Set = [] - for try await (id, content, bodyID) in rows.decode((Int, String, Int?).self) { - guard seen.insert(id).inserted else { throw PeerError.invalid("文件资料存在重复正文关系。") } - records.append( - FileRecord( - id: id, metadata: try FileJSON.decode(FileMetadata.self, content), - bodyID: bodyID)) + return try await database.withConnection(for: target, password: password) { client in + let rows = try await client.query( + """ + SELECT b.id,b.content,t.id FROM inkcre.relations managed JOIN inkcre.blocks b ON b.id=managed.to_ + LEFT JOIN inkcre.relations body ON body.from_=b.id AND body.content='body' + LEFT JOIN inkcre.blocks t ON t.id=body.to_ AND t.resolver='core.text.v1' + WHERE managed.from_=\(block) AND managed.content='manages' AND b.resolver=\(LocalFilesContract.resolver) ORDER BY b.id + """, logger: PeerDatabase.logger) + var records: [FileRecord] = [] + var seen: Set = [] + for try await (id, content, bodyID) in rows.decode((Int, String, Int?).self) { + guard seen.insert(id).inserted else { throw PeerError.invalid("文件资料存在重复正文关系。") } + records.append( + FileRecord( + id: id, metadata: try FileJSON.decode(FileMetadata.self, content), + bodyID: bodyID)) + } + return records } - return records } public func text(for record: FileRecord) async throws -> String? { guard let body = record.bodyID else { return nil } - let rows = try await pool().query( - "SELECT content FROM inkcre.blocks WHERE id=\(body) AND resolver='core.text.v1'") - for try await text in rows.decode(String.self) { return text } - throw PeerError.needsAction("正文已移除,请刷新文件列表。") + return try await database.withConnection(for: target, password: password) { client in + let rows = try await client.query( + "SELECT content FROM inkcre.blocks WHERE id=\(body) AND resolver='core.text.v1'", + logger: PeerDatabase.logger) + for try await text in rows.decode(String.self) { return text } + throw PeerError.needsAction("正文已移除,请刷新文件列表。") + } } func locked( diff --git a/extensions/local-files/Sources/LocalFiles/Models.swift b/extensions/local-files/Sources/LocalFiles/Models.swift index 6d09ecb..7f45879 100644 --- a/extensions/local-files/Sources/LocalFiles/Models.swift +++ b/extensions/local-files/Sources/LocalFiles/Models.swift @@ -147,12 +147,14 @@ public struct FileObservation: Sendable { public struct DirectoryBinding: Codable, Sendable, Identifiable { public let source: Int - public let target: ConnectionTarget + public let connectionID: UUID + public let peerID: UUID public var bookmark: Data - public var id: String { "\(target.peerID).\(source)" } + public var id: String { "\(peerID).\(source)" } public init(source: Int, target: ConnectionTarget, bookmark: Data) { self.source = source - self.target = target + self.connectionID = target.id + self.peerID = target.peerID self.bookmark = bookmark } } diff --git a/extensions/local-files/Sources/LocalFilesMac/LocalFilesExtension.swift b/extensions/local-files/Sources/LocalFilesMac/LocalFilesExtension.swift index 4031b6b..3ab04c3 100644 --- a/extensions/local-files/Sources/LocalFilesMac/LocalFilesExtension.swift +++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesExtension.swift @@ -15,12 +15,5 @@ public final class LocalFilesExtension: NativeExtension { public func start() throws { try model.start() } public func stop() async throws { await model.stop() } public func showMain() { present?() } - public func setEnabled(_ enabled: Bool) async throws { - guard let target = try await context.connections.load() else { return } - do { - try await PeerDatabase.shared.setEnabled( - enabled, target: target, - password: context.credentials.read(for: target.id), extension: descriptor) - } catch { throw PeerDatabase.failure(error) } - } + } diff --git a/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift index 69bf525..728af41 100644 --- a/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift +++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift @@ -35,6 +35,7 @@ public final class LocalFilesModel: ObservableObject { private let context: PeerHostContext private var bindings: [DirectoryBinding] = [] private var monitors: [String: Monitor] = [:] + private var prepared: Set = [] private var timer: Task? private var recordsTask: Task? private var textTask: Task? @@ -79,7 +80,7 @@ public final class LocalFilesModel: ObservableObject { } public func hasAccess(_ source: DirectorySource) -> Bool { guard let target else { return false } - return bindings.contains { $0.source == source.id && $0.target.peerID == target.peerID } + return bindings.contains { $0.source == source.id && $0.peerID == target.peerID } } public func start() throws { @@ -87,6 +88,7 @@ public final class LocalFilesModel: ObservableObject { bindings = try JSONDecoder().decode( [DirectoryBinding].self, from: Data(contentsOf: bindingURL)) } + prepared.removeAll() running = true timer = Task { while !Task.isCancelled { @@ -103,6 +105,9 @@ public final class LocalFilesModel: ObservableObject { actionTask?.cancel() refreshTask?.cancel() timer?.cancel() + recordsTask?.cancel() + textTask?.cancel() + for monitor in monitors.values { monitor.task?.cancel() } await actionTask?.value await refreshTask?.value await timer?.value @@ -129,7 +134,8 @@ public final class LocalFilesModel: ObservableObject { sources = [] records = [] issue = nil - // A coalesced old refresh must settle before the new selection is refreshed. + // Cancel old reads before refreshing the new selection. + refreshTask?.cancel() await refreshTask?.value await refresh() } @@ -152,8 +158,10 @@ public final class LocalFilesModel: ObservableObject { guard running else { return } loading = sources.isEmpty defer { loading = false } + let saved: ConnectionSettings.SavedConnections do { - let current = try await context.connections.load() + saved = try await context.connections.loadAll() + let current = saved.current if current != target { recordsTask?.cancel() selectedSource = nil @@ -166,64 +174,85 @@ public final class LocalFilesModel: ObservableObject { issue = error.localizedDescription return } - var targets = Dictionary( - bindings.map { ($0.target.id, $0.target) }, uniquingKeysWith: { _, newer in newer }) - if let target { targets[target.id] = target } + let targets = saved.connections.filter { connection in + connection.id == target?.id + || bindings.contains { + $0.connectionID == connection.id && $0.peerID == connection.peerID + } + } + for binding in bindings + where !targets.contains(where: { + $0.id == binding.connectionID && $0.peerID == binding.peerID + }) { + await stopMonitor(binding.id) + statuses[binding.id] = "原目标连接不可用,请恢复该信息库的连接。" + } if target == nil { sources = [] records = [] } - for connection in targets.values { + await withTaskGroup(of: Void.self) { group in + for connection in targets { + group.addTask { await self.refresh(connection) } + } + } + } + + private func refresh(_ connection: ConnectionTarget) async { + guard running, !Task.isCancelled else { return } + defer { if target?.id == connection.id { loading = false } } + do { + let client = try await client(connection) + try await prepare(client) + try await client.checkParticipation() + let found = try await client.sources() guard running, !Task.isCancelled else { return } - do { - let client = try client(connection) - try await client.prepare() - let found = try await client.sources() - guard running, !Task.isCancelled else { return } - if target?.id == connection.id { - let old = selectedSource - sources = found - selectedSource = found.contains(where: { $0.id == old }) ? old : found.first?.id - issue = nil + if target?.id == connection.id { + let old = selectedSource + sources = found + selectedSource = found.contains(where: { $0.id == old }) ? old : found.first?.id + issue = nil + loadRecords() + } + for binding in bindings.filter({ $0.connectionID == connection.id }) { + guard let source = found.first(where: { $0.id == binding.source }), + source.config.enabled + else { + await stopMonitor(binding.id) + continue } - for binding in bindings.filter({ $0.target.id == connection.id }) { - guard let source = found.first(where: { $0.id == binding.source }), - source.config.enabled - else { - await stopMonitor(binding.id) + if monitors[binding.id]?.source.config != source.config { + await stopMonitor(binding.id) + } + if monitors[binding.id] == nil { + do { try startMonitor(binding, source: source, target: connection) } catch { + statuses[binding.id] = error.localizedDescription continue } - if monitors[binding.id]?.source.config != source.config { - await stopMonitor(binding.id) - } - if monitors[binding.id] == nil { - do { try startMonitor(binding, source: source) } catch { - statuses[binding.id] = error.localizedDescription - continue - } - } - if let monitor = monitors[binding.id], monitor.task == nil { - let jobs = try await client.pendingScans(source) - if !jobs.isEmpty || monitor.dirty { schedule(binding.id) } - } - } - for source in found - where !bindings.contains(where: { $0.id == key(source.id, connection) }) { - statuses[key(source.id, connection)] = "需要选择目录以恢复访问" } - } catch { - let message = PeerDatabase.failure(error).localizedDescription - if target?.id == connection.id { issue = message } - for binding in bindings.filter({ $0.target.id == connection.id }) { - await stopMonitor(binding.id) - statuses[binding.id] = message + if let monitor = monitors[binding.id], monitor.task == nil { + let jobs = try await client.pendingScans(source) + if !jobs.isEmpty || monitor.dirty { schedule(binding.id) } } } + for source in found + where !bindings.contains(where: { $0.id == key(source.id, connection) }) { + statuses[key(source.id, connection)] = "需要选择目录以恢复访问" + } + } catch { + guard running, !Task.isCancelled else { return } + let message = PeerDatabase.failure(error).localizedDescription + if target?.id == connection.id { issue = message } + for binding in bindings.filter({ $0.connectionID == connection.id }) { + await stopMonitor(binding.id) + statuses[binding.id] = message + } } - loadRecords() } - private func startMonitor(_ binding: DirectoryBinding, source: DirectorySource) throws { + private func startMonitor( + _ binding: DirectoryBinding, source: DirectorySource, target: ConnectionTarget + ) throws { guard running, !Task.isCancelled else { throw CancellationError() } var stale = false let root = try URL( @@ -234,7 +263,7 @@ public final class LocalFilesModel: ObservableObject { == URL(fileURLWithPath: source.config.path).resolvingSymlinksInPath().path else { throw PeerError.needsAction("目录位置已改变,请重新选择目录。") } let scoped = root.startAccessingSecurityScopedResource() - let monitor = Monitor(source: source, target: binding.target, root: root, scoped: scoped) + let monitor = Monitor(source: source, target: target, root: root, scoped: scoped) do { if stale, let index = bindings.firstIndex(where: { $0.id == binding.id }) { bindings[index].bookmark = try root.bookmarkData( @@ -278,7 +307,7 @@ public final class LocalFilesModel: ObservableObject { statuses[id] = "正在扫描…" var job: SourceJob? let client: LocalFileClient - do { client = try self.client(monitor.target) } catch { + do { client = try await self.client(monitor.target) } catch { statuses[id] = error.localizedDescription monitor.dirty = true break @@ -319,16 +348,15 @@ public final class LocalFilesModel: ObservableObject { statuses[id] = "\(message);状态暂未写回信息库" } } - if let job { + if let job, !Task.isCancelled, running { let status = Date() >= job.deadline ? "timed_out" : PeerDatabase.isCancellation(error) ? "aborted" : "failed" - let closing = Task { + do { try await client.close( job, status: status, state: DirectoryState(issue: message)) - } - do { try await closing.value } catch { + } catch { statuses[id] = "\(message);任务终态待信息库核对" } } @@ -344,12 +372,14 @@ public final class LocalFilesModel: ObservableObject { statuses[id] = "\(progress.completed)/\(progress.total) · \(progress.filename)" } - private func client(_ target: ConnectionTarget) throws -> LocalFileClient { - let active = self.target - let connection = - active?.peerID == target.peerID && active?.host == target.host - && active?.port == target.port - && active?.database == target.database ? active! : target + private func prepare(_ client: LocalFileClient) async throws { + guard !prepared.contains(client.target.id) else { return } + try await client.prepare() + prepared.insert(client.target.id) + } + + private func client(_ target: ConnectionTarget) async throws -> LocalFileClient { + let connection = try await context.connections.resolve(target) return LocalFileClient( target: connection, password: try context.credentials.read(for: connection.id)) } @@ -459,8 +489,8 @@ public final class LocalFilesModel: ObservableObject { } let bookmark = try url.bookmarkData( options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) - let client = try client(target) - try await client.prepare() + let client = try await client(target) + try await prepare(client) guard running, !Task.isCancelled else { throw CancellationError() } let directory: DirectorySource if let source { diff --git a/scripts/build-app.sh b/scripts/build-app.sh index be68262..2c2cb24 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 -CFBundleVersion17 +CFBundleVersion19 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication