Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Packages/PeerCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

调用方创建 `PeerDatabase(applicationVersion:name:labels:)`,通过显式 `ConnectionTarget` 与凭据发起操作,结束时 `await database.shutdown()`。构造本身不联网、不注册 Peer;注册和租约是明确的方法调用。借用数据库的 `CaptureWriter` / `LocalFilesRepository` 不关闭它;第一方 App 将一个实例放入 `PeerHostContext`,在各扩展停止后统一关闭。独立消费者可以创建彼此隔离的实例,不继承 App 的 Bundle 身份、机器名称或进程单例。

持续运行的 Keychain 消费者传递 `DatabaseCredentials.keychain(store)`。`PeerDatabase` 在没有会话时取得一次密码,后续数据库操作与续租直接复用已授权 client,不查询 Keychain。`.password` 和 `password:` 入口同样只提供冷启动凭据;它们不能让旧业务引用覆盖新认证或把运行角色切回旧配置。显式更新认证使用 `database.authorize(target, password:)`,相同密码与数据库配置继续复用 client;`database.invalidate(id)` 或 `shutdown()` 结束会话。独立 Core 消费者应自行持有并关闭数据库实例。

App 的 Host、Capture 和 Local Files 共享同一个 `PeerHostContext` 与数据库实例。连接表单及 Capture 待办的密码修复通过 `context.savePassword` 持久化并更新该会话;相同密码及只修改名称/Web 地址继续复用 client。连接表单留空密码保存时通过 `context.reauthorize` 明确重新取密;同密码继续复用 client,失败则撤销会话。Keychain 锁定、删项或外部修改不撤销已建立的会话,重新授权或下次启动才重新检查。

`CredentialStore.read` 始终直接读取,不自行缓存。现有凭据继续保存在原 file-based Keychain,不迁移存储或引入对 SDK 宿主签名/entitlement 的新要求。`CredentialStore(service:keychain:)` 可将访问限定到明确的文件型密钥串;省略 keychain 时沿用系统搜索列表。

`PeerMacUI` 是同一 package 中的应用 UI product,提供宿主 UI 协作和连接表单编辑。仅链接 `PeerCore` 不会拉入该 target。窗口、表单输入与授权交互策略由 App/PeerMacUI 拥有;`NativeExtension` 只要求业务生命周期,不要求显示主窗口。具体扩展的界面仍属于相应扩展。

本次候选尚未发布,`PeerDatabase.shared` 已删除。扩展写入与数据访问接口的构造函数要求传入数据库;表单型 `ConnectionSettings.saveEdited` 的消费者需 `import PeerMacUI`。数据库协议、图形状与扩展业务身份保持原合同。
Expand Down
20 changes: 17 additions & 3 deletions Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ extension PeerDatabase {
public func join(
_ target: ConnectionTarget, password: String,
extension descriptor: NativeExtensionDescriptor
) async throws {
try await join(target, credentials: .password(password), extension: descriptor)
}

public func join(
_ target: ConnectionTarget, credentials: DatabaseCredentials,
extension descriptor: NativeExtensionDescriptor
) async throws {
let defaults = UserDefaults(suiteName: "dev.inkcre.native-participation")!
let key = "\(target.peerID).\(descriptor.name)"
let firstJoin = !defaults.bool(forKey: key)
let issue: String? = try await withConnection(for: target, password: password) {
let issue: String? = try await withConnection(for: target, credentials: credentials) {
connection in
try await connection.withTransaction(logger: Self.logger) { connection in
if firstJoin {
Expand Down Expand Up @@ -60,8 +67,15 @@ extension PeerDatabase {
public func useBundledVersion(
_ descriptor: NativeExtensionDescriptor, target: ConnectionTarget, password: String
) async throws {
try await register(target, password: password)
try await withConnection(for: target, password: password) { connection in
try await useBundledVersion(descriptor, target: target, credentials: .password(password))
}

public func useBundledVersion(
_ descriptor: NativeExtensionDescriptor, target: ConnectionTarget,
credentials: DatabaseCredentials
) async throws {
try await register(target, credentials: credentials)
try await withConnection(for: target, credentials: credentials) { connection in
try await connection.withTransaction(logger: Self.logger) { connection in
try await connection.query(
"INSERT INTO inkcre.extensions(name) VALUES (\(descriptor.name)) ON CONFLICT (name) DO NOTHING",
Expand Down
27 changes: 25 additions & 2 deletions Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,34 @@ public struct PeerHostContext: Sendable {
public let directory: URL
public let connections: ConnectionSettings
public let credentials: CredentialStore
public init(identity: String, directory: URL, database: PeerDatabase = PeerDatabase()) {
public init(
identity: String, directory: URL, database: PeerDatabase = PeerDatabase(),
credentials: CredentialStore? = nil
) {
self.database = database
self.identity = identity
self.directory = directory
connections = ConnectionSettings(url: directory.appendingPathComponent("connection.json"))
credentials = CredentialStore(service: "\(identity).connections")
self.credentials = credentials ?? CredentialStore(service: "\(identity).connections")
}

/// Saving through the Host updates the same session used by every bundled extension.
public func savePassword(_ password: String, for target: ConnectionTarget) async throws {
try target.validate()
try credentials.save(password, for: target.id, allowInteraction: true)
try await database.authorize(target, password: password)
}

/// Explicitly reread Keychain; unchanged authentication keeps its client, failure ends the session.
public func reauthorize(_ target: ConnectionTarget, allowInteraction: Bool = true) async throws
{
try target.validate()
do {
let password = try credentials.read(for: target.id, allowInteraction: allowInteraction)
try await database.authorize(target, password: password)
} catch {
await database.invalidate(target.id)
throw error
}
}
}
106 changes: 73 additions & 33 deletions Packages/PeerCore/Sources/PeerCore/Credentials.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,75 @@ import Security

public struct CredentialStore: Sendable {
private let service: String
private let keychain: KeychainReference?
private static let accessLock = NSLock()
/// An explicit keychain scopes independent consumers and disposable acceptance fixtures.
public init(service: String, keychain: SecKeychain? = nil) {
self.service = service
self.keychain = keychain.map(KeychainReference.init)
}

public init(service: String) { self.service = service }
private final class KeychainReference: @unchecked Sendable {
// Security.framework keychain references are thread-safe; all accesses also use accessLock.
let value: SecKeychain
init(_ value: SecKeychain) { self.value = value }
}

public func save(_ secret: String, for connection: UUID) throws {
Self.accessLock.lock()
defer { Self.accessLock.unlock() }
guard !secret.isEmpty else {
throw PeerError.invalid("请输入数据库密码。")
}
let query: [String: Any] = [
private var query: [String: Any] {
var result: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: connection.uuidString,
]
let data = Data(secret.utf8)
var status = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary)
if status == errSecItemNotFound {
var item = query
item[kSecValueData as String] = data
status = SecItemAdd(item as CFDictionary, nil)
if let keychain { result[kSecMatchSearchList as String] = [keychain.value] }
return result
}

public func save(_ secret: String, for connection: UUID, allowInteraction: Bool = false) throws
{
try Self.withAccess(allowInteraction: allowInteraction) {
guard !secret.isEmpty else {
throw PeerError.invalid("请输入数据库密码。")
}
var query = query
query[kSecAttrAccount as String] = connection.uuidString
let data = Data(secret.utf8)
var status = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary)
if status == errSecItemNotFound {
var item = query
item.removeValue(forKey: kSecMatchSearchList as String)
if let keychain { item[kSecUseKeychain as String] = keychain.value }
item[kSecValueData as String] = data
status = SecItemAdd(item as CFDictionary, nil)
}
guard status == errSecSuccess else {
throw PeerError.storage("Keychain 保存失败(\(status))。")
}
}
guard status == errSecSuccess else { throw PeerError.storage("Keychain 保存失败(\(status))。") }
}

public func read(for connection: UUID, allowInteraction: Bool = false) throws -> String {
try Self.withAccess(allowInteraction: allowInteraction) {
var query = query
query[kSecAttrAccount as String] = connection.uuidString
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecInteractionNotAllowed || status == errSecAuthFailed {
throw PeerError.needsAction("凭据暂不可用,请在连接设置中解锁密钥串或重新保存密码。")
}
guard status == errSecSuccess, let data = result as? Data,
let secret = String(data: data, encoding: .utf8)
else {
throw PeerError.needsAction("无法读取此信息库的凭据,请更新数据库密码。")
}
return secret
}
}

static func withAccess<Result>(allowInteraction: Bool, _ body: () throws -> Result) throws
-> Result
{
Self.accessLock.lock()
defer { Self.accessLock.unlock() }
// File-based Keychain ignores LAContext; serialize and restore its process-wide UI policy.
Expand All @@ -37,23 +80,20 @@ public struct CredentialStore: Sendable {
SecKeychainSetUserInteractionAllowed(allowInteraction) == errSecSuccess
else { throw PeerError.storage("无法设置密钥串访问方式。") }
defer { SecKeychainSetUserInteractionAllowed(previous.boolValue) }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: connection.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecInteractionNotAllowed || status == errSecAuthFailed {
throw PeerError.needsAction("凭据暂不可用,请在连接设置中解锁密钥串或重新保存密码。")
}
guard status == errSecSuccess, let data = result as? Data,
let secret = String(data: data, encoding: .utf8)
else {
throw PeerError.needsAction("无法读取此信息库的凭据,请更新数据库密码。")
return try body()
}
}

/// A bootstrap credential source. PeerDatabase resolves it only when no session exists.
public enum DatabaseCredentials: Sendable {
case password(String)
case keychain(CredentialStore)

/// One-off access for consumers that do not own a reusable database authentication session.
public func read(for connection: UUID) throws -> String {
switch self {
case .password(let password): return password
case .keychain(let store): return try store.read(for: connection)
}
return secret
}
}
66 changes: 59 additions & 7 deletions Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,42 @@ public actor PeerDatabase {
}

public func client(for target: ConnectionTarget, password: String) throws -> PostgresClient {
try client(for: target, credentials: .password(password))
}

/// Bootstrap credentials are ignored once a session exists; only authorize changes its password.
public func client(for target: ConnectionTarget, credentials: DatabaseCredentials) throws
-> PostgresClient
{
try target.validate()
if let pool = pools[target.id], pool.target.host == target.host,
// An established session owns authentication. Old repositories may still carry old
// bootstrap credentials; ordinary work must never turn those into an authentication update.
if let pool = pools[target.id] {
guard pool.target.hasSameDestination(as: target) else {
throw PeerError.invalid("已授权连接不能改投另一个信息库,请创建新连接。")
}
return pool.client
}
return try authorize(target, password: credentials.read(for: target.id))
}

/// Explicitly replace session authentication. Reusing the same password preserves the client.
@discardableResult
public func authorize(_ target: ConnectionTarget, password: String) throws -> PostgresClient {
try target.validate()
let previous = pools[target.id]
if let pool = previous, pool.target.host == target.host,
pool.target.port == target.port, pool.target.database == target.database,
pool.target.username == target.username, pool.target.tls == target.tls,
pool.password == password
{
pools[target.id] = Pool(
Comment thread
xiaoland marked this conversation as resolved.
Resolved
target: target,
password: password, client: pool.client, task: pool.task,
registered: pool.registered)
return pool.client
}
let previous = pools.removeValue(forKey: target.id)
pools.removeValue(forKey: target.id)
previous?.task.cancel()
var configuration = PostgresClient.Configuration(
host: target.host, port: target.port, username: target.username, password: password,
Expand All @@ -51,12 +78,18 @@ public actor PeerDatabase {
let client = PostgresClient(configuration: configuration, backgroundLogger: Self.logger)
let task = Task { await client.run() }
pools[target.id] = Pool(
target: target, password: password, client: client, task: task,
target: target,
password: password, client: client, task: task,
registered: previous?.registered == true
&& previous?.target.hasSameDestination(as: target) == true)
return client
}

/// End this owner's authorization. The next operation must obtain credentials again.
public func invalidate(_ connection: UUID) {
pools.removeValue(forKey: connection)?.task.cancel()
}

public func shutdown() async {
let tasks = pools.values.map(\.task)
pools.removeAll()
Expand All @@ -65,12 +98,17 @@ public actor PeerDatabase {
}

public func register(_ target: ConnectionTarget, password: String) async throws {
let client = try client(for: target, password: password)
try await register(target, credentials: .password(password))
}

public func register(_ target: ConnectionTarget, credentials: DatabaseCredentials) async throws
{
let client = try client(for: target, credentials: credentials)
if pools[target.id]?.registered == true { return }
let name = self.name
let labels = self.labels
let applicationVersion = self.applicationVersion
try await withConnection(for: target, password: password) { connection in
try await withConnection(for: target, credentials: credentials) { connection in
try await connection.withTransaction(logger: Self.logger) { connection in
try await connection.query(
"""
Expand All @@ -90,7 +128,21 @@ public actor PeerDatabase {
for target: ConnectionTarget, password: String,
_ operation: @escaping @Sendable (PostgresConnection) async throws -> Result
) async throws -> Result {
let client = try await client(for: target, password: password)
try await withConnection(for: target, credentials: .password(password), operation)
}

public nonisolated func withConnection<Result: Sendable>(
for target: ConnectionTarget, credentials: DatabaseCredentials,
_ operation: @escaping @Sendable (PostgresConnection) async throws -> Result
) async throws -> Result {
let client = try await client(for: target, credentials: credentials)
return try await withConnection(client: client, operation)
}

private nonisolated func withConnection<Result: Sendable>(
client: PostgresClient,
_ operation: @escaping @Sendable (PostgresConnection) async throws -> Result
) async throws -> Result {
return try await withThrowingTaskGroup(of: Result.self) { group in
group.addTask {
try await client.withConnection { connection in
Expand Down Expand Up @@ -120,7 +172,7 @@ public actor PeerDatabase {
for (id, pool) in pools where pool.registered {
group.addTask {
do {
try await self.withConnection(for: pool.target, password: pool.password) {
try await self.withConnection(client: pool.client) {
connection in
let rows = try await connection.query(
"SELECT inkcre.renew_peer_lease(\(pool.target.peerID),120)",
Expand Down
16 changes: 9 additions & 7 deletions Packages/PeerCore/Sources/PeerMacUI/ConnectionEditing.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import Foundation
import PeerCore

extension ConnectionSettings {
extension PeerHostContext {
public func saveEdited(
host: String, port: String, database: String, username: String, tls: ConnectionTarget.TLS,
web: String, password: String, name: String = "", previous: ConnectionTarget?,
credentials: CredentialStore
) throws -> ConnectionTarget {
web: String, password: String, name: String = "", previous: ConnectionTarget?
) async throws -> ConnectionTarget {
let host = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard let port = Int(port) else { throw PeerError.invalid("数据库端口无效。") }
let web = web.trimmingCharacters(in: .whitespacesAndNewlines)
Expand All @@ -21,9 +20,12 @@ extension ConnectionSettings {
name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? host : name.trimmingCharacters(in: .whitespacesAndNewlines))
try target.validate()
if !password.isEmpty { try credentials.save(password, for: target.id) }
_ = try credentials.read(for: target.id, allowInteraction: true)
try save(target)
if !password.isEmpty {
try await savePassword(password, for: target)
} else {
try await reauthorize(target)
}
try await connections.save(target)
return target
}
}
Loading
Loading